Ruby on Railsチュートリアル第11章
第11章(アカウントの有効化)
AccountActivationsリソース
- セッションと同様に一連の操作をリソースとしてモデル化。
- アカウントの有効化では、有効化リンクをメールに含めてユーザーに送信する。メールをユーザーがクリックしてページを表示(GET)するという特性上、有効化情報を更新する(PATCH)がupdateアクションではなくeditアクションを使用する。
- Railsにはhas_secure_tokenというメソッドがあるが、これはハッシュ化されていないトークンをデータベースへ保存してしまう。そのた、第三者がデータベースからトークンを盗み有効化する可能性を否定できない。
- 記憶トークンと同様に、仮想的な属性を使用して、ハッシュ化したトークンをデータベースへ保存する実装の方がより堅牢となる。
AccountActivationsデータモデル
ユーザーのモデルには、ハッシュ化したトークン(activation_digest
)、有効化されているか真偽値を返すactivated
、有効化された日時を返すactivated_at
が追加される。
カラム名 | データ型 |
---|---|
id | integer |
name | string |
string | |
created_at | datetime |
updated_at | datetime |
password_digest | string |
remember_digest | string |
admin | boolean |
activation_digest | string |
activated | boolean |
activated_at | datetime |
AccountActivationsのbeforeアクション
有効化トークンやダイジェストはユーザーオブジェクトが作成される前に生成されておく必要がある。(アカウント有効化→作成の手順)
before_createでトークンやダイジェストを生成すればok。
[app/models/user.rb] def create_activation_digest self.activation_token = User.new_token self.activation_digest = User.digest(activation_token) end
記憶トークンのbeforeメソッドと酷似しているが、記憶ダイジェストは既にモデルで紐づいて生成されているものを更新(update)するのに対して、アクティベーショントークンはまだ無い要素を生成している。
[app/models/user.rb] def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) remember_digest end
アカウント有効化のメール送信
メーラー
- メーラーはコントローラと同じように生成可能。
rails generate mailer UserMailer account_activation password_reset
- 生成したメーラーごとにテキストメール用とHTML用の2種類のビューテンプレートが生成。
メーラーのテスト
- fixtureデータをuser変数に代入し、有効化トークンを追加。
- アカウント有効化メールをmailへ代入。
- assert_equalでメールの題名、toとfromのメールアドレスが一致してるか確認。
- assert_matchで文字列を正規表現でテスト。
[test/mailers/user_mailer_test.rb] test "account_activation" do user = users(:michael) user.activation_token = User.new_token mail = UserMailer.account_activation(user) assert_equal "Account activation", mail.subject assert_equal [user.email], mail.to assert_equal ["user@realdomain.com"], mail.from assert_match user.name, mail.body.encoded assert_match user.activation_token, mail.body.encoded assert_match CGI.escape(user.email), mail.body.encoded end
assert_matchについて
assert_match
は、第1引数に正規表現を取って、第2引数に文字列を取り、第1引数の正規表現の値が第2引数に含まれているかどうかを検証。
チュートリアル内の例だと、
- 文字列
foobar
の中にfoo
は含まれるためtrue。 - 文字列
foobar
の中にbaz
は含まれないためfalse。 - 正規表現
/\w+/
は、「wがすべての半角英数字とアンダースコア文字列で+がそれを1個以上繰り返す」で、foobar
の中には半角英数字が含まれるためtrue。 $#!*+@
の中には半角英数字もアンダースコアも含まれないため、true。
assert_match 'foo', 'foobar' # true assert_match 'baz', 'foobar' # false assert_match /\w+/, 'foobar' # true assert_match /\w+/, '$#!*+@' # false
アカウント有効化
メタプログラミング、sendメソッド
プログラムでプログラムを作成することを「メタプログラミング」と呼ぶ。
sendメソッドは、渡されたオブジェクトへメッセージを送ることでメソッドを動的に決定可能。
user.activation_digest
、user.send(:activation_digest)
、user.send("activation_digest")
は全て等価。更に、activationのシンボル(:activation)を適当な変数(attribute)に代入すると、user.send("#{attribute}_digest")
のようにも記述可能。
つまり、sendメソッドに渡すメッセージの中身を都度必要なものを利用することで、欲しいメソッドを動的に変えられる。
def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) end
このauthenticated?メソッドは、remember_digestとremember_tokenを一般化して、digestの中身はメタプログラミングで動的に変えられるようになっている。user.authenticated?(:remember, remember_token)
の形で呼び出すとこれまで通りの動作となる。
def authenticated?(attribute, token) digest = self.send("#{attribute}_digest") return false if digest.nil? BCrypt::Password.new(digest).is_password?(token) end
editアクション
有効化リンクのメールは、edit_account_activation_url(@user.activation_token, email: @user.email)
の名前付きルーティングによって、https://www.example.com/account_activations/#activation_token#/edit
といった形のURLが生成される。一方で、edit_user_url(user)
の名前付きルーティングではhttps://www.example.com/users/1/edit
のようなURLが生成される。つまり、idの部分がactivation_tokenに該当するため、この情報はparams[:id]
で持ってくることができる。
editアクションの中身の!user.activated? && user.authenticated?(:activation, params[:id])
の部分は、userが既に有効化されていないことを確認した上で、認証している。認証するとログイン状態となるため、既に使用した有効化リンクを盗み出して不正ログインするのを防ぐために重要。
update時にパスワードが無い場合はバリデーションエラーで失敗するため、update_attributeで更新をおこなう。
[app/controllers/account_activations_controller.rb] def edit user = User.find_by(email: params[:email]) if user && !user.activated? && user.authenticated?(:activation, params[:id]) user.update_attribute(:activated, true) user.update_attribute(:activated_at, Time.zone.now) log_in user flash[:success] = "Account activated!" redirect_to user else flash[:danger] = "Invalid activation link" redirect_to root_url end end
ユーザー有効化のテスト
メール配信が噛むテストにおいては、setupメソッドにActionMailer::Base.deliveries.clear
を入れておくことでテスト前に配列を空にしておくことが出来る。
有効化する際のメール配信はassert_equal 1, ActionMailer::Base.deliveries.size
とすることで、配信されたメールが1件であることを確認可能。
第11章ー演習ー
11.1.1
①現時点でテストスイートを実行すると green になることを確認してみましょう。
(解答) greenになる。
②表 11.2の名前付きルーティングにpathではなくurlを使っている理由を考えてみましょう。(ヒント: 私たちはこれから名前付きルーティングをメールで使います。)
(解答) 通常の相対パスはrailsのアプリケーション内であれば正しく認識出来るが、アプリケーション外からのアクセスとなるため、完全なURLを渡せる絶対パスとする必要がある。
11.1.2
①本節の変更を加えた後も、テストスイートが引き続き green になることを確認してみましょう。
(解答) greenのまま。
②コンソールからUserクラスのインスタンスを生成し、そのオブジェクトからcreate_activation_digestメソッドを呼び出そうとすると、PrivateメソッドなのでNoMethodErrorが発生することを確認してみましょう。また、そのUserオブジェクト内のダイジェストの値も確認してみましょう。
(解答) (NoMethodError) が発生。digestはnil。
irb(main):003:0> user.activation_digest => nil
③リスト 6.35で、メールアドレスの小文字化にはemail.downcase!という代入不要なメソッドもあることを学びました。このメソッドを使って、リスト 11.3のdowncase_emailメソッドを改良してみてください。また、うまく変更できれば、テストスイートは成功したままになっていることも確認してみてください。
(解答) 変更しても成功したまま。
def downcase_email email.downcase! end
11.2.1
①Railsコンソールを開き、CGIモジュールのescapeメソッド(リスト 11.15)でメールアドレスの文字列をエスケープできることを確認してみましょう。このメソッドで"Don't panic!"をエスケープすると、どんな結果になりますか?
(解答)
irb(main):001:0> CGI.escape("Don't panic!") => "Don%27t+panic%21"
11.2.2
①Railsのプレビュー機能を使って、ブラウザから先ほどのメールを表示してみてください。「Date」の欄にはどんな内容が表示されているでしょうか?
(解答) 今日の日付と時刻が表示される。
11.2.3
①この時点で、テストスイートが green になっていることを確認してみましょう。
(解答) greenになっている。
②リスト 11.20で使ったCGI.escapeの部分を削除すると、テストが red に変わることを確認してみましょう。
(解答) エスケープするとgreenで外すとredになる。
11.2.4
①新しいユーザーを登録したとき、リダイレクト先が適切なURLに変わったことを確認してみましょう。その後、Railsサーバーのログから送信メールの内容を確認してみてください。有効化トークンの値はどうなっていますか?
(解答)飛ばします。
②コンソールを開き、データベース上にユーザーが作成されたことを確認してみましょう。また、このユーザーはデータベース上にはいますが、有効化のステータスがfalseのままになっていることを確認してください。
(解答)飛ばします。
11.3.1
①コンソール内で新しいユーザーを作成してみてください。新しいユーザーの記憶トークンと有効化トークンはどのような値になっているでしょうか? また、各トークンに対応するダイジェストの値はどうなっているでしょうか?
(解答)
irb(main):002:0> user.remember_token => nil irb(main):003:0> user.remember_digest => nil irb(main):004:0> user.activation_token => "enSoDrbXKKWMfQCx-vE1Nw" irb(main):005:0> user.activation_digest => "$2a$12$yspioAUh6D/oCsR3mJ3zmukcQe0REGJO1YS.j7Zyc0qhYl/Zo0v2i"
②リスト 11.26で抽象化したauthenticated?メソッドを使って、先ほどの各トークン/ダイジェストの組み合わせで認証が成功することを確認してみましょう。
(解答) 最初は引数を:activation, :activation_token
と記載してしまっていたためfalseとなったが、文脈に合うように引数を(:activation, user.activation_token)
に変えたらtrueとなった。
irb(main):006:0> user.authenticated?(:activation, :activation_token) => false irb(main):007:0> user.authenticated?(:activation, user.activation_token) => true
11.3.2
①11.2.4で生成したメールに含まれているURLをRailsコンソールで調べてみてください。URLのどの部分に有効化トークンが含まれているでしょうか?
(解答) 飛ばします。
②先ほど見つけたURLをブラウザに貼り付けて、そのユーザーの認証に成功し、有効化できることを確認してみましょう。また、有効化ステータスがtrueになっていることをコンソールから確認してみてください。
(解答) 飛ばします。
11.3.3
①リスト 11.36にあるactivateメソッドはupdate_attributeを2回呼び出していますが、これは各行で1回ずつデータベースへ問い合わせしていることになります。リスト 11.40に記したテンプレートを使って、update_attributeの呼び出しを1回のupdate_columns呼び出しにまとめてみましょう。これでデータベースへの問い合わせが1回で済むようになります。変更後にテストを実行し、 green になることも確認してください。(注: update_columnsはバリデーションが実行されない上、update_attributeと異なりモデルのコールバックも行われないため、本チュートリアル以外で使用する際は注意が必要です。)
(解答)
def activate update_columns(activated: true, activated_at: Time.zone.now) end
②現在は、/usersのユーザーindexページを開くとすべてのユーザーが表示され、/users/:idのようにIDを指定すると個別のユーザーを表示できます。しかし考えてみれば、有効でないユーザーは表示する意味がありません。そこで、リスト 11.41のテンプレートを使って、この動作を変更してみましょう10 。なお、このテンプレートで使っているActive Recordのwhereメソッドについては、13.3.3でもう少し詳しく説明します。
(解答)
def index @users = User.where(activated: true).paginate(page: params[:page]) end def show @user = User.find(params[:id]) redirect_to root_url and return unless @user.activated? end
③ここまでの演習課題で変更したコードをテストするために、/usersと/users/:idの両方に対する統合テストを作成してみましょう。/usersテストのテンプレートはリスト 11.42に示されています(本節の手法をリスト 10.63に適用した結果も示されています)。users/:idについては、rails generate integration_test user_showを実行してから、リスト 11.43に示すように無効なfixtureユーザを追加してください。対応するテストのテンプレートはリスト 11.44に示されています。
(解答)
/usersの統合テスト
[test/integration/users_index_test.rb] test "should display only activated users" do # ページにいる最初のユーザーを無効化する。 # 無効なユーザーを作成するだけでは、 # Railsで最初のページに表示される保証がないので不十分 User.paginate(page: 1).first.toggle!(:activated) # /usersを再度取得して、無効化済みのユーザーが表示されていないことを確かめる get users_path # 表示されているすべてのユーザーが有効化済みであることを確かめる assigns(:users).each do |user| assert user.activated? end end
/users/:idの統合テスト
[test/integration/user_show_test.rb] require "test_helper" class UsersShowTest < ActionDispatch::IntegrationTest def setup @inactive_user = users(:inactive) @activated_user = users(:archer) end test "should redirect when user not activated" do get user_path(@inactive_user) assert_response :redirect assert_redirected_to root_url end test "should display user when activated" do get user_path(@activated_user) assert_response :success assert_template 'users/show' end end
11.4
①実際に本番環境でユーザー登録をしてみましょう。ユーザー登録時に入力したメールアドレスにメールは届きましたか?
②メールを受信できたら、実際にメールをクリックしてアカウントを有効化してみましょう。また、Render上のログを調べてみて、有効化に関するログがどうなっているのか調べてみてください。
飛ばします。
第11章ーまとめー
- Railsには
has_secure_token
というメソッドがあるが、これはハッシュ化していない生のトークンをデータベースへ保存するため、手動でダイジェスト化する実装とする方が安全。 - メーラーはコントローラと同様に生成可能でメーラーに対して2つのビュー(テキスト形式、HTML形式)が生成される。
assert_match
では第1引数の正規表現が第2引数に含まれているかどうかを検証する。- メタプログラミングとして
send
メソッドは渡すメッセージによってメソッドを動的に書き換えられる。 - 有効化メールには有効化トークンを含め、
params[:id]
の形で取得できる。 - 有効化の際には既に有効化されたアカウントでは無いことを確認することで、有効化リンクの乗っ取りなどを防ぐ。
第11章終わり🐻