はじめに
Railsチュートリアル9章の内容は、私にとってあまりに複雑でとても難しく感じたので
ほんの少しでもわかりやすく理解できるよう、9章の中でもセッションの永続化に関する内容を簡単にまとめてみました!
あまりにボリューミーなので、セッションの永続化を全編、Rmember meチェックボックス編を後編にしてまとめました。
こんな人に読んで欲しい
Railsチュートリアルにてログイン機能を実装しており、尚且つ9章を終えたが理解度が足りていないかもと感じている方。
あと私自身。
内容
セッション永続化機能の実装。
簡単に言うと、ブラウザを閉じてもログインを保持することができる機能です。
見たらわかるめちゃくちゃ便利な機能って事で絶対に物にしたいです。
ほぼ箇条書きでまとめてあります。
キリの良い所まで一旦まとめてから、コードの記述を行っています。
実装手順
前置きが長くなりましたが、いざまとめ作業へと参ります!
ログイン保持機能を実装する手順を簡単にまとめると以下のようになります。
1.永続セッション作成の準備
2.永続セッション作成
3.上記を利用して、ログインユーザーのログインを保持する
4.永続セッションからログアウトできるようにする
5.上記機能実装により発生するバグを修正する
では、より詳しくまとめていきます。
1.永続セッション作成の準備
手順1の、永続セッション作成の準備についてです。
ログインを保持するには永続セッションが必要ですが、さらにその永続セッションを作成するための準備が必要になるので、手順1ではその準備を行っていきます。
永続セッション作成準備でやること
●ユーザーを記憶するための、記憶トークンなる物を作成する。
●その記憶トークンをハッシュ化し、データベースに保存させる機能を実装する。
1-1.トークンの作成。
●Userモデル内に、クラスメソッドとして生成。
➡︎Userモデル内のdigestメソッドが、ユーザーオブジェクトを必要としないため。
●トークンを生成するメソッドを定義。
➡︎new_tokenとする。
●トークンは、長いランダムな文字列にすること。
➡︎SecureRandomモジュールにある、urlsage_base64メソッドを使用。
➡︎22文字のランダムな文字列が生成される。
●このトークンを使って、記憶トークンを生成する。
1-2.記憶トークンをユーザーと関連付け、記憶ダイジェストに保存されるようにする。
●記憶ダイジェスト属性をUserモデルに追加する。
➡︎カラム名はremember_digest、データ型は文字列とする。
➡︎コマンドでDBへその変更を反映。
➡︎このデータにユーザーが触れることはないので、インデックスは不要。
➡︎記憶トークンは、このカラムに保存されることになる。
●記憶トークン属性を追加する。
➡︎DBには保存しないような属性を追加する。
➡︎attr_accessorを使い、仮想の属性とする。
➡︎Userモデル内に記述する。
➡︎属性名はremember_tokenとする。
➡︎この記憶トークンの値が、記憶ダイジェストカラムに追加されることになる。
●ユーザーを記憶し、記憶ダイジェストに保存するメソッドを定義する。
➡︎Userモデル内に作成。
➡︎メソッド名をrememberとする。
➡︎1-1で生成したランダムな文字列のトークン(new_token)を、remember_token属性として取得する処理の記述。
→remember_token属性にはselfを付け、属性として正しく認識させる。
→付けないとローカル変数として定義される。
➡︎記憶トークンを、記憶ダイジェストに保存する処理の記述。
→update_attributeを使う。
→remember_tokenで取得した値をハッシュ化して、記憶ダイジェストに保存する。
→パスワード実装の際に使用した、digestメソッドによりハッシュ化する。
➡︎このメソッドは、バリデーションを素通りさせる。
➡︎これにより、ユーザーと関連付けられた記憶トークンが、記憶ダイジェストに保存されるようになった。
→例えば、user.rememberのようにして、このメソッドを使ってユーザーを呼び出すと、呼び出したユーザーのremember_digestカラムに、ランダムな文字列で生成された記憶トークンが保存されるようになる。
1-3.準備完了
●ユーザーをレシーバにrememberメソッドを呼び出すと、そのユーザーの記憶ダイジェストにランダムな文字列が保存されるようになりました。
●以上の機能を利用して、永続セッションを作成していきます。
models/user.rb#1-1 def User.new_token #Userを付することでクラスメソッドであることを明示的に示している。 SecureRandom.urlsafe_base64 end
1-2$ rails g migration add_remember_digest_to_users remember_digest:string $ rails db:migrate
models/user.rb#1-2 attr_accessor :remember_token #仮想の記憶トークン属性を作成。
models/user.rb#1-2 #記憶トークンをハッシュ化し、呼び出したユーザーの記憶ダイジェストに保存する。 def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end
2.永続セッション作成
次に、手順2の永続セッションの作成です。
手順1で作成したメソッドなどは、ここで使用していきます。
永続セッション作成でやること
・ユーザーIDを暗号化する
・暗号化したユーザーIDと記憶トークンを、ブラウザの永続cookiesに保存する。
・以上の機能を実装するために、cookiesメソッドを使用する。
2-1.記憶トークンをcookiesに保存する。
●cookiesメソッドを使用することで保存できる。
➡︎記憶トークンをcookieに保存する。
→cookies.permanent[:remember_token] = remember_token
→cookiesメソッドはsessionメソッド同様、ハッシュで扱う。
→permanentメソッドを使用すれば、cookiesの有効期限が20年後に設定される。
→permanentメソッドにより永続化完了。
2-2.ユーザーIDを暗号化してcookiesに保存する。
●cookiesメソッドでcookieにユーザーを保存する。
➡︎cookies[:user_id] = user.id
➡︎ただこのままでは、IDが生のテキストのまま保存されているので、奪い取られる可能性がある。
➡︎署名付きcookieを使い、ユーザーIDを暗号化する。
→cookies.signed[:user_id] = user.id
→sigedメソッド付与により暗号化される。
➡︎さらに、cookieも永続化してあげる。
→cookies.permanent.signed[:user_id] = user.id
→permanentとsignedはメソッドチェーンで繋ぐ。
➡︎cookiesを設定したことで、cookiesからユーザーを取得できるようになった。
→例えば、User.find_by(id: cookies.signed[:user_id])
→ちなみに、cookies.signed[:user_id]では自動でユーザーIDの暗号化が解除される。
2-3.cookiesに保存された記憶トークンが、ユーザーの記憶ダイジェストと一致することを確認する。
●この2つの一致を確認することにより、後々ログインを可能とする。
➡︎パスワードで例えると、passwordとpassword_digestの比較と同じ。
●bcryptを使って確認。
➡︎secure_passwordのソースコードを確認してみる。
→BCrypt::Password.new(password_digest) == unencrypted_passwordというコードを参考にする。
→bcryptでハッシュ化されたパスワードを比較する際にこのコードが使われているので、これを利用してやるという動機がある。
→BCrypt::Password.new(remember_digest) == remember_tokenとして、当てはめてみる。
→しかしこれだと、暗号化されたパスワードとトークンを直接比較している。
→bcryptでは記憶ダイジェストは復号化されないので、直接比較はできない。
➡︎bcrypt gemのソースコードの方を詳しく見てみる。
→すると、==の部分がis_password?という論理値メソッドで再定義されている。
→なので、BCrypt::Password.new(remember_digest).is_password?(remember_token)として比較してあげることにする。
➡︎これにより、記憶トークンと記憶ダイジェストを比較してあげることにする。
2-4.記憶トークンと記憶ダイジェストを比較するためのメソッドの定義をする。(2-3より)
●Userモデルの中に作成。
●メソッド名はauthenticated?とする。
●渡された記憶トークンが、記憶ダイジェストと一致したらtrueを返す処理を記述する。
●ここでの記憶トークンとは、cookiesに保存されている物で、仮想の属性であるアクセサとは別物。
※このメソッドは、手順3の3-2で使用。
2-5.永続セッションの作成が完了しました。
#2-1.記憶トークンをcookiesに保存する。 cookies.permanent[:remember_token] = user.remember_token
#2-2.ユーザーIDを暗号化してcookiesに保存する cookies.permanent.signed[:user_id] = user.id
models/user.rb#2-4.記憶トークンと記憶ダイジェストを比較するためのメソッドの定義をする def authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) end
3.ログインしたユーザーのログインを保持する
続きまして、手順3に移ります。
手順2で作成した永続セッションの動作を利用して、ログインを保持する機能を実装していきます。
3-1.ログイン時にユーザーを保持させる。
●ログインを保持するためのメソッドを定義する。
➡︎メソッド名をrememberとし、ユーザーを引数に取るヘルパーメソッドを定義。
→Sessionsヘルパー内に記述。
→記憶トークンを生成し、DBの記憶ダイジェストに保存させる処理を記述。
→これに関しては、Usersモデルで定義したrememberメソッドを呼び出せばいい。
→暗号化したユーザーIDを、永続cookiesに保存する処理を記述。
→ユーザーの記憶トークンを、永続cookiesに保存する処理を記述。
➡︎そして、アクション内でこのメソッドを呼び出すと…
→渡されたユーザーの記憶トークンが記憶ダイジェストにが保存される。
→暗号化したユーザーIDと記憶トークンが、永続cookiesに保存される。
→その結果、ログインが保持されることになる。
●最後に、Sessionsコントローラ内でrememberヘルパーメソッドを呼び出す。
・ログインの保持は、ログインした時に実行されるのでlog_inメソッドとセットで定義してあげる。
●これでログインしたユーザーは、ブラウザに正しく記憶されるようになったので、ログイン保持機能の実装は完了した。
3-2.問題点
●しかしながら現状では、このログイン保持機能には、問題点が1つ存在。
●それは、Sessionsヘルパー内で定義されているcurrent_user内にあり、このメソッド内でsessionメソッドによる一時セッションしか扱っていないため、ログイン保持機能が正常に動作しないこと。
3-3.解決策
●永続セッションを動作させる場合には、ユーザーの取得方法に関して条件がある。
➡︎session[:user_id]が存在する場合は、一時セッションからユーザーを取得する必要がある。
→sessionにユーザーIDが存在するかどうかの条件式を記述。
➡︎それ以外の場合は、cookies[:user_id]からユーザーを取得する必要がある。
→cookiesに、暗号化されたユーザーIDが存在するかどうかの条件式を記述。
→存在する場合、cookiesに保存されたIDと一致するIDを持つユーザーを取得する処理を記述。
→「上記のユーザーが存在し」、かつ、「そのユーザーの記憶トークンと記憶ダイジェストが一致する(2-4で定義したメソッドを使用)」かどうかの条件式を記述。
→どちらの条件も満たすならば、そのユーザーでログインする処理を記述。
→ログイン中のユーザーをインスタンス変数で返す処理を記述。
➡︎リファクタリング
→ローカル変数を用いて、同じ記述をしている部分を修正。
→重複している記述を、条件式内でローカル変数として一気に定義してあげる。
→ユーザーIDのセッションが存在する場合=if(user_id = session[:user_id])とする。
→ユーザーIDのクッキーが存在する場合=if(user_id = cookies.signed[:user_id])とする。
→ローカル変数user_idを利用する。
●これにより、ログインしたユーザーが正しく記憶されるようになった。
helpers/sessions_helper.rb#3-1.ログイン時にユーザーを保持させるメソッド。 def remember(user) user.remember cookies.permanent.signed[:user_id] = user.id cookies.permanent[:remember_token] = user.remember_token end
controllers/sessions_controller.rbdef create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) log_in user remember user #3-1.ログイン時に、ログインを保持する。 redirect_to user else flash.now[:danger] = "失敗" render 'new' end end
helpers/sessions_helper.rb#3-2.記憶トークンcookieに対応するユーザーを返す def current_user if (user_id = session[:user_id]) #sessionを持つユーザーの存在。 @current_user ||= User.find_by(id: user_id ) elsif (user_id = cookies.signed[:user_id]) #暗号化IDを持つユーザーの存在。 user = User.find_by(id: user_id ) #ユーザーの取得 if user && user.authenticated?(cookies[:remember_token]) #ユーザーの存在と、記憶トークンと記憶ダイジェストが一致するかどうか(2-4で定義)。 log_in user @current_user = user end end end
4.永続セッションからログアウトできるようにする
4-1.完全にログアウトできるように、ユーザーを忘れるためのメソッドを定義する必要がある。
●Userモデルに追加。
●メソッド名をforgetとして定義する。
●DBの記憶ダイジェストをnilで更新する処理を記述をする。
➡︎update_attributeを使用。
●ユーザーのログイン情報を破棄する処理を記述する。
●永続セッションを終了させる準備が完了。
4-2.永続的セッションを破棄するメソッドを記述。
●Sessionsヘルパー内に追加。
●メソッド名をforgetとする。
●渡されたユーザーのログイン情報を破棄する処理を記述。
●ブラウザに保存されている、ユーザーIDのクッキーを削除する処理を記述。
●ブラウザに保存されている、記憶トークンのクッキーを削除する処理を記述。
4-3.現在のユーザーのログアウトする。
●log_outヘルパーメソッド内で、永続セッションを破棄するメソッドを呼び出す。
➡︎ログイン中のユーザーを破棄するので、引数はcurrent_user。
4-4.全てのテストスイートがGREENになる。
models/user.rb#4-1.ユーザーのログイン情報を破棄する。 def forget update_attribute(:remember_digest, nil) end
helpers/sessions_helper.rb#4-2.ユーザーの永続的セッションを破棄する。 def forget(user) user.forget #4-1のメソッドを呼び出してる。 cookies.delete(:user_id) cookies.delete(:rememeber_token) end
helpers/sessions_helper.rb#4-3.現在のユーザーをログアウトする。 def log_out forget(current_user) #4-2のメソッドを呼び出している。 session.delete(:user_id) @current_user = nil end
5.上記機能実装により発生するバグを修正する
まず、上記機能実装によるバグが2つ存在するので、その詳細から挙げていく。
【1つ目】:複数のタブを開きながらログインしているユーザーが、一方のタブでログアウトをして、もう一つのタブで再度ログアウトしようとするとエラーになる。
【原因】:ログアウトリンクをクリックすると、current_userがnilになり、log_outメソッド内のforget(current_user)が失敗してしまうため。
【解決策】:ユーザーが、ログイン中の場合にのみログアウトさせる。
【2つ目】:複数のブラウザ(FirefoxやChromeなど)でログインしているユーザーが、一方のブラウザ(Firefoxとする)ではログアウトして、一方(Chromeとする)のブラウザではログアウトはせずに、ブラウザを終了させてから再度同じブラウザで同じページへ行くと、問題が発生する。
【具体的なエラーの挙動】
●Firefoxでログアウトした時。
➡︎user.forgetメソッドによってremember_digestがnilになる。
➡︎この時同じタイミングで、log_outメソッドによりユーザーIDが削除され、current_userメソッド内の2つの条件文がfalseになる。
➡︎結果、current_userメソッドの戻り値は期待通りnilになる。
➡︎ここまでは正常に動作している。
●問題は、Chromeをログアウトせずに閉じた時。
➡︎current_userメソッド内のsession[:user_id]はnilになる(セッションの有効期限切れによる)。
➡︎しかし、cookiesはブラウザに残り続けている。
➡︎そのためChromeを再起動して、アプリにアクセスすると、DBからそのユーザーを見つけることができてしまうため、セキュリティ上の問題が存在する。
➡︎クッキーが存在するせいで、current_userメソッド内のuser && user.authenticated?(cookies[:remember_token])が評価される。
➡︎結果的に、記憶ダイジェストがnilなので、user.authenticated?(cookies[:remember_token])の部分でエラーが発生する。
【原因】
●Firefoxログアウト時に、remember_digestが削除されるにもかかわらず、Chromeアクセス時、BCrypt::Password.new(remember_didest).is_password?(remember_token)が実行されてしまうため。
➡︎要するにremember_digestがnilとなるため、bcrypt内で例外が発生してしまう。
【解決策】
●remember_digestが存在しない場合は例外ではなく、falseを返す処理を、authenticated?メソッドに追加する。
5-1.まず、上記2つのエラーをキャッチするテストを記述していく。
【1つ目の問題についてのテスト】
●2番目のウィンドウでログアウトをクリックするユーザーを検証する。
➡︎integration/users_login_test.rb内のログアウトテストに追加で記述。
➡︎1個目のログアウトの後に、もう一つログアウトテストを追加する。
→delete logout_path
➡︎current_userがいないので、2度目の呼び出しはエラーが発生するため、テストスイートはRED。
●テストをパスさせるために機能を追加していく。
➡︎ユーザーがログイン中の場合のみ、ログアウトできるようにする機能を記述する。
→Sessionsコントローラ内に処理を記述。
→logged_in?メソッド(ログインしているユーザーがいればtrueを返すメソッド)が、trueの場合にのみ、log_outを呼び出すようにする。
→destroyアクション内に処理を記述。
→「 log_out if logged_in? 」
➡︎テストスイートがパス。GREEN。
【2つ目の問題についてのテスト】
●ダイジェストが存在しない場合のauthenticated?をテストする。
➡︎Userモデルで直接テストする。
➡︎記憶ダイジェストを持たないユーザーを用意する。
➡︎authenticated?メソッドを呼び出す。(渡されたトークンが、ダイジェストと一致したらtrueを返すメソッド)
➡︎assert_not @user.authenticated?('')
→結果がfalseならパス。
→記憶トークンが使われる前に記憶ダイジェストでエラーになるので、記憶トークン部分の値は何でもいい。
➡︎BCrypt::Password.new(nil)でエラーが発生するため、テストスイートはRED。
●テストをパスさせる。
➡︎authenticated?メソッドで、記憶ダイジェストがnilの場合はfalseを返すようにする処理を記述。
→return false if remember_digest.nil?
→returnキーワードによって、記憶ダイジェストがnilの場合、即座にメソッドを終了させる。
→処理を途中で終わらせるのに用いられるテクニック。
➡︎テストスイートGREEN。
integration/users_login_test.rbtest "login with valid information followed by logout" do get login_path post login_path, params: { session: { email: @user.email, password: 'password' }} assert is_logged_in? assert_redirected_to @user follow_redirect! assert_template 'users/show' assert_select "a[href=?]", login_path, count: 0 assert_select "a[href=?]", logout_path assert_select "a[href=?]", user_path(@user) delete logout_path assert_not is_logged_in? assert_redirected_to root_url delete logout_path #5-1.2度目のログアウトを検証(RED)。 follow_redirect! assert_select "a[href=?]", login_path assert_select "a[href=?]", logout_path, count: 0 assert_select "a[href=?]", user_path(@user), count: 0 end
controllers/sessions_controller.rbdef destroy log_out if logged_in? #5-1.ログインしているユーザーがいる場合のみログアウトさせる。 redirect_to root_url end
test/models/user_test.rb#5-1.authenticated?メソッドの結果がfalseならテストをパスする。 test "authenticated? should return false for a user with nil digest" do assert_not @user.authenticated?('') end
models/user.rbdef authenticated?(remember_token) return false if remember_digest.nil? #5-1.記憶ダイジェストがnilの場合はfalseを返す。 BCrypt::Password.new(remember_digest).is_password?(remember_token) end
最後に
以上で、セッションの永続化機能の実装が完了しました。
あとは、この機能をユーザーが選択可能にする[Remember me]チェックボックス機能を実装すれば、9章の内容が完了します。
チェックボックス編に関しては、別でまた記事を投稿しようと思います。