9.1 Remember me機能
ここでは、ユーザーのログイン状態をブラウザを閉じた後でも有効にする[remember me]機能を実装していく。
remember me機能は、ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができるようになる。
9.1.1 記憶トークンと暗号化
ここでは、セッションの永続化の第一歩として
をする。ここで、「トークン」とはPCが作ってくれるパスワードみたいなもの。
セッションハイジャックと対応
セッションハイジャック
前章では、session
メソッドで保存した情報は自動的に安全が保たれるが、cookies
メソッドに保存する情報は安全に保たれず、セッションハイジャックという攻撃を受ける可能性がある。
この攻撃は、記憶トークンを奪って、特定のユーザーになりすましてログインするというもの。cookiesを盗み出す有名な方法は4通り
(1)管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す
(2)DBから記憶トークンを取り出す
(3)クロスサイトスクリプティング(XSS)を使う
(4)ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る
対応
最初の問題(1)を防止するため、7.5でSSLをサイト全体に適用して、ネットワークデータを暗号化で保護し、パケットスニッファから読み取られないようにした。
2番目の問題(2)の対策には、記憶トークンをそのままDBに保存するのではなく、記憶トークンのハッシュ値を保存するようにする。これは、6.3で生のパスワードをDBに保存するかわりに、パスワードのダイジェストを保存したのと同じ考え。
3番目の問題(3)については、Railsによって自動的に対策が行われる。具体的には、ビューのテンプレートで入力した内容をすべて自動的にエスケープ(無効化)する。
4番目の(4)、ログイン中のPCへの物理アクセスによる攻撃については、さすがにシステム側での根本的な防衛手段を講じることは不可能。
だが、二次被害を最小限に留めることは可能。具体的には、ユーザーがログアウトしたときにトークンを必ず変更するようにし、セキュリティ上重要になる可能性のある情報を表示する時には、デジタル署名(digital signature)を行うようにする。
永続的セッションの作成の方針
上記の設計やセキュリティ上の考慮事項を元に、次の方針で永続的セッションを作成する。
1.記憶トークンにはランダムな文字列を生成して用いる
2.ブラウザのcookiesにトークンを保存する時には、有効期限を設定する
3.トークンはハッシュ値に変換してからDBに保存する
4.ブラウザのcookiesに保存するユーザーIDは暗号化しておく
5.永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがDB内のハッシュ値と一致することを確認する。
上記の手順はユーザーがログインする時の手順の類似であることに注目!ユーザーログインでは、メールアドレスをキーにユーザーを取り出し、送信されたパスワードがパスワードダイジェストと一致することを、authenticate
メソッドで確認してログインしていた。つまり、ここでの実装はhas_secure_password
と似た側面を持つ。
remember_digest
属性をUserモデルに追加
まず、remember_digest属性をUserモデルに追加する。記憶トークンの保存場所になる。
console
$ rails g migration add_remember_digest_to_users remember_digest:string
マイグレーション名がto_usersで終わっていることより、マイグレーションの対象がDBのusersテーブルであることをRailsに示している。
db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[5.1] def change add_column :users, :remember_digest, :string end end
記憶ダイジェストはユーザーが直接読み出すことはないので、remember_digestカラムにインデックスを追加する必要はない。
DBに反映。
$ rails db:migrate
記憶トークンの生成の方針
ここで、記憶トークンの生成には(候補としていろいろなものが考えられるが)Ruby標準ライブラリにあるSecureRandomモジュールにあるurlsafe_base64
メソッドを用いることにする。
$ rails c >> SecureRandom.urlsafe_base64 => "ランダムな文字列"
base64はURLを安全にエスケープするためにも用いられる。うーん、すごい!
base64を採用すれば、第10章でアカウントの有効化のリンクやパスワードリセットのリンクでも同じトークンジェネレータを使えるようになる!
ユーザーの記憶に向けて
ユーザーを記憶するには、
(a)記憶トークンを作成して、
(b)その記憶トークンをダイジェストに変換し、DBに保存する
の2つをする。ここで
(a)の記憶トークンの作成にはurlsafe64_base
メソッドを利用すること、
(b)のダイジェストのDBでの保存先はremember_digestとすること
にしていた。なのであとは「記憶トークンをダイジェストに変換」することである。
じつは、fixtureをテストする時に8.2.4でdigest
メソッドを既に作成していた。
def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end
これを利用して、新しいトークンを作成するためのnew_token
メソッドを作成できる。
この新しいdigest
メソッドではユーザーオブジェクトが不要なので、このメソッドもUserモデルのクラスメソッドとして作成することにする。new_token
メソッドを追加したコードは次の通り。
models/user.rb
# 渡された文字列のハッシュを返す def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end # ランダムなトークンを返す def User.new_token SecureRandom.urlsafe_base64 end
実装計画
さしあたっての実装計画として、user.remember
メソッドを作成することにする。このメソッドは
Userモデルには既にremember_digest
属性(記憶トークンの保存場所)が追加されていたが、remember_token
属性をDBに保存せずに、user.remember_token
メソッド(cookiesの保存場所)を使ってトークンにアクセスできるようにする必要がある。
そのために、6.3の安全なパスワードの問題のときと同様の手法でこれを解決する。6.3では、「仮想の」password
属性と、DB上のセキュアなpassword_digest
属性を使った。仮想のpassword
属性はhas_secure_password
メソッドで自動的に作成できた。
しかし、今回はremember_token
のコードを自分で書かないといけない。remember_token
にはアクセスが必要なので、実装のため、次のようにattr_accessor
を使う。
models/user.rb
(rememberメソッドの作成とremember_tokenの定義)
class User < ApplicationRecord attr_accessor :remember_token ←追加 : # 渡された文字列のハッシュを返す def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end # ランダムなトークンを返す def User.new_token SecureRandom.urlsafe_base64 end # 永続セッションのためにユーザーをDBに記憶する def remember self.remember_token = User.new_token ←① update_attribute(:remember_digest, User.digest(remember_token)) ←② end end
①:User.new_token
(新しく作成したランダムな文字列)をremember_token
に保存。selfをつけるとクラス変数になるよ。
②:remember_token
をdigest
メソッドでハッシュ化(暗号化)して、Userモデルのremember_digest
を更新。update_attribute
を使うことでValidationを素通りしている。
9.1.2 ログイン状態の保持
user.remember
メソッドが動作するようになったので、「ユーザー」をDB内に「remember_digest
」として保存できるようになった。ここでは、ユーザーの「暗号化済みID」と「記憶トークン」をブラウザの永続cookiesに保存して永続セッションを作成する。そして、この「記憶トークン」をハッシュ化したものと「remember_digest
」が一致するユーザーを探す、すなわちユーザー認証を行う。
cookies
メソッド
このためにcookies
メソッドを使う。このメソッドはsession
のときと同様にハッシュとして扱える。個別のcookiesは、「value(値)」と「オプションのexpires
(有効期限)」からできている(有効期限は省略可能)例えば次のように、20年後に期限切れになる記憶トークンと同じ値をcookieに保存することで、永続的なセッションを作成できる。
cookies[:remember_token] = { value: remember_token, expires: 20.years.from_now.utc }
上のように20年で期限切れになるcookies設定はよく使われるので、特殊なpermanent
という専用のメソッドが追加された。
cookies.permanent[:remember_token] = remember_token←①
①:記憶トークンをcookiesに保存。
「ユーザーID」をcookiesに保存するには、session
メソッドで使ったのと同じパターンを使う。
cookies[:user_id] = user.id
このままではIDが生のテキストとしてcookiesに代入されてしまい、アプリのcookies形式まる見えになってしまい、攻撃者がユーザーアカウントを奪い取る可能性がある。これを避けるために、署名付きcookieを使う。これは、cookieをブラウザに保存する前に安全に暗号化するもの。
cookies.signed[:user_id] = user.id
ユーザーIDと記憶トークンはペアで扱う必要があるので、cookieも永続かしなくてはならない。そこで、signed
とpermanent
をメソッドチェーンで繋いで使う。
cookies.permanent.signed[:user_id] = user.id
以後のページのビューで、次のようにcookiesからユーザーを取り出せるようになる
User.find_by(id: cookies.signed[:user_id])←①
①:cookies.signed[:user_id]で自動的にユーザーIDのcookiesの暗号が解除され、元にもどる。うーん、すごい!
bcryptの利用
いまから「記憶トークン(remember_token)」をハッシュ化したものと「remember_digest
」が一致することの実装を行う。この一致を確認する方法はさまざまある。secure_passwordのソースコードには、
BCrypt::Password.new(password_digest) == unencrypted_password
このような比較がある。このコードを参考にして次のようなコードを使う。
BCrypt::Password.new(remember_digest) == remember_token
しかし本来なら、bcryptのハッシュは復号化できないはず。だが、bcrypt gemの機能によって、比較に使っている==演算子が再定義されていることが分かる。
実際の比較をコードで表すと、次のようになっている。
BCrypt::Password.new(remember_digest).is_password?(remember_token)
実際の比較では、==の代わりにis_password?という論理値メソッドが使われている。
まとめると
以上により、「記憶トークン」とDB内の「remember_digest
」を比較するauthenticated?
メソッドを、Userモデルの中に置けばよいと分かる。
app/models/user.rb
# 渡された文字列のハッシュ値を返す def User.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end # ランダムなトークンを返す def User.new_token SecureRandom.urlsafe_base64 end # 永続セッションのためにユーザーをDBに記憶 def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end # 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) ←① BCrypt::Password.new(remember_digest).is_password?(remember_token) end
①:ここのremember_token
は、9.3のattr_accessor :remember_token
で定義されたアクせサとは異なる点に注意!今回の場合、is_password?
の引数はメソッド内のローカル変数を参照している。
もう1つ、remember_digest
の属性の使い方にも注目!この使い方はself.remember_diget
と同じである。実際、remember_digest
の属性はDBのカラムに対応しているため、Active Recordによって簡単に取得したり保存できたりする。
これで、ログインしたユーザーを記憶する処理の準備が整った。remember
ヘルパーメソッドを追加して、log_in
と連携する。
controllers/sessions_controller.rb
class SessionsController < ApplicationController def new end def create user = User.find_by(email: params[:session][:email].downcase) if user && user.authenticate(params[:session][:password]) log_in user remember user ←追加 redirect_to user else flash.now[:danger] ="Invalid email/password combination" render 'new' end end def destroy log_out redirect_to root_url end end
上と同様に、cookies
メソッドでユーザーIDと記憶トークンの永続cookiesを作成する。
helpers/sessions_helper.rb
module SessionsHelper # 渡されたユーザーでログインする def log_in(user) session[:user_id] = user.id end # ユーザーのセッションを永続的にする def remember(user) user.remember ←① cookies.permanent.signed[:user_id] = user.id ←追加 cookies.permanent[:remember_token] = user.remember_token ←追加 end
①は次のコードであった
# 永続セッションのためにユーザーをDBに記憶 def remember self.remember_token = User.new_token update_attribute(:remember_digest, User.digest(remember_token)) end
リファクタリング
ただし、ログインするユーザーはブラウザで有効な記憶トークンを得られるように記憶されているが、current_user
メソッドでは一時セッションしか扱っておらず、このままでは正常に動作しない。
#現在ログイン中のユーザーを返す(いる場合) def current_user @current_user ||= User.find_by(id: session[:user_id]) end
永続セッションの場合は、session[:user_id]
が存在すれば一時セッションからユーザーを取り出し、それ以外はcookies[:user_id]
からユーザーを取り出して、対応する永続セッションにログインする必要がある。次のようにする。
def current_user if session[:user_id] @current_user ||= User.find_by(id: session[:user_id]) elsif cookies.signed[:user_id] user = User.find_by(id: cookies.signed[:user_id]) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end
上のコードでも、session
メソッドとcookies
メソッドもそれぞれ2回ずつ使われてしまい、無駄がある。これを解消するためは、次のローカル変数を使う。
def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end
上のコードでは。
if (user_id = session[:user_id])
と比較を行っているように見えますが、これは比較ではない。これは代入を行っている。このコードを言葉で表すなら「(ユーザーIDにユーザーIDのセッションを代入した結果)ユーザーIDのセッションが存在すれば」となる。うーん、むずい!
current_user
へルパーを定義すると、次のようになる。
helpers/sessions_helper.rb
module SessionsHelper # 渡されたユーザーでログインする def log_in(user) session[:user_id] = user.id end # ユーザーのセッションを永続的にする def remember(user) user.remember cookies.permanent.signed[:user_id] = user.id cookies.permanent[:remember_token] = user.remember_token end # 記憶トークンcookieに対応するユーザーを返す def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end #ユーザーがログインしていればtrue、その他ならfalseを返す def logged_in? !current_user.nil? end # 現在のユーザーをログアウトする def log_out session.delete(:user_id) @current_user = nil end
ブラウザのcookiesを削除する手段が未実装なので、今はユーザーがログアウトできない。
9.1.3 ユーザーを忘れる
ユーザーがログアウトできるように、ユーザーを記憶するためのメソッドと同様の方法で、ユーザーを忘れるためのメソッドuser.forget
を定義する。このuser.forget
メソッドによって、user.rememberが取り消される。具体的には、記憶ダイジェスト(DB保存されている)をnilで更新する。
models/user.rb
# ユーザーのログイン情報を破棄する ←追加 def forget update_attribute(:remember_digest, nil) end
このコードを使うと、永続セッションを終了できるようになる準備が整った。終了するには、forgetヘルパーメソッドを追加してlog_outヘルパーメソッドから呼び出す。次のコードでは、forgetヘルパーメソッドでは、user.forgetを呼んでからuser_idとremember_tokenのcookiesを削除している。
app/helpers/sessions_helper.rb
# 渡されたユーザーでログインする def log_in(user) session[:user_id] = user.id end : #永続的セッションを破棄する def forget(user) user.forget cookies.delete(:user_id) cookies.delete(:remember_token) end # 現在のユーザーをログアウトする def log_out forget(current_user) session.delete(:user_id) @current_user = nil end
9.1.4 2つの目立たないバグ
小さなバグが2つ残っている。
1つ目のバグ
ユーザーが同じサイトを複数のタブで開いているとする。このとき、ユーザーが1つのタブでログアウトし、もう1つのタブで再度ログアウトしようとするとエラーとなる。これは、もう1つのタブで"Log out"リンクをクリックすると、current_userがnilとなってしまうため、log_outメソッド内のforget(current_user)が失敗してしまうから。この問題を解消するには、ユーザーがログイン中の場合にのみログアウトさせるとよい。
2つの目のバグ
ユーザーが複数のブラウザ(FirefoxやChrome)でログインしていたとき、例えば、Firefoxでログアウトし、
Chromeではロウアウトせずにブラウザを終了させ、再度Cromeで同じページを開くと、エラーとなる。FirefoxとChromeを使った例で考える。ユーザーがFirefoxからログアウトすると、user.forget
メソッドによってremember_digestがnilとなる。この時点では、Firefoxでまだアプリが正常に動作しているはず。このとき、log_out
メソッドによってuser_idが削除される。user_idが削除されたことにより、次のcurrent_userメソッドのuser_idの条件式で、どちらもfalseとなる。
# 記憶トークンcookieに対応するユーザーを返す def current_user if (user_id = session[:user_id]) ←falseになる @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) ←falseになる user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end
結果として、current_userメソッドの最終的な評価結果は、nilとなる。
一方、Chromeを閉じたとき、session[:user_id]はnilになる。(これはブラウザを閉じたときに、全てのセッション変数の有効期限が切れるため)。しかし、cookiesはブラウザの中に残り続けているため、Chromeを再起動してサンプルアプリにアクセスすると
、DBからユーザーを見つけることができる。
# 記憶トークンcookieに対応するユーザーを返す def current_user if (user_id = session[:user_id]) @current_user ||= User.find_by(id: user_id) elsif (user_id = cookies.signed[:user_id]) user = User.find_by(id: user_id) if user && user.authenticated?(cookies[:remember_token]) log_in user @current_user = user end end end
結果として、次のif文の条件式が評価される。
if user && user.authenticated?(cookies[:remember_token])
このとき、userがnilであれば1番目の条件式で評価は終了するが、実際にはnilでないので、2番目の条件式まで評価が進み、エラーが発生する。原因は、Firefoxでログアウトしたときに、ユーザーのremember_digestが削除してしまっているにもかかわらず、Chromeでアプリにアクセスしたときに次の文を実行しているから。
BCrypt::Password.new(remember_digest).is_password?(remember_token)
すなわち、上のremember_digestがnilになるので、bcyptライブラリ内部で例外が発生。この問題を解決するには、remember_digestが存在しないときはfalseを返す処理authenticated?に追加する必要がある。
テスト
上の2つのバグ修正に、テスト駆動開発はうってつけ。
test/integation/users_login_test.rb
#ログアウト用 delete logout_path assert_not is_logged_in? assert_redirected_to root_url #2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする delete logout_out 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
上のテストでは、current_userがないため、2回目のdelete logout_pathの呼び出しでエラーが発生し、テストは失敗。
次にこのテストを成功させる。logged_in?がtrueの場合に限ってlog_outを呼び出すようにする。
cotrollers/sessions_controller.rb
def destroy log_out if logged_in? ←①追加 redirect_to root_url end
①:ログアウトはログインしているときだけ
2番目のバグのテスト
統合テストで2種類のブラウザをシミュレートするのは困難。代わりに、同じ問題をUserモデルで直接テストする。
具体的には、記憶ダイジェストを持たないユーザーを用意し(setupメソッドで定義した@userインスタンスではtrueになる)、続いてauthenticated?を呼び出す。
test/models/user_test.rb
def setup @user = User.new(name: "Example User", email: "user@example.com", password: "foobar", password_confiramtion: "foovar") end : test "authenticated? should return false for a user with nil digest" do assert_not @user.authenticated?(' ') end end
このテストでは、Bcrypt::Password.new(nil)でエラーが発生するため、失敗する。
このテストを成功するためには、記憶ダイジェストがnilの場合にfalseを返すようにするとよい。
app/models/user.rb
# 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) return false if remember_digest.nil? ←追加 BCrypt::Password.new(remember_digest).is_password?(remember_token) end # ユーザーのログイン情報を破棄する def forget update_attribute(:remember_digest, nil) end end
ここでは、記憶ダイジェストがnilの場合にはreturnキーワードで即座にメソッドを終了している。
処理を中途で終了する場合によく使われるテクニック。これでテストは成功する。
|