kuma0319のブログ

webエンジニアになるためのアウトプットブログ

Ruby on Railsチュートリアル第11章

Railsチュートリアルの第11章を進めていきます。

第11章(アカウントの有効化)

AccountActivationsリソース

  • セッションと同様に一連の操作をリソースとしてモデル化。
  • アカウントの有効化では、有効化リンクをメールに含めてユーザーに送信する。メールをユーザーがクリックしてページを表示(GET)するという特性上、有効化情報を更新する(PATCH)がupdateアクションではなくeditアクションを使用する。
  • Railsにはhas_secure_tokenというメソッドがあるが、これはハッシュ化されていないトークンをデータベースへ保存してしまう。そのた、三者がデータベースからトークンを盗み有効化する可能性を否定できない。
  • 記憶トークンと同様に、仮想的な属性を使用して、ハッシュ化したトークンをデータベースへ保存する実装の方がより堅牢となる。
AccountActivationsデータモデル

ユーザーのモデルには、ハッシュ化したトークン(activation_digest)、有効化されているか真偽値を返すactivated、有効化された日時を返すactivated_atが追加される。

カラム名 データ型
id integer
name string
email 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種類のビューテンプレートが生成。
メーラーのテスト
  1. fixtureデータをuser変数に代入し、有効化トークンを追加。
  2. アカウント有効化メールをmailへ代入。
  3. assert_equalでメールの題名、toとfromのメールアドレスが一致してるか確認。
  4. 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引数に含まれているかどうかを検証。 チュートリアル内の例だと、

  1. 文字列foobarの中にfooは含まれるためtrue。
  2. 文字列foobarの中にbazは含まれないためfalse。
  3. 正規表現 /\w+/は、「wがすべての半角英数字とアンダースコア文字列で+がそれを1個以上繰り返す」で、foobarの中には半角英数字が含まれるためtrue。
  4. $#!*+@の中には半角英数字もアンダースコアも含まれないため、true。
assert_match 'foo', 'foobar'      # true
assert_match 'baz', 'foobar'      # false
assert_match /\w+/, 'foobar'      # true
assert_match /\w+/, '$#!*+@'      # false

アカウント有効化

メタプログラミング、sendメソッド

プログラムでプログラムを作成することを「メタプログラミング」と呼ぶ。

sendメソッドは、渡されたオブジェクトへメッセージを送ることでメソッドを動的に決定可能。
user.activation_digestuser.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章終わり🐻