前提知識
remember me 機能
「ユーザーのログイン情報を記憶し、ブラウザを再起動した後でも、ログイン情報を改めて入力する必要なく直ちにログイン後のユーザー情報にアクセスできるようにする」という機能です。
永続クッキー
英語では「permanent cookies」と呼ばれます。Webアプリケーション側が指定した期間にわたり、ユーザーの端末上に保存されるようにしたcookiesのことを指します。ブラウザプロセスの終了により破棄される「セッションクッキー(session cookies)」に対する概念です。
記憶トークンと暗号化
この項で行うこと
記憶トークン(remember token)を作成します。これから実装する以下のような機能に向けての第一ステップです。
-
cookies
メソッドによる永続的cookiesの作成 - 安全性の高い記憶ダイジェスト(remember digest)によるトークン認証の実装
セッションハイジャック
永続的cookiesと表裏一体で存在する攻撃手法として、「セッションハイジャック」という手法があります。「記憶トークンを奪って、特定のユーザーになりすましてログインする」という攻撃手法です。このような攻撃を実現するためには、以下のような手法が用いられます。
- パケットスニッファソフトにより直接cookieを取り出す
- ネットワークもしくはアプリケーションの管理が甘く、ネットワークパケットを直接抜き取れるようなアプリケーションであることが前提
- データベースから記憶トークンを取り出す
- クロスサイトスクリプティングを使う
- ユーザーが使用している端末を直接操作してアクセスを奪い取る
セッションハイジャックへの対策
「ネットワークパケットを直接抜き取る」という攻撃手法への対策
「サイト全体で、エンドツーエンドの通信に対してSSLを適用する」という対策が代表的です。エンドツーエンドで通信が暗号化されていれば、ネットワークパケットを直接抜き取っても通信内容の機密は保たれます。config/environments/production.rb
に対する以下の設定内容は、当該対策に関わるものですね。
config.force_ssl = true
「データベースから記憶トークンを取り出す」という攻撃手法への対策
「DBMSにおいて、生の記憶トークンではなく、記憶トークンのハッシュ値を保存するようにする」という対策が代表的です。当サンプルアプリケーションの過去の実装において、Userモデルのパスワードに対して同様の実装を行いましたよね。関連するメソッドとしては以下のようなものがあります。
has_secure_password
authenticate
「クロスサイトスクリプティングを使う」という攻撃手法への対策
こちらはRailsそのものに対策の仕組みが実装されています。具体的には、「ビューのテンプレートで入力した内容を自動的にエスケープする」というものです。
「ユーザーが使用している端末を直接操作してアクセスを奪い取る」という攻撃手法への対策
さすがに攻撃そのものに対してWebアプリケーション側で取れる根本的対策はありません。しかしながら、被害を最小限に留める仕組みはあります。以下のようなものです。
- ユーザーが(別端末などで)ログアウトしたときに必ずトークンを変更するようにする
- セキュリティ上重要になる可能性のある情報を表示するときはデジタル署名を行う
永続的セッションの実装はどのような方針をとるのか
セキュリティ上考慮するべき点を踏まえ、永続的セッションの実装は以下の方針をとることとします。
- 記憶トークンには、ランダムな文字列を生成して用いる
- ブラウザのcookiesにトークンを保存する際には、有効期限を設定する
- トークンはハッシュ値に変換してからRDBに保存する
- ブラウザのcookiesに保存するユーザーIDは暗号化しておく
- 永続ユーザーIDを含むcookiesを受け取った場合の対応
- 受け取ったIDでRDBを検索する
- 記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する
remember_digest
属性と、Userモデルの新たな設計
トークンはハッシュ値に変換してからRDBに保存する
上記の実装方針を踏まえ、「トークンのハッシュ値を保存するための属性」がRDBに必要となります。remember_digest
という属性名で当該属性を定義することとします。remember_digest
属性追加後のUserモデルの定義は以下の通りになります。
Userモデルの変更を、実際にRDBに反映します。使用するのはrails generate migration
コマンドですね。
# rails generate migration add_remember_digest_to_users remember_digest:string
Running via Spring preloader in process 438
invoke active_record
create db/migrate/[timestamp]_add_remember_digest_to_users.rb
マイグレーションの実体である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
password_digest
と同様、remember_digest
もユーザーが直接読み出すことはありません(かつ、そうさせてはならない属性です)。そのため、インデックスを生成する必要もありません。生成されたマイグレーションには手を加えず、そのまま使います。
# rails db:migrate
== [timestamp] AddRememberDigestToUsers: migrating =========================
-- add_column(:users, :remember_digest, :string)
-> 0.0164s
== [timestamp] AddRememberDigestToUsers: migrated (0.0169s) ================
改めてpassword_digest
属性の実装を振り返る
「トークンのハッシュ値を保存するための属性」といえば、過去に実装した「パスワードのハッシュ値を保存するための属性」と似ています。先ほどのUserモデルの定義における、password_digest
属性がそれです。このとき使ったコマンドは以下でした。
rails generate migration add_password_digest_to_users password_digest:string
マイグレーションの実体であるdb/migrate/[timestamp]_add_password_digest_to_users.rb
の内容は以下の通りでしたね。
class AddPasswordDigestToUsers < ActiveRecord::Migration[5.1]
def change
add_column :users, :password_digest, :string
end
end
このとき生成されたマイグレーションに手を加えなかったのも同じです。
rails db:migrate
記憶トークンとしてSecureRandom.urlsafe_base64
メソッドの結果を使う
記憶トークンとして用いる文字列に求められる性質
記憶トークンとして用いる文字列には、以下のような性質が求められます。
- 十分な長さを有すること
- 十分にランダムであること
- (できれば)一意であること
- 同一のトークンを持つユーザーが複数いても問題はない
- 一方で、セッションハイジャックのリスクなどを考え、トークンは一意であるほうが安全とはいえる
SecureRandom
ライブラリ
Ruby標準のライブラリの一つです。Ruby 2.6.0 リファレンスマニュアルには、以下のような説明があります。
安全な乱数発生器のためのインターフェースを提供するモジュールです。 HTTP のセッションキーなどに適しています。
urlsafe_base64
メソッド
SecureRandom
ライブラリに含まれる特異メソッドの一つです。a
-z
、A
-Z
、0
-9
、-
、_
のみから成るランダムな文字列を返します。引数を指定しなければ、返ってくる文字列の文字数は22となります。
# rails console
>> SecureRandom.urlsafe_base64
=> "eBnhzEgyNc1zWD83O1SQZQ"
上記はSecureRandom.urlsafe_base64
メソッドの典型的な実行例です。
SecureRandom.urlsafe_base64
の実行結果が意味するところ
引数なしのurlsafe_base64
の実行結果は、「64種類の文字からなる長さ22のランダムな文字列」です。ランダム性が十分であれば、このメソッドの実行結果がたまたま完全に一致する可能性は、「$1/64^{22}=2^{-132}\approx10^{-40}$」となります。衝突の可能性は無視できるほど低い確率であり、一意性は十分といえます。
また、メソッド名に urlsafe という語が入っているように、urlsafe_base64
の実行結果は、エスケープなしにURLに埋め込むことができます。URLを扱う操作においても、特段の配慮なしに使うことができるわけなのです。URLでトークンを扱う操作の例を以下に列挙します。
- アカウント有効化のリンクを発行する
- パスワードリセットのリンクを発行する
Userモデルにトークン生成用メソッドを追加する
class User < ApplicationRecord
...略
+
+ # ランダムなトークンを返す
+ def User.new_token
+ SecureRandom.urlsafe_base64
+ end
end
ここで重要なのは、「new_token
メソッドは、Userモデルのオブジェクトを必要としない。ゆえに(インスタンスメソッドではなく)クラスメソッドとして定義する」という実装の方針です。
同じくUserモデル内のクラスメソッドとして、「渡された文字列のハッシュ値を返す」というdigest
メソッドが既に実装されています。そのコードは以下です。
# 渡された文字列のハッシュ値を返す
def User.digest(unencrypted_password)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
BCrypt::Password.create(unencrypted_password, cost: cost)
end
new_token
メソッドとdigest
メソッドにより、「ランダムな文字列を生成し、ハッシュ値に変換してからRDBに保存する」という一連の動作が実現できます。
user.remember
メソッドの作成
user.remember
メソッドとは
user.remember
メソッドは、以下の動作をするメソッドです。
- 記憶トークンをユーザーと関連付ける
- トークンに対応する記憶ダイジェストをRDBに保存する
remember_token
属性
生の記憶トークンそのもの(remember_token
属性とします)は、RDBには保存せず、ユーザー環境のcookiesに保存されます。「Userモデルにはremember_token
属性を定義しつつ、RDBにはremember_token
属性を定義しない」という実装が求められます。
この関係は、Userモデル上のパスワードの実装における「RDBに定義されるpassword_digest
属性と、RDBには定義されないpassword
およびpassword_confirmation
属性との関係」に類似しています。しかしながら、パスワードの実装におけるhas_secure_password
メソッドのような仕組みは、残念ながらトークンの実装では使えません。remember_token
属性のコードは自分で書く必要があります。
attr_accessor
メソッドと、remember_token
属性の定義
remember_token
は、Userモデルのインスタンス変数として定義します。cookiesにより永続化され、Userモデルの外から読み書きが行われるインスタンス変数であるため、attr_accessor
メソッドによりUserモデルの外から読み書きができるようにします。コードは以下です。
attr_accessor :remember_token
user.remember
メソッドの実装
user.remember
メソッドの実装は以下になります。
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
以下が重要なポイントです。
-
self.remember_token
により、@remember_token
に値を代入している- より厳密には、
@remember_token
に値をセットするremember_token
メソッドが呼び出されている
- より厳密には、
- RDBへの保存処理が
update_attribute
により行われている-
save
でもなければupdate_attributes
でもない - バリデーションを素通りさせる必要があるため
-
最終的に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
この時点でテストは正しく完了します。
# rails test
Running via Spring preloader in process 478
Started with run options --seed 51133
24/24: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.72491s
24 tests, 68 assertions, 0 failures, 0 errors, 0 skips
リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、
User.new_token
やUser.digest
を使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。
こうした技巧的な派生コードについては、演習2.1.および演習2.2.で触れてみることにします。
ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。
演習 - 記憶トークンと暗号化
1.1. コンソールを開き、データベースにある最初のユーザーを変数user
に代入してください。その後、そのuser
オブジェクトからremember
メソッドがうまく動くかどうか確認してみましょう。
# rails console --sandbox
>> user = User.find(1)
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2019-10-22 22:46:59", updated_at: "2019-10-22 22:46:59", password_digest: "$2a$10$j21OfGX82PY0/BqDcapJmeeo/xaVKgSQ9pEZD8hAp4B...", remember_digest: nil>
>> user.remember
(0.1ms) SAVEPOINT active_record_1
SQL (9.0ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", "2019-11-05 10:08:44.739223"], ["remember_digest", "$2a$10$6l.Ri3zvU7ux2jQ5nvftC.irkIhGI/BdhYO.K7hDwyDtSY/z7.z8e"], ["id", 1]]
(0.2ms) RELEASE SAVEPOINT active_record_1
=> true
>> user
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2019-10-22 22:46:59", updated_at: "2019-11-05 10:08:44", password_digest: "$2a$10$j21OfGX82PY0/BqDcapJmeeo/xaVKgSQ9pEZD8hAp4B...", remember_digest: "$2a$10$6l.Ri3zvU7ux2jQ5nvftC.irkIhGI/BdhYO.K7hDwyD...">
SQLのUPDATE
文が正常に発行され、remember
メソッドそのものもtrue
を返してきています。remember
メソッドはうまく動いていると判断できます。
UPDATE
文中にremember_token
が存在しないのは一つのポイントです。
1.2. remember_token
とremember_digest
の違いも確認してみてください。
>> user.remember_token
=> "Iiq68oTYkHXriAF-aFx83w"
>> user.remember_digest
=> "$2a$10$6l.Ri3zvU7ux2jQ5nvftC.irkIhGI/BdhYO.K7hDwyDtSY/z7.z8e"
「何らかの関数により生成されたランダムな文字列」という点は同じですが、文字数や使用する文字に違いが見られます。
文字数は以下のとおりです。
-
remember_token
…22文字 -
remember_digest
…60文字
remember_token
はURL安全な文字のみで構成される一方、remember_digest
にはURL安全でない文字(/
など)が含まれます。
remember_digest
は、$2a$10$
という文字列で始まっています。この部分の内容はBCryptの仕様で決められています。
-
$2a$
…BCryptのバージョンを表す -
10$
…コストパラメータが10であることを表す
>> user.inspect
=> "#<User id: 1, name: \"Rails Tutorial\", email: \"example@railstutorial.org\", created_at: \"2019-10-22 22:46:59\", updated_at: \"2019-11-05 10:08:44\", password_digest: \"$2a$10$j21OfGX82PY0/BqDcapJmeeo/xaVKgSQ9pEZD8hAp4B...\", remember_digest: \"$2a$10$6l.Ri3zvU7ux2jQ5nvftC.irkIhGI/BdhYO.K7hDwyD...\">"
「remember_token
属性は、inspect
メソッドの結果に表示されない」というのも大きな特徴です。inspect
の結果に表示させないようにするために、何らかの仕組みがあるのでしょうか。
2.1. テストスイートを実行して、ややわかりにくいリスト 9.4の実装で正しく動くことを確認してみてください。
class User < ApplicationRecord
attr_accessor :remember_token
...略
# 渡された文字列のハッシュ値を返す
- def User.digest(unencrypted_password)
+ def self.digest(unencrypted_password)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
BCrypt::Password.create(unencrypted_password, cost: cost)
end
+
+ # ランダムなトークンを返す
+ def self.new_token
+ SecureRandom.urlsafe_base64
+ end
+
+ # 永続セッションのためにユーザーをデータベースに記憶する
+ def remember
+ self.remember_token = User.new_token
+ update_attribute(:remember_digest, User.digest(remember_token))
+ end
end
# rails test
Running via Spring preloader in process 606
Started with run options --seed 56238
24/24: [=================================] 100% Time: 00:00:03, Time: 00:00:03
Finished in 3.14859s
24 tests, 68 assertions, 0 failures, 0 errors, 0 skips
テストは通りました。
# rails console --sandbox
>> user=User.find(1)
>> user.remember
(0.1ms) SAVEPOINT active_record_1
SQL (11.8ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", "2019-11-06 10:03:03.340289"], ["remember_digest", "$2a$10$eu2GAXNC3acJtvcE4i2/NeVOwr06wWCDIw9Sefyd7wigubxpx75zS"], ["id", 1]]
(0.2ms) RELEASE SAVEPOINT active_record_1
=> true
演習 1.1. の処理も正しく実行できることが確認できました。
2.2. テストスイートを実行して、非常に混乱しやすいリスト 9.5の実装でも正しく動くことを確認してみてください。
class User < ApplicationRecord
attr_accessor :remember_token
...略
- # 渡された文字列のハッシュ値を返す
- def self.digest(unencrypted_password)
- cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
- BCrypt::Password.create(unencrypted_password, cost: cost)
- end
-
- # ランダムなトークンを返す
- def User.new_token
- SecureRandom.urlsafe_base64
- end
+ class << self
+ # 渡された文字列のハッシュ値を返す
+ def digest(unencrypted_password)
+ cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
+ BCrypt::Password.create(unencrypted_password, cost: cost)
+ end
+
+ # ランダムなトークンを返す
+ def new_token
+ SecureRandom.urlsafe_base64
+ end
+ end
# 永続セッションのためにユーザーをデータベースに記憶する
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
end
# rails test
Running via Spring preloader in process 632
Started with run options --seed 12033
24/24: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.46399s
24 tests, 68 assertions, 0 failures, 0 errors, 0 skips
テストは通りました。
# rails console --sandbox
>> user=User.find(1)
>> user.remember
(0.1ms) SAVEPOINT active_record_1
SQL (12.8ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", "2019-11-06 10:15:34.199120"], ["remember_digest", "$2a$10$l1/8SG9CKjVxTqc1F4Ffp.xsHucOWMNLcTlEpkKxqYwEsmxlDl2Yq"], ["id", 1]]
(0.1ms) RELEASE SAVEPOINT active_record_1
=> true
演習 1.1. の処理も正しく実行できることが確認できました。
ログイン状態の保持
これから何をするか
ここまでの実装で、user.remember
メソッドが動作するようになりました。これにより、「ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存して、永続セッションを作成する」という処理が実装できる準備が整ったことになります。
cookies
メソッド
上述の処理を実際に行うには、cookies
メソッドを使います。cookies
も、session
と同様、ハッシュのような形で使うことができます。
個別のcookiesは、1つのvalue
(値)と、オプションのexprires
(有効期限)からなります。例えば以下のコードは、「記憶トークンと同じ値を有効期限20年のcookiesに保存する」というものです。
cookies[:remember_token] = { value: remember_token,
exprires: 20.years.from_now.utc }
Rails界隈においては、一般に、有効期限20年のcookiesをもって「永続cookies」とされます。cookies.permanent
メソッドを用いた以下のコードも、上述exprires: 20.years.from_now.utc
を用いたコードと同じ動作になります。
cookies.permanent[:remember_token] = remember_token
ユーザーIDをcookiesに保存する基本的な方法
sessions
と記法としては同じです。単にsessions
がcookies
に変わっただけといえます。
cookies[:user_id] = user_id
ただし、cookies
メソッドをそのまま使うと、保存内容は生のテキスト(平文)になってしまいます。「アプリケーションのcookiesの形式が外部から見えてしまう」というのは、セキュリティ上あまりよろしいとはいえません。
署名付きcookieにより、ユーザーIDをcookiesに安全に保存する
そこで、cookieの安全性を保つ技術である「署名付きcookie」を使うようにします。署名付きcookieを使う際に用いるメソッドはcookies.signed
です。
cookies.signed[:user_id] = user_id
cookies.signed
は、内部で以下の処理を行っています。
- cookiesの暗号化
- cookiesへのデジタル署名の付与
cookies.permanent.signed
の意味
cookies.permanent
とcookies.signed
はメソッドチェーンで繋いで使用することができます。
ユーザーIDと記憶トークンはペアで用いられます。そのため、cookieも永続化される必要があります。cookies.permanent.signed
というのは、「署名付きcookieを永続化する」という意味にほかなりません。
cookies.permanent.signed[:user_id] = user_id
設定されたcookiesを使う
設定されたcookiesからは、cookies.signed
メソッドにより情報を取り出すことができます。今回用いるcookiesには、暗号化された状態で情報が保存されていますが、cookies.signed
には、暗号化を解除する機能も実装されています。
User.find_by(id: cookies.signed[:user_id])
上記コードは、「cookiesに保存されたユーザーIDをキーとして、Userモデルに紐付けされたRDBを検索する」という動作をします。
記憶トークンの一致を確認する
BCrypt::Password.new(password_digest) == remember_token
この場面における==
演算子(メソッド)およびis_password?
メソッドの意味
この場面で==
が使えるようになるに至るプロセスは、実にRubyらしいプロセスです。bcrypt gemのソースコードであるbcrypt-ruby/lib/bcrypt/password.rb
を見ると、演算子とされる==
そのものが再定義されているのです。
def ==(secret)
super(BCrypt::Engine.hash_secret(secret, @salt))
end
alias_method :is_password?, :==
「==
メソッドの引数をハッシュ化した上で、==
メソッドのレシーバと、通常の==
によって比較する」という動作をする、と説明できます。また、同じメソッドが、論理値を返すis_password?
メソッドという名称でも定義されています。
結果、以下3つのコードは同じ意味になります。
BCrypt::Password.new(password_digest) == remember_token
BCrypt::Password.new(password_digest).==(remember_token)
BCrypt::Password.new(password_digest).is_password?(remember_token)
「論理値を返すこと」を明確にするために、今回はis_password?
メソッドを使います。
BCrypt::Password.new(password_digest).is_password?(remember_token)
Userモデルに、記憶トークンを確認するauthenticated?
メソッドを定義する
app/models/user.rb
に、以下のようにauthenticated?
メソッドの定義を追加していきます。
class User < ApplicationRecord
attr_accessor :remember_token
...略
+
+ # 渡されたトークンがダイジェストと一致したらtrueを返す
+ def authenticated?(remember_token)
+ BCrypt::Password.new(remember_digest).is_password?(remember_token)
+ end
end
authenticated?
メソッドの実装においては、以下が重要なポイントです。
-
authenticated?
メソッドの引数のremember_token
について- インスタンス変数である
self.remember_token
と同じ名前を使っている - インスタンス変数である
self.remember_token
とは異なる実体を指す
- インスタンス変数である
-
authenticated?
メソッド内のremember_digest
について- インスタンス変数である
self.remember_digest
と同じ実体を指す -
remember_token
とは異なり、self.
が省略できる
- インスタンス変数である
この挙動の違いを説明するにあたり、ポイントになるのは、「remember_digest
はRDBのカラムと紐付けされている。一方remember_token
はRDBのカラムと紐付けされていない。」という点です。
attr_accessor
などによってゲッター・セッターメソッドが定義されたインスタンス変数の場合、ゲッター・セッターメソッドを呼び出す際にself.
は省略できません。しかしながら、「Active RecordによってRDBのカラムと紐付けされたインスタンス変数」については、Active Recordに実装された仕組みにより、self.
を省略しても参照できる形でインスタンス変数が定義されます。そういえば、name
やemail
もself.
なしでUserモデルのインスタンス変数に直接アクセスしていましたね。
Sessionsヘルパーに、永続Cookiesへの保存処理を実装する
module SessionsHelper
...略
+
+ # ユーザーのセッションを永続的にする
+ def remember(user)
+ user.remember
+ cookies.permanent.signed[:user_id] = user.id
+ cookies.permanent[:remember_token] = user.remember_token
+ end
...略
end
今回追加したSessionsHelper#remember
では、以下の処理を行っています。
- ユーザーの
remember_digest
属性を更新する - 暗号化したユーザーのIDを永続cookiesに保存する
- ユーザーの記憶トークンを永続cookiesに保存する
Sessionsコントローラーに、永続cookiesへの保存処理を実装する
SessionsHelper#remember
までの実装が済んでいるなら、app/controllers/sessions_controller.rb
にはremember user
の1行を追加するだけです。
class SessionsController < ApplicationController
...略
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
...略
end
end
...略
end
永続cookiesによるログインを実装する
def current_user
if session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
end
「ログイン中のユーザー」を表すcurrent_user
メソッドの現在の実装は、上記のとおりです。しかしながら現在の実装は、一時セッションしか扱っていません。永続セッションを扱えるようにするためには、current_user
メソッドの実装を変更する必要があります。
新たなcurrent_user
メソッドの実装方針は以下です。
-
session[:user_id]
が存在すれば、session[:user_id]
でログイン -
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
user && user.authenticated?
というのは、「ユーザーが存在し、かつ認証トークンが正しければtrue
」という意味になります。基本的なログイン機構の実装でも同様のコードが登場しました。
ただ、この実装内容では、session
メソッドもcookies.signed
メソッドもそれぞれ2回呼び出されることになります。まだ改善の余地がありそうですね。
基本的な実装内容からのリファクタリング
ということで、ローカル変数を使ってコードを改善します。
def current_user
if (user_id = session[:user_id])
@current_user ||= User.find_by(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
session
・cookies.signed
、いずれのメソッドも呼び出しは1回限りになりました。ただ、そのコードの中身については、少々紛らわしいので説明が必要です。
if (user_id = foobar)
の意味
if (user_id = session[:user_id])
例えば上記のコードは、下記のコードと同様の意味になることを想定して使われています。「(ユーザーIDにユーザーIDのセッションを代入した結果) ユーザーIDのセッションが存在すれば」という意味です。
unless (user_id = session[:user_id]).nil?
ポイントは以下です。
-
==
ではなく=
である- 等価比較ではなく代入である
- Rubyにおいては、オブジェクトを真偽値に変換する(
!!
)と、false
とnil
以外は全てtrue
として評価される- この場面で
user_id
がfalse
になることは想定されない
- この場面で
実際のcurrent_user
メソッドの定義
以上を踏まえた上で、app/helpers/sessions_helper.rb
にcurrent_user
メソッドを定義します。
module SessionsHelper
...略
+ # 現在ログイン中のユーザーを返す(いる場合)
+ 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
...略
end
無事永続cookiesが発行されるようになります。以下は、実際に発行された永続cookiesの例です。
名前もきちんとuser_id
とremember_token
ですね。
ログアウトできない!
現状では、(cookiesを直接削除するか、20年経過しない限り)このアプリケーションからログアウトする手段がありません。テストも通りません。
# rails test
Running via Spring preloader in process 107
Started with run options --seed 55952
FAIL["test_login_with_valid_information_followed_by_logout", UsersLoginTest, 2.62310190000062]
test_login_with_valid_information_followed_by_logout#UsersLoginTest (2.62s)
Expected at least 1 element matching "a[href="/login"]", found 0..
Expected 0 to be >= 1.
test/integration/users_login_test.rb:33:in `block in <class:UsersLoginTest>'
24/24: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.62620s
24 tests, 66 assertions, 1 failures, 0 errors, 0 skips
test/integration/users_login_test.rb
の33行目において「/login へのハイパーリンクが1つ見つからなければならないのに、実際には1つも見つからない」ということでテストが失敗しています。
delete logout_path
assert_not is_logged_in?
assert_redirected_to root_url
follow_redirect!
assert_select "a[href=?]", login_path
- /logout に対して
delete
アクションが正常に発行され、正常に完了しいる-
session[:user_id]
がnil
になる - リダイレクト先は / である
-
- しかしながら、リダイレクト先の / に「Log in」リンクは存在しない
次に実装すべきは、永続cookiesが実装された状態からユーザーがログアウトする機能です。
演習 - ログイン状態の保持
1. ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。
上述「実際のcurrent_user
メソッドの定義」の通りです。
2. コンソールを開き、リスト 9.6のauthenticated?
メソッドがうまく動くかどうか確かめてみましょう。
以下のユーザーが対象であることを前提とします。
また、以下の永続cookiesが存在することを前提とします。
remember_token
の値はiwYAfqnByUCCT7RnWS4IgQ
となっています。
# rails console --sandbox
>> user = User.find(2)
=> #<User id: 2, ...略>
>> user.authenticated?("iwYAfqnByUCCT7RnWS4IgQ")
=> true
>> SecureRandom.urlsafe_base64
=> "Eky2ZYTXB_a7gq-s5fv4ig"
>> user.authenticated?("Eky2ZYTXB_a7gq-s5fv4ig")
=> false
- 永続cookiesの
remember_token
の値を引数とするとtrue
が返ってきた -
SecureRandom.urlsafe_base64
で適当に生成したトークンを引数とするとfalse
が返ってきた
上述の結果から、authenticated?
メソッドはうまく動いているようです。
ユーザーを忘れる
Userモデルにforget
メソッドを実装する
永続cookiesが実装された状態からユーザーがログアウトする機能として、Userモデルにforget
メソッドを実装します。具体的には、「remember_digest
属性の内容をnil
で上書きする(そして、その変更をRDBに保存する)」という動作をするメソッドです。
def forget
update_attribute(:remember_digest, nil)
end
Userモデルなので、コードを追加するのはapp/models/user.rb
です。
class User < ApplicationRecord
attr_accessor :remember_token
...略
+
+ # ユーザーのログイン情報を破棄する
+ def forget
+ update_attribute(:remember_digest, nil)
+ end
+ end
Sessionsヘルパーに追加する、永続的セッションを破棄するforget
メソッド
内容は以下のとおりです。
def forget(user)
user.forget
cookies.delete(:user_id)
cookies.delete(:remember_token)
end
Sessionsヘルパーのforget
メソッドで実際に行う処理は以下のとおりとなります。
- Userモデルの
forget
メソッドを呼び出している- RDBに保存された
remember_digest
を破棄する
- RDBに保存された
- 以下の永続cookiesを削除している
- 暗号化されたユーザーID
- 認証トークン
Sessionsヘルパーに、永続的セッションを破棄してログアウトする処理を追加する
app/helpers/sessions_helper.rb
に、forget
メソッド、ならびにlog_out
メソッド内でのforget
メソッドの呼び出しを追加します。
module SessionsHelper
...略
+
+ # 永続的セッションを破棄する
+ 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
end
改めてテストを実行する
# rails test
Running via Spring preloader in process 136
Started with run options --seed 25135
24/24: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.09096s
24 tests, 68 assertions, 0 failures, 0 errors, 0 skips
この時点でテストは問題なく通ります。
演習 - ユーザーを忘れる
1. ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。
ログイン中の時点では、remember_token
とuser_id
という、有効期限が20年後に設定された2つのcookiesが存在します。
ログアウトすると、remember_token
とuser_id
という2つのcookiesはいずれも削除されました。
2つの目立たないバグ
バグの内容
その1「複数のタブから複数回ログアウト操作をするとエラーになる」
以下の操作をすると、現状ではRailsのエラー画面が表示される事態が発生してしまいます。
- 同一ブラウザの複数タブ(または複数ウィンドウ)で、ログイン中のユーザーページを開く
- 1つのタブで「Log out」をクリックし、ログアウト操作を行う
- 別のタブで「Log out」をクリックする
エラー画面のスクリーンショットを以下に掲載します。発生するエラーは、SessionsController#destroy
のNoMethodError
エラーです。
エラーの原因は、「current_user
がnil
であるにもかかわらず、current_user
を引数としたforget
メソッドを呼び出してしまう」という操作にあります。しかしながら、「ユーザーが通常のブラウジングで行いうる操作に対して例外を投げてしまう」というのはよろしくありません。
その2「複数のWebブラウザでログインし、そのうちの一つからログアウトすると、別のWebブラウザを終了して再起動したときにエラーになる」
以下の操作をすると、現状ではRailsのエラー画面が表示される事態が発生してしまいます。
- 複数のWebブラウザ(例えばFirefoxとSafari)で、特定のユーザーにログインする
- 1つのWebブラウザ(例えばFirefox)で「Log out」をクリックし、ログアウト操作を行う
- 別のWebブラウザ(例えばSafari)で、ログアウトせずに終了する
- ログアウトしていないほうのWebブラウザ(例えばSafari)を再起動し、アプリケーションの任意のページを表示する
エラー画面のスクリーンショットを以下に掲載します。発生するエラーは、表示しようとしたページのBCrypt::Errors::InvalidHash
エラーです。今回は表示しようとしたページが / なので、エラーはStaticPages#home
で発生しています。
エラーの原因は、「一つのWebブラウザでログアウト操作を行った時点でRDBからremember_digest
の属性値が削除されるにもかかわらず、別のWebブラウザからremember_token
が入力されてしまう」という操作にあります。
もう少し詳しい説明
1つ目のWebブラウザでログアウトすると、Sessionsヘルパーのlog_out
メソッドが呼び出されます。結果、以下の処理が行われます。
- 1つ目のWebブラウザの永続cookiesが削除される
- 1つ目のWebブラウザの一時cookiesが削除される
結果、以下のcurrent_user
メソッドの戻り値はnil
になります。
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
一方、2つ目のWebブラウザの内部状態は以下のようになります。
- 2つ目のWebブラウザのプログラムを終了した時点で、2つ目のWebブラウザの一時cookiesは破棄される
- 一連の動作において、2つ目のWebブラウザの永続cookiesは削除されない
結果、以下の文の評価結果がtrue
となります。
elsif (user_id = cookies.signed[:user_id])
処理は以下の文まで進み…
if user && user.authenticated?(cookies[:remember_token])
Userモデルのauthenticated?
メソッドが呼び出され…
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
BCrypt::Password.new(remember_digest)
メソッドでエラーとなります。「1つ目のWebブラウザにおける操作により、既にremember_digest
の内容はnil
である。パスワードのハッシュとしてnil
という値はありえないはずなのに、それを引数としてBCrypt::Password.new
メソッドが呼び出されてしまう。結果、例外を投げる」というわけです。
テストによるバグの解決
ここは「テストによりバグを解決する」という局面です。実際には、以下のテストを書いていきます。
- 2番目のウィンドウでログアウトするユーザーを想定したテスト
- 記憶ダイジェストを持たないユーザーに対する動作のテスト
2番目のウィンドウでログアウトするユーザーを想定したテスト
テストを書く
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
...略
test "login with valid information followed by logout" do
get login_path
post login_path, params: { session: { email: @user.email, password: 'password' } }
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
+ # 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
+ delete logout_path
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
end
テストを実行する(通らない)
テストを実行してみます。結果はエラーを返して終了です。
# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 101
Started with run options --seed 8392
ERROR["test_login_with_valid_information_followed_by_logout", UsersLoginTest, 2.0612237000004825]
test_login_with_valid_information_followed_by_logout#UsersLoginTest (2.06s)
NoMethodError: NoMethodError: undefined method `forget' for nil:NilClass
app/helpers/sessions_helper.rb:35:in `forget'
app/helpers/sessions_helper.rb:42:in `log_out'
app/controllers/sessions_controller.rb:18:in `destroy'
test/integration/users_login_test.rb:33:in `block in <class:UsersLoginTest>'
2/2: [===================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.18225s
2 tests, 13 assertions, 0 failures, 1 errors, 0 skips
「forget
メソッドのレシーバーがnil
であることに起因するNoMethodError
エラー」というメッセージが出ていますね。実際に発生しているエラーを捕捉できています。
テストが通るようにする
Railsチュートリアル本文における解決策としては、「log_out
はlogged_in?
がtrue
の場合に限り呼び出すようにする」というものが挙げられています。
class SessionsController < ApplicationController
...略
def destroy
- log_out
+ log_out if logged_in?
redirect_to root_url
end
end
再度テストを実行してみましょう。
# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 114
Started with run options --seed 58185
2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01
Finished in 2.00099s
2 tests, 16 assertions, 0 failures, 0 errors, 0 skips
無事テストが通りました。
記憶ダイジェストを持たないユーザーに対する動作のテスト
「複数のWebブラウザプログラムからのアクセスを想定した統合テストを書く」というのは、正直かなり困難です。しかしながら、「記憶ダイジェストを持たないユーザーを想定した統合テストを書く」というのは容易です。実際には、以下の手順でテストを進めていきます。
- 記憶ダイジェストを持たないユーザーを用意する
- 当該ユーザーに対して、
authenticated?
メソッドを実行する -
authenticated?
メソッドがfalse
を返せばテスト成功
「記憶ダイジェストを持たないユーザー」というのは、UserTest#setup
メソッドで定義する@user
とは別に用意する必要があります。@user
に対しては、authenticated?
メソッドの実行結果がtrue
になるからです。
テストを書く
実際のテストコードは以下の通りです。
test "authenticated? should return false for a new user with nil digest" do
assert_not @user.authenticated?('')
end
test/models/user_test.rb
にテストを追加していきます。
require 'test_helper'
class UserTest < ActiveSupport::TestCase
...略
+
+ test "authenticated? should return false for a new user with nil digest" do
+ assert_not @user.authenticated?('')
+ end
end
authenticated?
の引数が空文字列である理由
authenticated?
の引数が空文字列になっています。その理由を説明するために、ここで改めてUserモデルにおけるauthenticated?
メソッドの実装を見てみましょう。
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
remember_digest
がダイジェストとして不正な文字列である場合、is_password?
が呼び出される以前、BCrypt::Password.new
の時点でエラーになります。今回実装するテストでは、このような場合に@user.authenticated?('')
はfalse
を返さなければならないという条件です。
BCrypt::Password.new
が値を戻し、is_password?
まで進めば、is_password?
の実装上、戻り値はtrue
かfalse
になります。ゆえに、authenticated?
の引数は空文字列でも何でも構わない、ということになります。
テストを実行する(通らない)
テストを実行してみます。結果はエラーを返して終了です。
# rails test test/models/user_test.rb
Running via Spring preloader in process 127
Started with run options --seed 13861
ERROR["test_authenticated?_should_return_false_for_a_new_user_with_nil_digest", UserTest, 0.17453030000069703]
test_authenticated?_should_return_false_for_a_new_user_with_nil_digest#UserTest (0.18s)
BCrypt::Errors::InvalidHash: BCrypt::Errors::InvalidHash: invalid hash
app/models/user.rb:31:in `new'
app/models/user.rb:31:in `authenticated?'
test/models/user_test.rb:75:in `block in <class:UserTest>'
12/12: [=================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.33746s
12 tests, 20 assertions, 0 failures, 1 errors, 0 skips
「BCrypt::Errors::InvalidHash
エラー」というメッセージが出ていますね。実際に発生しているエラーを捕捉できています。
テストが通るようにする
Railsチュートリアル本文における解決策としては、「記憶ダイジェストがnil
である場合、authenticated?
メソッドがfalse
を返すようにする」というものが挙げられています。
class User < ApplicationRecord
...略
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
+ return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
...略
end
再度テストを実行してみましょう。
# rails test test/models/user_test.rb
Running via Spring preloader in process 140
Started with run options --seed 13423
12/12: [=================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.27720s
12 tests, 21 assertions, 0 failures, 0 errors, 0 skips
無事テストが通りました。
演習 - 2つの目立たないバグ
1. リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。
class SessionsController < ApplicationController
...略
def destroy
- log_out if logged_in?
+ log_out# if logged_in?
redirect_to root_url
end
end
上記変更を保存した上で、以下の操作を行います。
- Firefoxの複数タブで、ログイン中のユーザーページを開く
- 1つのタブで「Log out」をクリックし、ログアウト操作を行う
- 別のタブで「Log out」をクリックする
user
がnil
であるのにforget
メソッドを呼び出してしまい、結果NoMethodError
が発生します。
rails server
のログ画面には、以下のスタックトレースが表示されています。
app/helpers/sessions_helper.rb:35:in `forget'
app/helpers/sessions_helper.rb:42:in `log_out'
app/controllers/sessions_controller.rb:18:in `destroy'
スクリーンショットの内容、ならびにrails server
に表示されたトレースを見ると、SessionsController#destroy
からSessionsHelper#forget
へエラーの原因となるメソッド呼び出しが波及していっていることがわかります。
test/integration/users_login_test.rb
の統合テストも通りません。
# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 166
Started with run options --seed 55314
ERROR["test_login_with_valid_information_followed_by_logout", UsersLoginTest, 2.165016299999479]
test_login_with_valid_information_followed_by_logout#UsersLoginTest (2.17s)
NoMethodError: NoMethodError: undefined method `forget' for nil:NilClass
app/helpers/sessions_helper.rb:35:in `forget'
app/helpers/sessions_helper.rb:42:in `log_out'
app/controllers/sessions_controller.rb:18:in `destroy'
test/integration/users_login_test.rb:33:in `block in <class:UsersLoginTest>'
2/2: [===================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.19785s
2 tests, 13 assertions, 0 failures, 1 errors, 0 skips
2. リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。
class User < ApplicationRecord
...略
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
- return false if remember_digest.nil?
+ # return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
...略
end
上記変更を保存した上で、以下の操作を行います。
- FirefoxとSafariで、特定のユーザーにログインする
- Firefoxで「Log out」をクリックし、ログアウト操作を行う
- Safariで、ログアウトせずに終了する
- Safariを再起動し、アプリケーションの任意のページを表示する
SafariでBCrypt::Errors::InvalidHash
エラーが発生します。
rails server
のログ画面には、以下のスタックトレースが表示されています。
app/models/user.rb:32:in `new'
app/models/user.rb:32:in `authenticated?'
app/helpers/sessions_helper.rb:21:in `current_user'
app/helpers/sessions_helper.rb:30:in `logged_in?'
スクリーンショットの内容、ならびにrails server
に表示されたトレースを見ると、BCrypt::Password.new(remember_digest)
でエラーが発生していることがわかります。
def authenticated?(remember_token)
# return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token) # エラー
end
test/models/user_test.rb
の統合テストも通りません。
# rails test test/models/user_test.rb
Running via Spring preloader in process 153
Started with run options --seed 35463
ERROR["test_authenticated?_should_return_false_for_a_new_user_with_nil_digest", UserTest, 0.3806449999992765]
test_authenticated?_should_return_false_for_a_new_user_with_nil_digest#UserTest (0.38s)
BCrypt::Errors::InvalidHash: BCrypt::Errors::InvalidHash: invalid hash
app/models/user.rb:32:in `new'
app/models/user.rb:32:in `authenticated?'
test/models/user_test.rb:75:in `block in <class:UserTest>'
12/12: [=================================] 100% Time: 00:00:00, Time: 00:00:00
Finished in 0.41116s
12 tests, 20 assertions, 0 failures, 1 errors, 0 skips
3. 上のコードでコメントアウトした部分を元に戻し、テストスイートがred
からgreen
になることを確認しましょう。
コードを以下のように変更します。
class SessionsController < ApplicationController
...略
def destroy
- log_out# if logged_in?
+ log_out if logged_in?
redirect_to root_url
end
end
class User < ApplicationRecord
...略
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
- # return false if remember_digest.nil?
+ return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
...略
end
アプリケーション全体に対するテストを実行します。
# rails test
Running via Spring preloader in process 179
Started with run options --seed 8193
25/25: [=================================] 100% Time: 00:00:02, Time: 00:00:02
Finished in 2.48940s
25 tests, 69 assertions, 0 failures, 0 errors, 0 skips
無事テストが通りました。