はじめに
Ruby on Rails チュートリアル実例を使ってRailsを学ぼう 第4版の 9章 9.1 Remember meの演習まとめ&解答例です。
個人の解答例なので、誤りがあればご指摘ください。
9.1.1 記憶トークンと暗号化
本章での学び
永続cookiesを使って、ブラウザを再起動した後でもすぐにログインできる機能を実装する。
- 記憶トークン(remember token)の生成
- cookiesメソッドによる永続的cookiesの作成
- 安全性の高い記憶ダイジェスト(remember digest)によるトークン認証に記憶トークンを使用する
セキュリティ上の考慮事項
cookiesを永続化するとセッションハイジャック攻撃を受ける可能性がある。 記憶トークンを奪って、特定のユーザになりすましてログインする。 記憶トークンを奪う手段は以下4通り。
1.管理の甘いネットワークを通過するネットワークパケットから、パケットスニッファを使って直接cookieを取り出す。 対策:SSLをサイト全体に適用して、ネットワークデータを暗号化する。
2.DBから記憶トークンを取り出す。 対策:記憶トークンそのものを保存するのではなく、ハッシュ値を保存する。
3.クロスサイトスクリプティングを使う。 対策:Railsによって自動的に対策が行われる。具体的にはビューで入力された内容をすべて自動的にエスケープする。
4.ユーザーがログインしているPCやスマホを直接操作してアクセスを奪いとる。 対策:対策は不可能。二次被害を最小限に留めるために、ユーザが別端末でログアウトした際には必ずトークンを変更する。セキュリティ上重要になる可能性がある情報を表示する際には、デジタル署名を表示する。
永続セッションの実装方針
- 記憶トークンには、ランダムな文字列を生成して用いる。
- ブラウザのcookiesトークンを保存するときには、有効期限を設定する。
- トークンはハッシュ値に変換してから、データベースに保存する。
- ブラウザのcookiesに保存するユーザIDは暗号化しておく。
- 永続ユーザIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。
remember_digest属性をユーザモデルに追加
usersのデータモデルに、remember_digest属性を追加する。
yokoyan:~/workspace/sample_app (advanced-login) $ rails generate migration add_remember_digest_to_users remember_digest:string Running via Spring preloader in process 2174 Expected string default value for '--jbuilder'; got true (boolean) invoke active_record create db/migrate/20170514215307_add_remember_digest_to_users.rb
マイグレーション用のファイルが自動生成される。 記憶ダイジェストはユーザーが直接呼び出すことはないため、remember_digestカラムにインデックスは追加しない。
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0] def change add_column :users, :remember_digest, :string end end
rails db:migrate
で適用する。
yokoyan:~/workspace/sample_app (advanced-login) $ rails db:migrate == 20170514215307 AddRememberDigestToUsers: migrating ========================= -- add_column(:users, :remember_digest, :string) -> 0.0050s == 20170514215307 AddRememberDigestToUsers: migrated (0.0052s) ================
記憶トークンの生成
Ruby標準ライブラリのSecureRandom
モジュールにあるurlsafe_base64
メソッドを採用する。
A-Z,a-z,0-9,-,、,_のいずれかの文字(64文字)からなる長さ22のランダムな文字列を返す。
64文字だからbase64。
yokoyan:~/workspace/sample_app (advanced-login) $ rails console Running via Spring preloader in process 1128 Loading development environment (Rails 5.0.0.1) >> SecureRandom.urlsafe_base64 => "Dzw6pg00KzZUtdgQ2tm42Q"
fixture内のdigest
メソッド内に定義する。
Userオブジェクトが不要であるため、インスタンスメソッドではなく、Userモデルのクラスメソッドとして定義する。
def User.new_token SecureRandom_urlsafe_base64 end
記憶トークンのアクセサーを定義する
Userモデル内に、attr_accessor
で属性を定義すると、対応するアクセサーメソッド(setter、getter)が自動生成される。
今回は、記憶トークン(remember_token)のアクセサーを定義する。
attr_accessor :remember_token
記憶ダイジェストをデータベースに保存する
user.remember
メソッドにて実現する。
- 記憶トークン(remember_token)とユーザを関連付ける
- トークンに対応する記憶ダイジェスト(remember_digest)をデータベースに保存する
update_attribute
メソッドにて記憶ダイジェストを更新する
演習1
コンソールを開き、データベースにある最初のユーザーを変数userに代入してください。その後、そのuserオブジェクトからrememberメソッドがうまく動くかどうか確認してみましょう。また、remember_tokenとremember_digestの違いも確認してみてください。
?> user = User.first User Load (0.5ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2017-05-07 21:50:35", updated_at: "2017-05-07 21:50:35", password_digest: "$2a$10$esrzyVS.n7KGJ27bcs7UF.78tD7e75pMkwy6gnnwhlQ...", remember_digest: nil> >> user.remember (0.2ms) begin transaction SQL (0.4ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", 2017-05-16 04:16:50 UTC], ["remember_digest", "$2a$10$0Nw3IJy2BwvBcijbUx5tKuxGEuyYmEFYcz0lcr.6Dca3FaNii7Lp."], ["id", 1]] (13.1ms) commit transaction => true >> user.remember_token => "rGF2zWPxkxnQzJmrZfT9QQ" >> user.remember_digest => "$2a$10$0Nw3IJy2BwvBcijbUx5tKuxGEuyYmEFYcz0lcr.6Dca3FaNii7Lp."
演習2
リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。 しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、リスト 9.4 (ややわかりにくい) や、リスト 9.5 (非常に混乱する) の実装でも、正しく動くことを確認してみてください。ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。 わかりにくさの原因の一部はこの点にあります。
修正前のソース。
# 渡された文字列のハッシュ値を返す 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
参考ページ
以下がわかりやすかった。 Ruby 初級者のための class << self の話 (または特異クラスとメタクラス)
特異メソッド方式
リスト9.4の実装方法。def self.class_method
のように、メソッド名の前にクラスメソッドを定義する対象のクラス名をつけて定義する。
この場合のself
は、User
クラスを表す。
# 渡された文字列のハッシュ値を返す def self.digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end # ランダムなトークンを返す def self.new_token SecureRandom.urlsafe_base64 end
テスト結果がgreenになることを確認。
yokoyan:~/workspace/sample_app (advanced-login) $ rails test Running via Spring preloader in process 1992 Started with run options --seed 20784 23/23: [==============================================================================================================================] 100% Time: 00:00:01, Time: 00:00:01 Finished in 1.53597s 23 tests, 68 assertions, 0 failures, 0 errors, 0 skips
特異クラス方式
リスト9.5の実装方法。
class << self
と書いた行から、end
までの間に、def class_method
のようにクラス名を書かずにインスタンスメソッドと同じようなメソッド定義を書いていく。
この間に書かれたものは、全てクラスメソッドとして定義される。
特異メソッド方式のように、都度self.
と記載しなくて良い。
class User < ApplicationRecord attr_accessor :remember_token ・・・略・・・ class << self # この範囲がクラスメソッド # 渡された文字列のハッシュ値を返す def digest(string) cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost BCrypt::Password.create(string, cost: cost) end # ランダムなトークンを返す def new_token SecureRandom.urlsafe_base64 end end # この範囲がクラスメソッド ・・・略・・・ end
テスト結果がgreenになることを確認。
yokoyan:~/workspace/sample_app (advanced-login) $ rails test Running via Spring preloader in process 2069 Started with run options --seed 32363 23/23: [==============================================================================================================================] 100% Time: 00:00:01, Time: 00:00:01 Finished in 1.55466s 23 tests, 68 assertions, 0 failures, 0 errors, 0 skips
9.1.2 ログイン状態の保持
本章での学び
cookiesを20年間保持する
- cookiesはハッシュとして扱える。
- 個別のcookiesは、ひとつのvalue(値)と、expires(有効期限)からできている。
- expiresの省略も可能。
20年後に期限切れになる記憶トークンと同じ値をcookieに保存することで、永続的なセッションを作ることができる。
cookies[:remember_token] = { value: remember_token, expires: 20.years.from_now.utc }
上記のコードは以下のコートど同じ意味になる。
cookies.permanent[:remember_token] = remember_token
よく使われるため、Railsのpermanent
メソッドとして追加された。
rails/actionpack/lib/action_dispatch/middleware/cookies.rb
【helper】ユーザーIDをcookieに保存する
session
メソッドと同様、:user_id
をキーにして、user.id
を代入できる。
この場合、ユーザーIDが生のテキストとしてcookieに保存される。
cookies[:user_id] = user.id
署名付きcookieを使うためには、cookies.signed
メソッドを使用する。
cookieをブラウザに保存する前に暗号化を行う。
cookies.signed[:user_id] = user.id
【helper】永続化した署名付きcookieにユーザーIDを保存する
ユーザーIDと記憶トークンはペアで扱う必要がある。
cookieを永続化するために、signed
とpermanent
をメソッドチェーンで結ぶ。
cookies.permanent.signed[:user_id] = user.id
【Model】永続化した署名付きcookieからユーザーIDを取り出す
cookies
を設定すると、ページのビューから取り出すことが可能になる。
User.find_by(id: cookies.signed[:user_id])
渡された記憶トークンとユーザーの記憶ダイジェストの比較
GitHubのsecure_password.rbのソースを参考にする。
記憶ダイジェストと、暗号化されていないパスワード(unencrypted_password
)を比較している。
module InstanceMethodsOnActivation # Returns +self+ if the password is correct, otherwise +false+. # # class User < ActiveRecord::Base # has_secure_password validations: false # end # # user = User.new(name: 'david', password: 'mUc3m00RsqyRe') # user.save # user.authenticate('notright') # => false # user.authenticate('mUc3m00RsqyRe') # => user def authenticate(unencrypted_password) BCrypt::Password.new(password_digest).is_password?(unencrypted_password) && self end
上記のやり方を参考にして、記憶ダイジェストと記憶トークンの比較を行う。
BCrypt::Password.new(password_digest).is_password?(remember_token)
本処理は、User
モデルにauthenticated?
メソッドとして定義する。
# 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) BCrypt::Password.new(remember_digest).is_password?(remember_token) end
以下、注意点。
authenticated?
メソッドの引数である、remember_token
は、ローカル変数。User
モデル内で定義したアクセサattr_accessor :remember_token
とは異なる。Password.new
メソッドの引数である、remember_digest
は、User
モデルが持つ変数。self.remember_digest
と意味は同じ。remember_digest
属性は、Users
テーブルのカラムであるため、Active Record
によって取得が可能。
【controller】セッションコントローラでログインしてユーザを保持する
sessions_controller
のcreate
アクション内で、ログイン処理終了後、
sessions_helper
で定義したヘルパーメソッドremember user
を呼び出す。
if user && user.authenticate(params[:session][:password]) log_in user remember user redirect_to user
【helper】セッションヘルパーでユーザを保存する
コントローラであるsessions_controller
から呼び出されるヘルパーのsessions_helper
内で、
- Userモデルの
remember
メソッドを呼び出す- 記憶トークンの生成
User.new_token
- base64でランダムなトークンを返す
- 記憶トークンを記憶ダイジェストに変換
User.digest(rememer_token)
- BCryptでハッシュ化
- 記憶ダイジェストの値で、Usersテーブルを更新
update_attribute(:remember_digest, 記憶ダイジェストの値)
- 記憶トークンの生成
- cookieの永続化
cookies.permanent
- 署名付きcookieの生成
cookies.signed[:user_id]
- 永続化したcookieへ記憶トークンを保存
cookies.permanent[:remember_token]
【helper】永続セッションのcurrent_userを更新する
sessions_helper
内で定義している、current_user
ヘルパーは、一時セッションを使用している。
@current_user ||= User.find_by(id:session[:user_id])
永続セッションの場合
- session[:user_id]
が存在すれば、一時セッションからユーザーを取得する。
- cookies[:user_id]
が存在すれば、永続セッションからユーザーを取得する。
if (user_id = session[:user_id]) # 一時セッションからユーザーを取り出す elsif (user_id = cookies[:user_id]) # 永続セッションからユーザーを取り出す if # ユーザーが存在してかつ、永続セッションの中の記憶トークンがDBの値と一致する # ログイン処理 end end
動作確認
上記コードを実装して、ログインすると、cookieの有効期限が20年後になっている。 また、ブラウザを閉じて再度アプリケーションにアクセスすると、ログイン状態となっている。 (現状では、ログアウトできないためログインしっぱなしとなる)
演習1
ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。
動作確認のキャプチャ参照。 remember_tokenと、暗号化されたuser_idがあることを確認。
演習2
コンソールを開き、リスト 9.6のauthenticated?メソッドがうまく動くかどうか確かめてみましょう。
?> user = User.first User Load (0.2ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]] => #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2017-05-07 21:50:35", updated_at: "2017-05-21 22:01:07", password_digest: "$2a$10$esrzyVS.n7KGJ27bcs7UF.78tD7e75pMkwy6gnnwhlQ...", remember_digest: "$2a$10$JG8CGfVQNaJolnazSyxDJe7brrf/3TNExvNi2lWYWir..."> >> user.remember (0.1ms) begin transaction SQL (0.6ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", 2017-05-23 21:33:36 UTC], ["remember_digest", "$2a$10$wX/tlvvmb1BNA/2dy7qOaugLTQgVGP6Ojm2nskWTpe5RniAnijjmq"], ["id", 1]] (16.5ms) commit transaction => true >> user.authenticated?(user.remember_token) => true
9.1.3 ユーザーを忘れる
本章での学び
【モデル】ユーザーのログイン情報を破棄する
User
モデルにて、データベースで保持しているremember_digest
をnil
で更新する。
def forget update_attribute(:remember_digest, nil) end
【ヘルパー】永続セッションを破棄する
sessions_helper
のforget
ヘルパーメソッドにて、以下の処理を行う。
- モデルで定義した
User.foget
を呼び出す- データ・ベースで保持している
remember_digest
をnil
に更新する
- データ・ベースで保持している
- cookies内から
user_id
を削除 - cookies内から
remember_token
を削除
def forget(user) user.forget cookies.delete(:user_id) cookies.delete(:remember_token) end
【ヘルパー】ログアウトする
sessions_helper
のlogout
ヘルパーメソッドにて、以下の処理を行う。
- forgetヘルパーメソッドの呼び出し
- sessionから
user_id
を削除 @current_user
をnil
に更新
def logout forget(current_user) session.delete(:user_id) @current_user = nil end
演習1
ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。
ログアウト前のcookiesの状態。
ログアウト後のcookiesの状態。 cookiesが消えていることを確認。
9.1.4 2つの目立たないバグ
本章での学び
バグその1
再現手順
- 同じサイトを複数のタブで開く
- タブAでログアウトする
- タブBでログアウトリンクをクリックすると、エラーになる
原因
タブAでログアウトしたタイミングで、current_user
がnil
になってしまうため。
@current_user = nil
タブBのforget(current_user)
の引数にnil
が渡され、undefined method `forget' for nil:NilClassエラーが発生している。
対策
sessions_controller.rb
のdestroy
メソッドを修正する。
current_user
がnil
ではない場合(ログイン状態の場合)に、log_out
メソッドを呼び出す。
def destroy log_out if logged_in? redirect_to root_url end
バグその2
再現手順
同じユーザでFirefoxとChromeでログインする
Firefoxでログアウトする
Chromeではログアウトせずにブラウザを終了する
- 再度、Chromeでアクセスするとエラーが発生する。
- BCrypt::Errors::InvalidHash: BCrypt::Errors::InvalidHash: invalid hashが発生。
原因
ブラウザAでログアウトしたタイミングで、remember_digest
がDBから削除されているにもかかわらず、
ブラウザBでcookiesが残っているため、DBからユーザが取得できてしまう。
結果、ブラウザB側のBCrypt
の処理でremember_digest
を参照しようとしてエラーが発生している。
対策
remember_digest
が存在しない(値がnil)場合は、処理を継続させない。
# 渡されたトークンがダイジェストと一致したらtrueを返す def authenticated?(remember_token) return false if remember_digest.nil? BCrypt::Password.new(remember_digest).is_password?(remember_token) end
演習1
リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。
バグその1を参照。
演習2
リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。
バグその2を参照。
演習3
上のコードでコメントアウトした部分を元に戻し、テストスイートが red から greenになることを確認しましょう。
修正後、テストがgreenになることを確認。
yokoyan:~/workspace/sample_app (advanced-login) $ rails test Running via Spring preloader in process 1713 Started with run options --seed 42203 24/24: [==============================================================================================================================] 100% Time: 00:00:01, Time: 00:00:01 Finished in 1.25789s 24 tests, 69 assertions, 0 failures, 0 errors, 0 skips
おわりに
9章から急に難易度が上がりました。 MVCモデルのうち、どこを修正しているのかを意識しないと迷子になるな・・・。 sessionやcookieはWEBアプリケーションに欠かせないものなので、 この章は気合を入れて理解に努めます。