Ruby on 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_token
とremember_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)メソッド
- rememberメソッドで記憶ダイジェストを生成。
- 暗号化されたidの永続化クッキーを作成。
- 暗号化されたリメンバートークンのクッキーを作成。
[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_user
がnilとなり、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として設定されるか。
- @userにテストfixtureが追加されremember(@user)を実行する。この時点で、永続化cookiesに保存される。
- session情報は無いため、current_userのif文
if (user_id = session[:user_id])
はnilになる。 - elsif分の
elsif (user_id = cookies.encrypted[:user_id])
はrememberしたからtrue。 - 次のif文
if user && user.authenticated?(cookies[:remember_token])
もtrue。 - @current_userへ@userが代入されるため、@current_user == @userとなる。
remember_digestが不一致の場合にcurrent_userが設定されないか。
- 1~3までは同じ。
- if文
if user && user.authenticated?(cookies[:remember_token])
の部分で、update_attribute(:remember_digest)でremember_digestを変更しているため、ここがfalse。 - 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章終わり🐻