kuma0319のブログ

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

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

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

第9章(高度なログイン機構)

Remember me機能

記憶トーク
  • 記憶トークンを使用した永続化セッションの実装は、has_secure_passwordと似た構成。
  • 記憶トークンはハッシュ値に変換した記憶ダイジェストとしてデータベースに保管し、照合もハッシュ値で比較する。
  • 記憶トークンの生成はランダムな文字列であれば、何でもよいが、RubyライブラリのSecureRandomモジュールのurlsafe_base64は22文字のランダムな文字列を生成するためちょうどよい。

has_secure_passwordで自動で生成されるpassword属性と同じように、remember_token属性をattr_accesorを用いて自分で作成する。
remember_tokenメソッドはローカル変数ではなくUserクラス自身(self)のメソッドとして作成する。
update_attributeで記憶ダイジェストを更新する。(記憶ダイジェストは8章のdigestメソッドを使用して作成)

[app/models/user.rb]
class User < ApplicationRecord
  attr_accessor :remember_token
  .
  .
  .
  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end
end
cookiesメソッド

sessionメソッドと同じようにハッシュとして扱う。sessionメソッドと違う点で気を付ける必要があるのが、sessionメソッドは取り込んだidを自動的に暗号化して一時cookiesに保管するが、cookiesメソッドはIDを生のテキストとしてcookiesに保管してしまう
そのため、cookies.encrypted[:user_id] = user.idとして暗号化cookiesの形で使用する。更に、期限を20年とするpermanentと組み合わせてcookies.permanent.encrypted[:user_id] = user.idとする。

トークンと記憶ダイジェストの比較について

ここの中身の以下の記述がだいぶ難しかったのでまとめます。 BCrypt::Password.new(remember_digest) == remember_token

チュートリアル内では以下のように書かれていました。

このコードをじっくり調べてみると、実に奇妙なつくりになっています。bcryptで暗号化されたパスワードを、トークンと直接比較しています。ということは、==で比較する際にダイジェストを復号化しているのでしょうか?しかし、bcryptのハッシュは復号化できないはずなので、復号化しているはずはありません。そこでbcrypt gemの新しいソースコードでセキュアなパスワードの処理部分を詳しく調べてみると、なんと、比較に使う==演算子が再定義されています。つまり、==による比較が次のコードと同等になります。
BCrypt::Password.new(remember_digest).is_password?(remember_token)
実際の比較では、==の代わりにis_password?という論理値メソッドが使われています。これで少し見えてきました。今から書くアプリケーションコードでもこれと同じ方法を使うことにしましょう。

そもそもここは一回読んだ段階で難易度が高く飛ばしていたのですが、改めて読んでもやはり分かりません。
渡されたトークンがユーザーの記憶ダイジェストと一致??といった感じです。

BCrypt::Password.new(remember_digest)の部分

第8章ではBCrypt::Password.create(string, cost: cost)でパスワードが生成されるとの内容がありました。ただ、Password.newの動作については説明が無かったのでコンソールで確かめます。
まずUser.firstの情報を変数userに入れて、rememberメソッドを実行し、remember_tokenremember_digestを作ります。

irb(main):001:0> user = User.first
  TRANSACTION (0.1ms)  begin transaction
  User Load (0.7ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=>                                                                     
#<User:0x00007fd25b9d8f48                                              
...                                                                    
irb(main):002:0> user.remember
  TRANSACTION (0.2ms)  SAVEPOINT active_record_1
  User Update (0.5ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2023-04-16 14:47:05.086543"], ["remember_digest", "$2a$12$xB74UACYS3Uue/1NRsIfSuxTT9IDqi3x5Ntp627iYu1BchcM45bRW"], ["id", 1]]                                                   
  TRANSACTION (0.2ms)  RELEASE SAVEPOINT active_record_1               
=> true          

ここにPassword.newを試してみます。

irb(main):004:0> BCrypt::Password.new(user.remember_digest)
=> "$2a$12$DcUMen5dUMWVykb9slC9QeDjr5Gz8ZLJtm7M1RDi7N9pWHCSBslpK"
irb(main):005:0> user.remember_digest
=> "$2a$12$DcUMen5dUMWVykb9slC9QeDjr5Gz8ZLJtm7M1RDi7N9pWHCSBslpK"

すると、user.remember_digestの中身がそのまま出てきました。これが私的には混乱しました。

irb(main):006:0> BCrypt::Password.new(user.remember_digest).class
=> BCrypt::Password
irb(main):007:0> user.remember_digest.class
=> String

文字列としては全く同じでもクラスのみが違っていました。remember_digestがstringクラスであったことを失念していました。。。
なので、BCrypt::Password.newの機能は引数の文字列をBCryptパスワード化することとなります。

.is_password?(remember_token)の部分

ここはチュートリアル内で==演算子では無いと明言してくれていました。

irb(main):003:0> BCrypt::Password.new(user.remember_digest).is_password?(user.remember_token)
=> true

is_password?は引数内の文字列をハッシュ化したものと等価(==)であるかを検査してくれるようです。
なので、 BCrypt::Password.new(remember_digest).is_password?(remember_token)は、文字列の記憶ダイジェストをBCryptパスワード化したものと、記憶トークンをハッシュ化したものが等価であることを確認していることになると思ってます。

remember(user)メソッドとcurrent_userの実装
remember(user)メソッド
  1. rememberメソッドで記憶ダイジェストを生成。
  2. 暗号化されたidの永続化クッキーを作成。
  3. 暗号化されたリメンバートークンのクッキーを作成。
[app/helpers/sessions_helper.rb]
  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember(user)
    user.remember
    cookies.permanent.encrypted[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end
current_userメソッド

現在のcurrent_userメソッドは@current_user ||= User.find_by(id: session[:user_id])で、一時セッションのidのみなので、永続セッションにも対応可能とする。

if session[:user_id]
  @current_user ||= User.find_by(id: session[:user_id])
elsif cookies.encrypted[:user_id]
  user = User.find_by(id: cookies.encrypted[:user_id])
  if user && user.authenticated?(cookies[:remember_token])
    log_in user
    @current_user = user
  end
end

短縮すると以下のようにも記述可能。確かにsessionとcookiesを1回ずつしか使用していないが上の方が分かりやすい。。。
意味としては、(user_id = session[:user_id])で、session[:user_id]をローカル変数user_idに代入しそれが存在すれば、という事らしい。

if (user_id = session[:user_id])
  @current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.encrypted[:user_id])
  user = User.find_by(id: user_id)
  if user && user.authenticated?(cookies[:remember_token])
    log_in user
    @current_user = user
  end
end
ユーザーを忘れる

記憶ダイジェストをnilで更新するメソッドを作成する。

[app/models/user.rb]
  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end

log_outヘルパーメソッドの中にforgetヘルパーメソッドを入れ込む。
forgetヘルパーメソッドはforgetしてから、cookiesを削除。

[app/helpers/sessions_helper.rb]
  # 永続的セッションを破棄する
  def forget(user)
    user.forget
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end

  # 現在のユーザーをログアウトする
  def log_out
    forget(current_user)
    reset_session
    @current_user = nil
  end
end
2つの小さなバグ

ここでは細かいところなので要点のみ押さえておきます。 ①同一のサイトを複数タブで開いた場合に、両方のタブでログアウトしようとした場合に、current_usernilとなり、log_outメソッドがエラーとなる。
(解決策)logged_in?メソッドがtrueの場合のみlog_outメソッドを呼び出す。 ②複数種のブラウザからログインし、片方のブラウザでログアウトした場合、ログアウトした側でremember_digestが削除されているのに、ログアウトしていない側でremember_digestの比較を行った際にエラー。
(解決策)記憶ダイジェストがnilの場合に、authenticated?がfalseを返すようにする。

Remember meチェックボックス

cssなどの部分は全て飛ばします。 ##### 三項演算子 if-elseのような分岐構文を1行で示す手法。

  if boolean?
    var = foo
  else
    var = bar
  end

このような記述は

  var = boolean? ? foo : bar

の1行に置き換えられる。boolean? ? foo : barの部分が三項演算子。理論値? ifの値:elseの値の形。

チェックボックスのアクション

オンのときにユーザーを記憶し、オフの場合には記憶しない。 if-else分で記述

if params[:session][:remember_me] == '1'
  remember(user)
else
  forget(user)
end

これを三項演算子で記述する。

params[:session][:remember_me] == '1' ? remember(user) : forget(user)

これだけで完了。

Remember meのテスト

テストユーザーのログイン

8章においては、sessionハッシュをpostすることでログインしていたが、都度記述しなくていいようにヘルパーメソッドを単体テスト用と統合テスト用で定義しておく。 単体テストActiveSupport::TestCaseクラス、統合テストはActionDispatch::IntegrationTestクラスで定義。

[test/test_helper.rb]
class ActiveSupport::TestCase
  # テストユーザーとしてログインする
  def log_in_as(user)
    session[:user_id] = user.id
  end
end

class ActionDispatch::IntegrationTest

  # テストユーザーとしてログインする
  def log_in_as(user, password: 'password', remember_me: '1')
    post login_path, params: { session: { email: user.email,
                                          password: password,
                                          remember_me: remember_me } }
  end
end

後はこれを利用してテストを記述。ユーザーのremember_tokenはコントローラの方だと属性として含まれるが、テストの方だと仮想のため属性として含まれていないため、cookiesの値と一致するかは通常比較出来ないため、ここではcookiesが空かどうかのみを見る。
remember_meチェックボックスをオフ"0"にした状態でテストした場合、remember_tokenは空文字でもnilとなるため、empty?ではなくbrank?を使用する。

[test/integration/users_login_test.rb]
class RememberingTest < UsersLogin

  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_not cookies[:remember_token].blank?
  end

  test "login without remembering" do
    # Cookieを保存してログイン
    log_in_as(@user, remember_me: '1')
    # Cookieが削除されていることを検証してからログイン
    log_in_as(@user, remember_me: '0')
    assert cookies[:remember_token].blank?
  end
end
assignsメソッド

テストからコントローラ内の仮想の属性にアクセスしたい場合は、assignsメソッドを利用する。
コントローラ内で定義したインスタンス変数に対応するシンボルをこのメソッドに渡すことでアクセス可能となる。
Rails5以降ではどうやら非推奨らしい。

current_userメソッドのテスト、テスト漏れの確認

テストを書き忘れた可能性のあるコードブロック内にわざと例外処理を仕込む。→テストGREENのままならテスト漏れしている、というテスト漏れの確認テクニックがある。

current_userメソッドのテストは条件分岐ifのif (user_id = session[:user_id])の部分が関与するため、これがtrueになるlog_in_asヘルパーは使用不可。ヘルパーの単体テストとする。
少しこのテストは追うのに時間がかかったので流れを書いておきます。
セッションがnilの場合にcurrent_userが@userとして設定されるか。

  1. @userにテストfixtureが追加されremember(@user)を実行する。この時点で、永続化cookiesに保存される。
  2. session情報は無いため、current_userのif文if (user_id = session[:user_id])nilになる。
  3. elsif分のelsif (user_id = cookies.encrypted[:user_id])はrememberしたからtrue。
  4. 次のif文if user && user.authenticated?(cookies[:remember_token])もtrue。
  5. @current_userへ@userが代入されるため、@current_user == @userとなる。

remember_digestが不一致の場合にcurrent_userが設定されないか。

  1. 1~3までは同じ。
  2. if文if user && user.authenticated?(cookies[:remember_token])の部分で、update_attribute(:remember_digest)でremember_digestを変更しているため、ここがfalse。
  3. current_userはnilになる。
[test/helpers/sessions_helper_test.rb]
require "test_helper"

class SessionsHelperTest < ActionView::TestCase

  #@userを定義し、rememberメソッドを適用。
  def setup
    @user = users(:michael)
    remember(@user)
  end

  #セッションがnilの場合にcurrent_userが@userとして設定されるか。
  test "current_user returns right user when session is nil" do
    assert_equal @user, current_user
    assert is_logged_in?
  end

  #remember_digestが不一致の場合にcurrent_userが設定されないか。
  test "current_user returns nil when remember digest is wrong" do
    @user.update_attribute(:remember_digest, User.digest(User.new_token))
    assert_nil current_user
  end
end
セッションリプレイ攻撃脆弱性対策

演習9.3.2の内容。
cookiesや一時セッションはセッションハイジャック(乗っ取り)攻撃を受ける可能性がある。一時セッションはブラウザを閉じれば消えるが、対策は必要。
現状だと、セッションハッシュを第三者が奪ったらそのまま@current_userとなるため、最終的にはセッションハッシュがユーザーのセッショントークンと等しい場合のみ@current_userを設定するようにする。
現状のcurrent_user

  def current_user
    if (user_id = session[:user_id])
      @current_user ||= User.find_by(id: user_id)

remember_digestは一意の値であるため、これをsession_tokenとして定義。
remember_digestが存在しない場合は、rememberメソッドを呼び出す。ここでrememberメソッドの返り値をremember_digestに変えておく。

[app/models/user.rb]

  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
    remember_digest
  end

  def session_token
    remember_digest || remember
  end

②セッションヘルパーのlog_inメソッドでセッショントークンを設定する。

[app/helpers/sessions_helper.rb]
session[:session_token] = user.session_token

③セッションハッシュとユーザーのセッショントークンが等しい場合に@current_userを設定する。

[app/helpers/sessions_helper.rb]
  def current_user
    if (user_id = session[:user_id])
      user = User.find_by(id: user_id)
      if user && session[:session_token] == user.session_token
        @current_user = user
      end

第9章ー演習ー

9.1.1

①コンソールを開き、データベースにある最初のユーザーを変数userに設定してください。その後、そのuserオブジェクトからrememberメソッドがうまく動くかどうか確認してみましょう。また、remember_tokenとremember_digestの違いも確認してみてください。
(解答)rememberメソッドはうまく動く。remember_tokenは確かに22文字の文字列であって、remember_digestはまた異なる値。

irb(main):006:0> user.remember_token.length
=> 22

②リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、ややわかりにくいリスト 9.4の実装でも、非常に混乱しやすいリスト 9.5の実装でも、いずれも正しく動くことを確認してみてください。(ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。)
(解答) 確認なので飛ばします。

9.1.2

①ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。
(解答) どちらも確認できた。

②コンソールを開き、リスト 9.6のauthenticated?メソッドがうまく動くかどうか確かめてみましょう。
(解答)

irb(main):004:0> user.authenticated?(user.remember_token)
=> true

9.1.3

①ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。
(解答)削除されていた。

9.1.4

①リスト 9.17で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。
(解答)飛ばします。

②リスト 9.20で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。
(解答)飛ばします。

③上のコードでコメントアウトした部分を元に戻し、テストスイートが red から green になることを確認しましょう。
(解答)飛ばします。

9.2

①ブラウザでcookies情報を調べ、[remember me]をチェックしたときに意図した結果になっているかどうかを確認してみましょう。
(解答) セッションを閉じてもログイン状態が保持された。

②コンソールを開き、三項演算子を使った実例を考えてみてください(コラム 9.2)。
(解答)

irb(main):002:0> s = 'string'
=> "string"
irb(main):003:0> s.empty? ? 'empty' : 'not_empty'
=> "not_empty"

9.3.1

①リスト 9.26の統合テストでは、仮想のremember_token属性にアクセスできないと説明しましたが、実は、assignsという特殊なテストメソッドを使うとアクセスできるようになります。コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使います。このメソッドにはインスタンス変数に対応するシンボルを渡します。例えばcreateアクションで@userというインスタンス変数が定義されていれば、テスト内部ではassigns(:user)と書くことでインスタンス変数にアクセスできます。本チュートリアルのアプリケーションの場合、Sessionsコントローラのcreateアクションでは、userをインスタンス変数ではない通常のローカル変数として定義しましたが、これをインスタンス変数に変えてしまえば、cookiesにユーザーの記憶トークンが正しく含まれているかどうかをテストできるようになります。このアイデアに従ってリスト 9.29とリスト 9.30の不足分を埋め、[remember me]チェックボックスのテストを改良してみてください。ヒントとして?や(コードを書き込む)を目印に置いてあります。なお、この演習問題で行う変更はこの後のリスト 10.33に影響するのでご注意ください。
(解答) コントローラの変数を@userにして、テスト内ではassert_equalでcookiesの記憶トークンと@user自身の記憶トークンが一致することを確認。

[app/controllers/sessions_controller.rb]
  def create
    @user = User.find_by(email: params[:session][:email].downcase)
    if @user&.authenticate(params[:session][:password])
      reset_session      # ログインの直前に必ずこれを書くこと
      params[:session][:remember_me] == '1' ? remember(@user) : forget(@user)
      log_in @user
      redirect_to @user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new', status: :unprocessable_entity
    end
  end
[test/integration/users_login_test.rb]
  test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_equal cookies[:remember_token], assigns(:user).remember_token
  end

9.3.2

①リスト 9.35にあるauthenticated?の式を削除すると、リスト 9.33の2つ目のテストで失敗すること、つまりこのテストが正しい対象をテストしていることを確認してみましょう。
(解答) 期待通りの結果になる。

②9.1.1で説明したように、私たちのアプリケーションのセッション内にあるuser_idは、このままだとセッションリプレイ攻撃に対して脆弱です。リスト 9.37のコードを用いて、リスト 9.38のコードの足りない部分「(コードを書き込む)」を埋めることで、コードを改善してください。また、以下のヒントも参考にしてください。 sessionハッシュがユーザーのセッショントークンと等しい場合のみ、@current_userを設定します。ただし、remember_digestは既に一意の値になっており、各ユーザーと紐付けられているので、単にこれをセッショントークンとして再利用し、ユーザーがまだ存在しない場合は生成することにします。このとき、rememberメソッドに少し手を加えて、update_attributeの結果を返すのではなく、リスト 9.37のようにダイジェストを返すようにする必要があります。

bcryptダイジェストの設計はセキュアであり、セッションの値は常に暗号化されるので、記憶ダイジェストを2つの目的に利用しても安全です。この実装では、ハイジャックされたセッションを無効にするために必要なのは、user.forgetを呼び出すことだけです。この後の演習(12.3.2.1)では、この手法を適用することで、ユーザーのパスワードをリセットするときに既存のセッションをすべて失効させられるようにする予定です。

最後に、これはセキュリティに関連する重要なコードなので、本チュートリアルの他の解答と異なり、以後のコードすべて、およびサンプルアプリにも必ず反映されています。
(解答)

  def current_user
    if (user_id = session[:user_id])
      user = User.find_by(id: user_id)
      if user && session[:session_token] == user.session_token
        @current_user = user
      end

第9章ーまとめー

  • has_secure_passwordと同様に、記憶トークンもハッシュ化した記憶ダイジェストとして扱う。
  • BCrypt::Password.createは、引数の文字列からハッシュ化したパスワードを生成する。BCrypt::Password.newは、引数の文字列をBCryptパスワード化する。
  • ログイン状態は一時セッション、もしくは永続化cookiesによって決まる。
  • ログアウトは記憶ダイジェストを削除し、セッションとcookies情報を削除することで可能。
  • 一時セッションのセッションリプレイ攻撃の対策として、セッションハッシュがユーザーのセッショントークンと等しい場合のみ@current_userを設定する

この章は難しい要素が多いです。。。

第9章終わり🐻