Ruby on Rails チュートリアル
-
第4版 目次
- 第1章ゼロからデプロイまで
- 第2章Toyアプリケーション
- 第3章ほぼ静的なページの作成
- 第4章Rails風味のRuby
- 第5章レイアウトを作成する
- 第6章ユーザーのモデルを作成する
- 第7章ユーザー登録
- 第8章基本的なログイン機構
- 第9章発展的なログイン機構
- 第10章ユーザーの更新・表示・削除
- 第11章アカウントの有効化
- 第12章パスワードの再設定
- 第13章ユーザーのマイクロポスト
- 第14章ユーザーをフォローする
|
||
第4版 目次
|
||
最新版を読む |
Ruby on Rails チュートリアル
プロダクト開発の0→1を学ぼう
下記フォームからメールアドレスを入力していただくと、招待リンクが記載されたメールが届きます。リンクをクリックし、アカウントを有効化した時点から『30分間』解説動画のお試し視聴ができます。
メール内のリンクから視聴を開始できます。
第4版 目次
- 第1章ゼロからデプロイまで
- 第2章Toyアプリケーション
- 第3章ほぼ静的なページの作成
- 第4章Rails風味のRuby
- 第5章レイアウトを作成する
- 第6章ユーザーのモデルを作成する
- 第7章ユーザー登録
- 第8章基本的なログイン機構
- 第9章発展的なログイン機構
- 第10章ユーザーの更新・表示・削除
- 第11章アカウントの有効化
- 第12章パスワードの再設定
- 第13章ユーザーのマイクロポスト
- 第14章ユーザーをフォローする
第9章発展的なログイン機構
第8章では、基本的なログイン機構を実装しました。しかし最近のウェブサービスでは、(任意で) ユーザーのログイン情報を記憶しておき、ブラウザを再起動した後でもすぐにログインできる機能 (remember me) を備えていることも一般的になってきました。そこで本章では、永続クッキー (permanent cookies) を使ってこの機能を実現していきます。具体的には、まずユーザーのログイン情報を長く記憶する方法 (例えばBitbucketやGitHubが使っている方法) について学びます。その後、[remember me] チェックボックスを使って、ユーザーの任意でログイン情報を記憶する方法について学びます (例えばTwitterやFacebookも使っていますね)。
なお、サンプルアプリケーションの基本的なログイン機構は第8章で実装できているので、実は本章をスキップして第10章に進んでしまうことも可能です (同様にして、アカウントの有効化とパスワードの再設定も実はスキップして第13章に進むことも可能です)。とはいえ、[remember me] 機能の実装方法を学ぶことは今後の開発で大いに役立ちますし、実際、続くアカウントの有効化 (第11章) やパスワードの再設定 (第12章) などの高度な機能を実装する上でも本章は欠かせません。また、ウェブ上の至るところで [remember me] を備えたログインフォームがありますが、本章はその中身 「コンピューターのマジック」を知る絶好のチャンスとも言えるでしょう。
9.1 Remember me 機能
本節では、ユーザーのログイン状態をブラウザを閉じた後でも有効にする [remember me] 機能を実装していきます。この機能を使うと、ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができるようになります。また本節の後半では、この機能を使うかどうかをユーザーに決めてもらうため、[remember me] のチェックボックスをログインフォームに追加していくことにします (9.2)。
それではいつものように、まずはトピックブランチを作成し、そこで作業を進めていきましょう。
$ git checkout -b advanced-login
9.1.1 記憶トークンと暗号化
8.2では、Railsのsession
メソッドを使ってユーザーIDを保存しましたが、この情報はブラウザを閉じると消えてしまいます。本節では、セッションの永続化の第一歩として記憶トークン (remember token) を生成し、cookies
メソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト (remember digest) によるトークン認証にこの記憶トークンを活用します。(訳注: 「トークン」とは、パスワードの平文と同じような秘密情報です。パスワードとトークンとの一般的な違いは、パスワードはユーザーが作成・管理する情報であるのに対し、トークンはコンピューターが作成・管理する情報である点です。)
8.2.1で解説したように、session
メソッドで保存した情報は自動的に安全が保たれますが、cookies
メソッドに保存する情報は残念ながらそのようにはなっていません。特に、cookiesを永続化するとセッションハイジャックという攻撃を受ける可能性があります。この攻撃は、記憶トークンを奪って、特定のユーザーになりすましてログインするというものです。cookiesを盗み出す有名な方法は4通りあります。 (1) 管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す1。 (2) データベースから記憶トークンを取り出す。 (3) クロスサイトスクリプティング (XSS) を使う。 (4) ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る。
7.5では、最初の問題を防止するためにSecure Sockets Layer (SSL) をサイト全体に適用して、ネットワークデータを暗号化で保護し、パケットスニッファから読み取られないようにしています。2番目の問題の対策としては、記憶トークンをそのままデータベースに保存するのではなく、記憶トークンのハッシュ値を保存するようにします。これは、6.3で生のパスワードをデータベースに保存する代わりに、パスワードのダイジェストを保存したのと同じ考え方に基づいています2。3番目の問題については、Railsによって自動的に対策が行われます。具体的には、ビューのテンプレートで入力した内容をすべて自動的にエスケープします。4番目のログイン中のコンピュータへの物理アクセスによる攻撃については、さすがにシステム側での根本的な防衛手段を講じることは不可能なのですが、二次被害を最小限に留めることは可能です。具体的には、ユーザーが (別端末などで) ログアウトしたときにトークンを必ず変更するようにし、セキュリティ上重要になる可能性のある情報を表示するときはデジタル署名 (digital signature) を行うようにします。
上で説明した設計やセキュリティ上の考慮事項を元に、次の方針で永続的セッションを作成することにします。
- 記憶トークンにはランダムな文字列を生成して用いる。
- ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
- トークンはハッシュ値に変換してからデータベースに保存する。
- ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
- 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。
上の最後の手順が、ユーザーログインのときの手順と似ていることにご注目ください。ユーザーログインでは、メールアドレスをキーにしてユーザーを取り出し、送信されたパスワードがパスワードダイジェストと一致することを (authenticate
メソッドで) 確認します (リスト 8.7)。つまり、ここでの実装はhas_secure_password
と似た側面を持ちます。
それでは最初に、必要となるremember_digest
属性をUserモデルに追加します (図 9.1)。
図 9.1のデータモデルをアプリケーションに追加するために、次のマイグレーションを生成します。
$ rails generate migration add_remember_digest_to_users remember_digest:string
(6.3.1のパスワードダイジェストのときのマイグレーションと比較してみましょう。) 前回のマイグレーションと同様、今回のマイグレーション名も_to_users
で終わっています。これは、マイグレーションの対象がデータベースのusers
テーブルであることをRailsに指示するためのものです。今回は種類=string
のremember_digest
属性を追加しているので、いつものようにRailsによってデフォルトのマイグレーションが作成されます (リスト 9.1)。
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
カラムにインデックスを追加する必要はありません。したがって、上のマイグレーションは変更せずにそのまま使います。
$ rails db:migrate
ここで、記憶トークンとして何を使うかを決める必要があります。有力な候補として様々なものが考えられますが、基本的には長くてランダムな文字列であればどんなものでも構いません。例えば、Ruby標準ライブラリのSecureRandom
モジュールにあるurlsafe_base64
メソッドなら、この用途にぴったり合いそうです3。このメソッドは、A–Z、a–z、0–9、"-"、"_"のいずれかの文字 (64種類) からなる長さ22のランダムな文字列を返します (64種類なのでbase64と呼ばれています)。典型的なbase64の文字列は、次のようなものです。
$ rails console
>> SecureRandom.urlsafe_base64
=> "q5lt38hQDc_959PVoo6b7A"
同一のパスワードを持つユーザーが複数いても問題ないのと同様に4、同一の記憶トークンを持つユーザーが複数いても問題ありません。一方で、セッションハイジャックのリスクなどを考えると、トークンは一意である方がより安全ではあります5。また、先ほどのbase64の文字列では、64種類の文字からなる長さ22の文字列なので、2つの記憶トークンがたまたま完全に一致する (=衝突する) 確率は「\( 1/64^{22} = 2^{-132} \approx 10^{-40} \)」となるので、トークンが衝突することはまずありません6。さらにありがたいことに、base64はURLを安全にエスケープするためにも用いられる (urlsafe_base64
という名前のメソッドがあることからもわかります) ので、base64を採用すれば、第10章でアカウントの有効化のリンクやパスワードリセットのリンクでも同じトークンジェネレータを使えるようになります。
ユーザーを記憶するには、記憶トークンを作成して、そのトークンをダイジェストに変換したものをデータベースに保存します。fixtureをテストするときにdigest
メソッドを既に作成してあったので (リスト 8.21)、上の結論に従って、新しいトークンを作成するためのnew_token
メソッドを作成できます。この新しいdigest
メソッドではユーザーオブジェクトが不要なので、このメソッドもUserモデルのクラスメソッドとして作成することにします7。以上を反映したUserモデルをリスト 9.2に示します。
app/models/user.rb
class User < ApplicationRecord
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }
# 渡された文字列のハッシュ値を返す
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
end
さしあたっての実装計画としては、user.remember
メソッドを作成することにします。このメソッドは記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをデータベースに保存します。リスト 9.1のマイグレーションは実行済みなので、Userモデルには既にremember_digest
属性が追加されていますが、remember_token
属性はまだ追加されていません。このためuser.remember_token
メソッドを使ってトークンにアクセスできるようにし、かつ、トークンをデータベースに保存せずに実装する必要があります。そこで、6.3で行ったパスワードの実装と同様の手法でこれを解決します。あのときは仮想のpassword
属性と、データベース上にあるセキュアなpassword_digest
属性の2つを使いました。仮想のpassword
属性はhas_secure_password
メソッドで自動的に作成されましたが、今回はremember_token
のコードを自分で書く必要があります。これを実装するため、4.4.5で触れたattr_accessor
を使って「仮想の」属性を作成します。
class User < ApplicationRecord
attr_accessor :remember_token
.
.
.
def remember
self.remember_token = ...
update_attribute(:remember_digest, ...)
end
end
remember
メソッドの1行目の代入にご注目ください。self
というキーワードを使わないと、Rubyによってremember_token
という名前のローカル変数が作成されてしまいます。この動作は、Rubyにおけるオブジェクト内部への要素代入の仕様によるものです。今欲しいのはローカル変数ではありません。self
キーワードを与えると、この代入によってユーザーのremember_token
属性が期待どおりに設定されます (リスト 6.32の他のbefore_save
コールバックで、email
ではなくself.email
と記述していた理由が、これでおわかりいただけたと思います)。remember
メソッドの2行目では、update_attribute
メソッドを使って記憶ダイジェストを更新しています。6.1.5で説明したように、このメソッドはバリデーションを素通りさせます。今回はユーザーのパスワードやパスワード確認にアクセスできないので、バリデーションを素通りさせなければなりません。
以上の点を考慮して、有効なトークンとそれに関連するダイジェストを作成できるようにします。具体的には、最初にUser.new_token
で記憶トークンを作成し、続いてUser.digest
を適用した結果で記憶ダイジェストを更新します。remember
メソッドの更新結果をリスト 9.3に示します。
remember
メソッドをUserモデルに追加する green app/models/user.rb
class User < ApplicationRecord
attr_accessor :remember_token
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }
# 渡された文字列のハッシュ値を返す
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
# 永続セッションのためにユーザーをデータベースに記憶する
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest, User.digest(remember_token))
end
end
演習
- コンソールを開き、データベースにある最初のユーザーを変数
user
に代入してください。その後、そのuser
オブジェクトからremember
メソッドがうまく動くかどうか確認してみましょう。また、remember_token
とremember_digest
の違いも確認してみてください。 - リスト 9.3では、明示的に
User
クラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、ややわかりにくいリスト 9.4の実装でも、非常に混乱しやすいリスト 9.5の実装でも、いずれも正しく動くことを確認してみてください。ヒント:self
は、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、self
はUser
「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。
self
を使ってdigest
とnew_token
メソッドを定義する green app/models/user.rb
class User < ApplicationRecord
.
.
.
# 渡された文字列のハッシュ値を返す
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
.
.
.
end
class << self
を使ってdigest
とnew_token
メソッドを定義する green app/models/user.rb
class User < ApplicationRecord
.
.
.
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
.
.
.
9.1.2 ログイン状態の保持
user.remember
メソッドが動作するようになったので、ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存して、永続セッションを作成する準備ができました。これを実際に行うにはcookies
メソッドを使います。このメソッドは、session
のときと同様にハッシュとして扱えます。個別のcookiesは、1つのvalue
(値) と、オプションのexpires
(有効期限) からできています。有効期限は省略可能です。例えば次のように、20年後に期限切れになる記憶トークンと同じ値をcookieに保存することで、永続的なセッションを作ることができます。(訳注: さらに詳しい仕組みを知りたい方は @coe401_ さんが書いた記事「たのしいOSSコードリーディング: Let’s read "cookies" (スライド版)」を読んでみてください。)
cookies[:remember_token] = { value: remember_token,
expires: 20.years.from_now.utc }
(上のコードではRailsの便利なtimeヘルパーも使っています。詳細はコラム 9.1を参照してください。) このような20年で期限切れになるcookies設定はよく使われていて、今ではRailsにもpermanent
という専用メソッドが追加されたほどです。このメソッドを使うと、コードは次のようにシンプルになります。
cookies.permanent[:remember_token] = remember_token
上のコードによって、Railsは期限を20.years.from_now
に設定します。
20.years.from_now
)Rubyは組み込みクラスを含むあらゆるクラスにメソッドを追加できることを4.4.2で学びました。あのときは、palindrome?
メソッドをString
クラスに追加しました (ついでに"deified"
も回文になっていることを発見しました)。また、Railsが実はblank?
メソッドをObject
クラスに追加していることも判明しました (これにより、"".blank?
、" ".blank?
、nil.blank?
はいずれもtrue
になります)。このcookies.permanent
メソッドでは、cookiesが20年後に期限切れになる (20.years.from_now
) ように指定していますが、これはRailsのtimeヘルパーを使った格好の例題になります。timeヘルパーはRailsによって、数値関連の基底クラスであるInteger
クラスに追加されます。
$ rails console >> 1.year.from_now => Wed, 21 Jun 2017 19:36:29 UTC +00:00 >> 10.weeks.ago => Tue, 12 Apr 2016 19:36:44 UTC +00:00
Railsは次のようなヘルパーも追加しています。
>> 1.kilobyte => 1024 >> 5.megabytes => 5242880
上のヘルパーは、ファイルのアップロードに5.megabytes
などの制限を与えるのに便利です。
メソッドを組み込みクラスに追加できる柔軟性の高さのおかげで、純粋なRubyを極めて自然に拡張することができます (もちろん注意して使う必要はありますが)。実際、Railsのエレガントな仕様の多くは、背後にあるRubyの高い拡張性によって実現されているのです。
ユーザーIDをcookiesに保存するには、session
メソッドで使ったのと同じパターン (リスト 8.14) を使います。具体的には次のようになります。
cookies[:user_id] = user.id
しかしこのままではIDが生のテキストとしてcookiesに保存されてしまうので、アプリケーションのcookiesの形式が見え見えになってしまい、攻撃者がユーザーアカウントを奪い取ることを助けてしまう可能性があります。これを避けるために、署名付きcookieを使います。これは、cookieをブラウザに保存する前に安全に暗号化するためのものです8。
cookies.signed[:user_id] = user.id
ユーザーIDと記憶トークンはペアで扱う必要があるので、cookieも永続化しなくてはなりません。そこで、次のようにsigned
とpermanent
をメソッドチェーンで繋いで使います。
cookies.permanent.signed[:user_id] = user.id
cookiesを設定すると、以後のページのビューでこのようにcookiesからユーザーを取り出せるようになります。
User.find_by(id: cookies.signed[:user_id])
cookies.signed[:user_id]
では自動的にユーザーIDのcookiesの暗号が解除され、元に戻ります。続いて、bcryptを使ってcookies[:remember_token]
がremember_digest
と一致することを確認します (リスト 9.3)。ところで、署名されたユーザーIDがあれば記憶トークンは不要なのではないかと疑問に思う方もいるかもしれません。しかし記憶トークンがなければ、暗号化されたIDを奪った攻撃者は、暗号化IDをそのまま使ってお構いなしにログインしてしまうでしょう。現在の設計では、攻撃者が仮に両方のcookiesを奪い取ることに成功したとしても、本物のユーザーがログアウトするとログインできないようになっています。
パズルもいよいよ最後のピースを残すだけとなりました。渡されたトークンがユーザーの記憶ダイジェストと一致することを確認します。この一致をbcryptで確認するための様々な方法があります。secure_passwordのソースコードを調べてみると、次のような比較を行っている箇所があります9。
BCrypt::Password.new(password_digest) == unencrypted_password
今回の場合、上のコードを参考に下のようなコードを使います。
BCrypt::Password.new(remember_digest) == remember_token
このコードをじっくり調べてみると、実に奇妙なつくりになっています。bcryptで暗号化されたパスワードを、トークンと直接比較しています。ということは、==で比較する際にダイジェストを復号化しているのでしょうか。しかし、bcryptのハッシュは復号化できないはずなので、復号化しているはずはありません。そこでbcrypt gemのソースコードを詳しく調べてみると、なんと、比較に使っている==
演算子が再定義されています。実際の比較をコードで表すと、次のようになっています。
BCrypt::Password.new(remember_digest).is_password?(remember_token)
実際の比較では、==
の代わりにis_password?
という論理値メソッドが使われています。これで少し見えてきました。今から書くアプリケーションコードでもこれと同じ方法を使うことにしましょう。
以上の説明を元に、記憶トークンと記憶ダイジェストを比較するauthenticated?
メソッドを、Userモデルの中に置けばよいのではないかと推測できます。このメソッドは、has_secure_password
で提供されるユーザー認証用のauthenticate
メソッドと似ています (リスト 8.15)。この実装結果をリスト 9.6に示します。ところで、このauthenticated?
メソッド (リスト 9.6) は記憶ダイジェストと強く結びついていますが、実は他の様々な用途にも応用できます。第10章ではこのメソッドを一般化してみます。
authenticated?
をUserモデルに追加する app/models/user.rb
class User < ApplicationRecord
attr_accessor :remember_token
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }
# 渡された文字列のハッシュ値を返す
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
# 永続セッションのためにユーザーをデータベースに記憶する
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
end
authenticated?
メソッドのローカル変数として定義したremember_token
は、リスト 9.3のattr_accessor :remember_token
で定義したアクセサとは異なる点に注意してください (リスト 9.6)。今回の場合、is_password?
の引数はメソッド内のローカル変数を参照しています。もう1つ、remember_digest
の属性の使い方にもご注目ください。この使い方はself.remember_digest
と同じであり、すなわち第6章のname
やemail
の使い方と同じになります。実際、remember_digest
の属性はデータベースのカラムに対応しているため、Active Recordによって簡単に取得したり保存したりできます (リスト 9.1)。
これで、ログインしたユーザーを記憶する処理の準備が整いました。remember
ヘルパーメソッドを追加して、log_in
と連携させてみましょう (リスト 9.7)。
app/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
log_in
のときと同様に、リスト 9.7では実際のSessionsヘルパーの動作は、remember
メソッド定義のuser.remember
を呼び出すまで遅延され、そこで記憶トークンを生成してトークンのダイジェストをデータベースに保存します。続いて上と同様に、cookies
メソッドでユーザーIDと記憶トークンの永続cookiesを作成します。作成したコードをリスト 9.8に示します。
app/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
# 現在ログインしているユーザーを返す (いる場合)
def current_user
if session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
end
# ユーザーがログインしていればtrue、その他ならfalseを返す
def logged_in?
!current_user.nil?
end
# 現在のユーザーをログアウトする
def log_out
session.delete(:user_id)
@current_user = nil
end
end
リスト 9.8のコードでは、ログインするユーザーはブラウザで有効な記憶トークンを得られるように記憶されますが、リスト 8.16で定義したcurrent_user
メソッドでは一時セッションしか扱っていないので、このままでは正常に動作しません。
@current_user ||= User.find_by(id: session[:user_id])
永続セッションの場合は、session[:user_id]
が存在すれば一時セッションからユーザーを取り出し、それ以外の場合はcookies[:user_id]
からユーザーを取り出して、対応する永続セッションにログインする必要があります。これを行うには、次のように記述します。
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
上のコードでもuser && user.authenticated
(リスト 8.7) を使っている点にご注目ください。このコードでも動作しますが、今のままではsession
メソッドもcookies
メソッドもそれぞれ2回ずつ使われてしまい、無駄です。これを解消するためには、次のようにローカル変数を使います。
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
上のコードでは次のような構造が使われていますが、少し紛らわしい点があります。
if (user_id = session[:user_id])
一見、上のコードは比較を行っているように見えますが、これは比較ではありません。比較であれば==
を使うはずですが、ここでは代入を行っています。このコードを言葉で表すと、「ユーザーIDがユーザーIDのセッションと等しければ...」ではなく、「(ユーザーIDにユーザーIDのセッションを代入した結果) ユーザーIDのセッションが存在すれば」となります10。
前述のようにcurrent_user
ヘルパーを定義すると、リスト 9.9のようになります。
current_user
を更新する red app/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
end
リスト 9.9のコードでは、新しくログインしたユーザーは正しく記憶されます。実際にログインしてからブラウザを閉じ、アプリケーションを再起動してからもう一度ブラウザでアプリケーションを開いてみると、期待どおり動作していることを確認できます。11その気になれば、ブラウザのcookiesをブラウザで直接調べて結果を確認することもできます (図 9.2)。12
アプリケーションに現在残された問題はあと1つだけです。ブラウザのcookiesを削除する手段が未実装なので (20年待てば消えますが)、ユーザーがログアウトできません。これは当然テストスイートでキャッチすべき問題であり、redにならなければなりません。
$ rails test
9.1.3 ユーザーを忘れる
ユーザーがログアウトできるようにするために、ユーザーを記憶するためのメソッドと同様の方法で、ユーザーを忘れるためのメソッドを定義します。このuser.forget
メソッドによって、user.remember
が取り消されます。具体的には、記憶ダイジェストをnil
で更新します (リスト 9.11)。
forget
メソッドをUserモデルに追加する red app/models/user.rb
class User < ApplicationRecord
attr_accessor :remember_token
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
validates :password, presence: true, length: { minimum: 6 }
# 渡された文字列のハッシュ値を返す
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
# 永続セッションのためにユーザーをデータベースに記憶する
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
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
end
リスト 9.11のコードを使うと、永続セッションを終了できるようになる準備が整います。終了するには、forget
ヘルパーメソッドを追加してlog_out
ヘルパーメソッドから呼び出します (リスト 9.12)。リスト 9.12をよく見てみると、forget
ヘルパーメソッドではuser.forget
を呼んでからuser_id
とremember_token
のcookiesを削除していることがわかります。
app/helpers/sessions_helper.rb
module SessionsHelper
# 渡されたユーザーでログインする
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
end
この時点で、すべてのテストスイートが greenになることを確認してみましょう。
$ rails test
9.1.4 2つの目立たないバグ
実は小さなバグが2つ残っていて、この2つのバグは互いに強く関連しています。1つ目のバグは次の通りです。ユーザーは場合によっては、同じサイトを複数のタブ (あるいはウィンドウ) で開いていることもあります。ログアウト用リンクはログイン中のみ表示されますが、今のcurrent_user
の使い方では、ユーザーが1つのタブでログアウトし、もう1つのタブで再度ログアウトしようとするとエラーになってしまいます。これは、もう1つのタブで "Log out" リンクをクリックすると、current_user
がnil
となってしまうため、log_out
メソッド内のforget(current_user)
が失敗してしまうからです (リスト 9.12)13。この問題を回避するためには、ユーザーがログイン中の場合にのみログアウトさせる必要があります。
2番目の地味な問題は、ユーザーが複数のブラウザ (FirefoxやChromeなど) でログインしていたときに起こります。例えば、Firefoxでログアウトし、Chromeではログアウトせずにブラウザを終了させ、再度Chromeで同じページを開くと、この問題が発生します14。FirefoxとChromeを使った具体例で考えてみましょう。ユーザーがFirefoxからログアウトすると、user.forget
メソッドによってremember_digest
がnil
になります (リスト 9.11)。この時点では、Firefoxでまだアプリケーションが正常に動作しているはずです。このとき、リスト 9.12ではlog_out
メソッドによってユーザーIDが削除されるため、ハイライトされている2つの条件はfalse
になります。
# 記憶トークン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
結果として、current_user
メソッドの最終的な評価結果は、期待どおりnil
になります。
一方、Chromeを閉じたとき、session[:user_id]
はnil
になります (これはブラウザが閉じたときに、全てのセッション変数の有効期限が切れるためです)。しかし、cookies
はブラウザの中に残り続けているため、Chromeを再起動してサンプルアプリケーションにアクセスすると、データベースからそのユーザーを見つけることができてしまいます。
# 記憶トークン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
文の条件式が評価されます。
user && user.authenticated?(cookies[:remember_token])
このとき、user
がnil
であれば1番目の条件式で評価は終了するのですが、実際にはnilではないので2番目の条件式まで評価が進み、そのときにエラーが発生します。原因は、Firefoxでログアウトしたときに (リスト 9.11) ユーザーのremember_digest
が削除されているにもかかわらず、Chromeでアプリケーションにアクセスしたときに次の文を実行してしまうからです。
BCrypt::Password.new(remember_digest).is_password?(remember_token)
すなわち上のremember_digest
がnil
になるので、bcryptライブラリ内部で例外が発生します。この問題を解決するには、remember_digest
が存在しないときはfalse
を返す処理をauthenticated?
に追加する必要があります。
テスト駆動開発は、この種の地味なバグ修正にはうってつけです。そこで、2つのエラーをキャッチするテストから書いていくことにしましょう。まずはリスト 8.31の統合テストを元に、redになるテストを作成してみます (リスト 9.14)。
test/integration/users_login_test.rb
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 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
# 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
リスト 9.14では、current_user
がないために2回目のdelete logout_path
の呼び出しでエラーが発生し、テストスイートは redになります。
$ rails test
次にこのテストを成功させます。具体的にはリスト 9.16のコードで、logged_in?
がtrueの場合に限ってlog_out
を呼び出すように変更します。
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
.
.
.
def destroy
log_out if logged_in?
redirect_to root_url
end
end
2番目の問題についてですが、統合テストで2種類のブラウザをシミュレートするのは正直かなり困難です。その代わり、同じ問題をUserモデルで直接テストするだけなら簡単に行えます。記憶ダイジェストを持たないユーザーを用意し (setup
メソッドで定義した@user
インスタンス変数ではtrueになります)、続いてauthenticated?
を呼び出します (リスト 9.17)。この中で、記憶トークンを空欄のままにしていることにご注目ください。記憶トークンが使われる前にエラーが発生するので、記憶トークンの値は何でも構わないのです。
authenticated?
のテスト red test/models/user_test.rb
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com",
password: "foobar", password_confirmation: "foobar")
end
.
.
.
test "authenticated? should return false for a user with nil digest" do
assert_not @user.authenticated?('')
end
end
上のコードではBCrypt::Password.new(nil)
でエラーが発生するため、テストスイートは redになります。
$ rails test
このテストを greenにするためには、記憶ダイジェストがnil
の場合にfalse
を返すようにすれば良さそうです (リスト 9.19)。
authenticated?
を更新して、ダイジェストが存在しない場合に対応 green app/models/user.rb
class User < ApplicationRecord
.
.
.
# 渡されたトークンがダイジェストと一致したら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
キーワードで即座にメソッドを終了しています。処理を中途で終了する場合によく使われるテクニックです。次のコードでもよいのですが、
if remember_digest.nil?
false
else
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
筆者はリスト 9.19のように明示的にreturnする方が、コードが若干短くなることもあって好みです。
リスト 9.19のコードを使うと、テストスイート全体が greenになり、サブタイトルは両方とも修正されるはずです。
$ rails test
9.2 [Remember me] チェックボックス
9.1.3のコードで、アプリケーションにプロ仕様の完全な認証システムが導入されました。本章の最後に、[remember me] チェックボックスでログインを保持する方法を解説します。チェックボックスを追加したモックアップを図 9.3に示します。
今回の実装は、リスト 8.4のログインフォームにチェックボックスを追加するところから始めます。チェックボックスは、他のラベル、テキストフィールド、パスワードフィールド、送信ボタンと同様にヘルパーメソッドで作成できます。ただし、チェックボックスが正常に動作するためには、次のようにラベルの内側に配置する必要があります。
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
上をログインフォームに反映したコードをリスト 9.21に示します。
app/views/sessions/new.html.erb
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(:session, url: login_path) do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
<%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
リスト 9.21では、2つのCSSクラスcheckbox
とinline
を使っています。Bootstrapではこれらをチェックボックスとテキスト「Remember me on this computer」として同じ行に配置します。スタイルを整えるため、もう少しCSSルールを追加します (リスト 9.22)。表示されるログインフォームを図 9.4に示します。
app/assets/stylesheets/custom.scss
.
.
.
/* forms */
.
.
.
.checkbox {
margin-top: -10px;
margin-bottom: 10px;
span {
margin-left: 20px;
font-weight: normal;
}
}
#session_remember_me {
width: auto;
margin-left: 0;
}
ログインフォームの編集が終わったので、チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにします。信じられないかもしれませんが、必要な準備はすべて終わっているので、実装はわずか1行で終わります。ログインフォームから送信されたparams
ハッシュには既にチェックボックスの値が含まれています。リスト 9.21のフォームに無効な値を入力して実際に送信すれば、ページのデバッグ情報で値を確認することもできます。特に、次の値は、
params[:session][:remember_me]
チェックボックスがオンのときに'1'
になり、オフのときに'0'
になります。
params
ハッシュのこの値を調べれば、送信された値に基いてユーザーを記憶したり忘れたりできるようになります15。
if params[:session][:remember_me] == '1'
remember(user)
else
forget(user)
end
詳細はコラム 9.2で説明しますが、次のような「三項演算子 (ternary operator)」を使うと、このようなif
-then
の分岐構造を1行で表すことができます16。
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
このコードを使うことで、Sessionsコントローラのcreate
アクション内にあったremember user
(リスト 9.7) が驚くほどコンパクトなコードになりました (リスト 9.23)。読者の皆様は、cost
変数の定義で使った三項演算子 (リスト 8.21) のコードも既に理解できるようになったことでしょう。
app/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
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
redirect_to user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
log_out if logged_in?
redirect_to root_url
end
end
リスト 9.23の実装によって、ログインシステムの実装がついに完了しました。ブラウザでこのチェックボックスを実際にオンにしたりオフにしたりして、動作を確認してみましょう。
「この世には10種類の人間がいる。2進法を理解できる奴と、2進法を理解できない奴だ」は、この業界に古くから伝わるジョークです。ここで言う「10種類」とは、もちろん10進法ではなく2進法のことです。つまり、2進法で「10」を解釈できれば、「2種類」という意味だと理解できます。上のジョークに倣えば、次のようなジョークもできそうです。「この世には10種類の人々がいる。三項演算子が好きな人、嫌いな人、三項演算子を知らない人」。ちなみにもし3番目に該当したとしても、このコラムを読めばそのカテゴリの人ではなくなりますので、ご心配なく。
プログラミング経験を重ねるうちに、論理値に応じて分岐する、次のような制御フローが頻繁に出現することに気付くと思います。
if boolean? 何かをする else 別のことをする end
Rubyや他の言語 (C/C++、Perl、PHP、Javaなど) では、上のようなフローをよりコンパクトな三項演算子 (ternary operator) と呼ばれる表現で置き換えることができます (3つの部分から構成されるためそのように呼ばれます)。
論理値? ? 何かをする : 別のことをする
例えば、次のような代入文を三項演算子で置き換えることもできます。
if boolean? var = foo else var = bar end
三項演算子を使うと、上のコードはこのようになります。
var = boolean? ? foo : bar
最後に、三項演算子をメソッドの戻り値として使うこともよくあります。
def foo do_stuff boolean? ? "bar" : "baz" end
Rubyでは暗黙的にメソッドの最後に評価した式の結果を返すので、上のfoo
メソッドは、boolean?
がtrue
であるかfalse
であるかに応じて、"bar"
または"baz"
をそれぞれ返します。
9.3 [Remember me] のテスト
[remember me] 機能は既に快調に動作していますが、ここで終わらせずにテストをちゃんと書き、動作をテストで確認できるようにしておくことが重要です。テストを書く理由の1つは、今行った実装のエラーをキャッチできるようにすることです。しかしもっと重要な理由は、ユーザーを永続化するコードの中心部分が、実はまだまったくテストされていないからです。これらの課題を達成するには、もう少し新しいテストのテクニックを覚える必要がありますが、それによりテストスイートが一段と強力になります。
9.3.1 [Remember me] ボックスをテストする
恥を忍んで申し上げると、筆者が自分自身でリスト 9.23でチェックボックスの処理を実装したときは、
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
最初は上のコードではなく、次のコードを使っていました。
params[:session][:remember_me] ? remember(user) : forget(user)
この流れでは、params[:session][:remember_me]
の値は’0’
または’1’
のいずれかになりますが、そこに罠がありました。0も1もRubyの論理値ではtrue
であることを思い出してください。したがって、値は常にtrueになってしまい、チェックボックスは常にオンになっているのと同じ動作になってしまいました。この種のミスはまさに、テストでキャッチすべきエラーです。
ユーザーが記憶されるにはログインが必要です。そこで、テスト内でユーザーがログインできるようにするためのヘルパーメソッドを定義することから始めます。リスト 8.23では、post
メソッドと有効なsession
ハッシュを使ってログインしましたが、毎回このようなことをするのは面倒です。そこで、log_in_as
というヘルパーメソッドを作成してテスト用の特別なログインができるようにし、無駄な繰り返しを排除します。
ログイン済みのユーザーをテストする方法はいくつかありますが、今回はコントローラの単体テストを使っていきましょう。具体的には、session
メソッドを直接操作して、:user_id
キーにuser.id
の値を代入してみます (これはリスト 8.14の方法と同じです)。
def log_in_as(user)
session[:user_id] = user.id
end
今回は既存のlog_in
メソッド (リスト 8.14) との混乱を防ぐため、あえてメソッド名をlog_in_as
としました。このテスト用のメソッドを、test_helper
ファイルのActiveSupport::TestCase
クラス内で定義してみます (リスト 8.26のis_logged_in?
ヘルパーと同じ場所です)。
class ActiveSupport::TestCase
fixtures :all
# テストユーザーがログイン中の場合にtrueを返す
def is_logged_in?
!session[:user_id].nil?
end
# テストユーザーとしてログインする
def log_in_as(user)
session[:user_id] = user.id
end
end
(実は本章ではこのヘルパーがなくても何とかなるのですが、説明を入れるタイミングとして適切で、また、第10章で実際に必要になるので今のタイミングで追加しています。)
次に統合テストでも同様のヘルパーを実装していきます。ただし統合テストではsession
を直接取り扱うことができないので、代わりにSessionsリソースに対してpost
を送信します (リスト 8.23)。メソッド名は単体テストと同じ、log_in_as
メソッドとします。
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
メソッド名は同じlog_in_as
ですが、今回は統合テストで扱うヘルパーなのでActionDispatch::IntegrationTest
クラスの中で定義します。これにより、私たち開発者は単体テストか統合テストかを意識せずに、ログイン済みの状態をテストしたいときはlog_in_as
メソッドをただ呼び出せば良い、ということになります (訳注: これもmatzの説明するダックタイピングの一種と言えそうです)。
最後に、何かあったときにすぐに見つけられるよう、この2つのlog_in_as
ヘルパーを同じ場所にまとめておきましょう (リスト 9.24)。
log_in_as
ヘルパーを追加する test/test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
fixtures :all
# テストユーザーがログイン中の場合にtrueを返す
def is_logged_in?
!session[:user_id].nil?
end
# テストユーザーとしてログインする
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
(テストコードがより便利になるように、log_in_as
メソッド (リスト 9.24) ではキーワード引数 (リスト 7.13) のパスワードと [remember me] チェックボックスのデフォルト値を、それぞれ'password'
と'1'
に設定しています。)
最後に、[remember me] チェックボックスの動作を確認するため、2つのテストを作成します。チェックボックスがオンになっている場合とオフになっている場合のテストです。リスト 9.24でログインヘルパーメソッドを定義しておいたので、このテストは簡単に書くことができます。例えばオンに対するテストは次のようになり
log_in_as(@user, remember_me: '1')
オフの場合はこのようになります。
log_in_as(@user, remember_me: '0')
(上のコードの'1'
はremember_me
のデフォルト値なので、1つ目のテストでは省略してもよいのですが、2つのコードを見比べやすいようにあえて省略しませんでした。)
ログインに成功すれば、cookies
内部のremember_token
キーを調べることで、ユーザーが保存されたかどうかをチェックできるようになります。cookiesの値がユーザーの記憶トークンと一致することを確認できれば理想的なのですが、現在の設計ではテストでこの確認を行うことはできません。コントローラ内のuser
変数には記憶トークンの属性が含まれていますが、remember_token
は実在しない「仮想」のものなので、@user
インスタンス変数の方には含まれていません。この課題は大して難しくないので、9.3.1.1の演習に回すことにします。さしあたって、今は関連するcookiesがnil
であるかどうかだけをチェックすればよいことにします。
実はもう1つ地味な問題があります (筆者はこれで何時間も躓いてしまいました...)。テスト内ではcookies
メソッドにシンボルを使えない、という問題です。そのため、
cookies[:remember_token]
上のコードは常にnil
になってしまいます。ありがたいことに、文字列をキーにすればcookies
でも使えるようになるので、
cookies['remember_token']
上のように書けば期待どおりに値が返されます。以上の結果を反映したテストコードをリスト 9.25に示します (リスト 8.23でusers(:michael)
と書くと、リスト 8.22のfixtureユーザーを参照していたことを思い出しましょう)。
test/integration/users_login_test.rb
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
.
.
.
test "login with remembering" do
log_in_as(@user, remember_me: '1')
assert_not_empty cookies['remember_token']
end
test "login without remembering" do
# クッキーを保存してログイン
log_in_as(@user, remember_me: '1')
delete logout_path
# クッキーを削除してログイン
log_in_as(@user, remember_me: '0')
assert_empty cookies['remember_token']
end
end
皆さんが筆者と同じ間違いをしていなければ、このテストは greenになっているはずでしょう。
$ rails test
演習
- リスト 9.25の統合テストでは、仮想の
remember_token
属性にアクセスできないと説明しましたが、実は、assigns
という特殊なテストメソッドを使うとアクセスできるようになります。コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassigns
メソッドを使います。このメソッドにはインスタンス変数に対応するシンボルを渡します。例えばcreate
アクションで@user
というインスタンス変数が定義されていれば、テスト内部ではassigns(:user)
と書くことでインスタンス変数にアクセスできます。本チュートリアルのアプリケーションの場合、Sessionsコントローラのcreate
アクションでは、user
を (インスタンス変数ではない) 通常のローカル変数として定義しましたが、これをインスタンス変数に変えてしまえば、cookies
にユーザーの記憶トークンが正しく含まれているかどうかをテストできるようになります。このアイデアに従ってリスト 9.27とリスト 9.28の不足分を埋め (ヒントとして?
や(コードを書き込む)
を目印に置いてあります)、[remember me] チェックボックスのテストを改良してみてください。17
create
アクション内でインスタンス変数を使うためのテンプレート app/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
params[:session][:remember_me] == '1' ? remember(?user) : forget(?user)
redirect_to ?user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
log_out if logged_in?
redirect_to root_url
end
end
test/integration/users_login_test.rb
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
.
.
.
test "login with remembering" do
log_in_as(@user, remember_me: '1')
assert_equal (コードを書き込む), assigns(:user).(コードを書き込む)
end
test "login without remembering" do
# クッキーを保存してログイン
log_in_as(@user, remember_me: '1')
delete logout_path
# クッキーを削除してログイン
log_in_as(@user, remember_me: '0')
assert_empty cookies['remember_token']
end
.
.
.
end
9.3.2 [Remember me] をテストする
9.1.2では、それまでの節で実装した永続的セッションが動作するかどうかを手動で確認していました。しかし実は、current_user
内のある複雑な分岐処理については、これまでまったくテストが行われていないのです。筆者はこのようなとき、テストを忘れている疑いのあるコードブロック内にわざと例外発生を仕込むというテクニックを使います。つまり、そのコードブロックがテストから漏れていれば、テストはパスしてしまうはずです。コードブロックがテストから漏れていなければ、例外が発生してテストが中断するはずです。現在のコードでこれを行ってみた結果をリスト 9.29に示します。
app/helpers/sessions_helper.rb
module SessionsHelper
.
.
.
# 記憶トークン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])
raise # テストがパスすれば、この部分がテストされていないことがわかる
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token])
log_in user
@current_user = user
end
end
end
.
.
.
end
この段階でテストを実行してみると、 greenになります。
$ rails test
リスト 9.29のコードが正常でないことがわかった以上、これはもちろん問題です。さらに申し上げると、この種の永続的セッションを手動で確認するのは非常に面倒なので、current_user
をリファクタリングするのであれば同時にテストも作成しておくことが重要です (これは第11章でやりましょう)。
また、リスト 9.24で定義したlog_in_as
ヘルパーメソッドでは、session[:user_id]
と定義してしまっています。このままでは、current_user
メソッドが抱えている複雑な分岐処理を統合テストでチェックすることが非常に困難です。ただありがたいことに、Sessionsヘルパーのテストでcurrent_user
を直接テストすれば、この制約を突破することができます。
$ touch test/helpers/sessions_helper_test.rb
テスト手順はシンプルです。
- fixtureで
user
変数を定義する - 渡されたユーザーを
remember
メソッドで記憶する current_user
が、渡されたユーザーと同じであることを確認します
上の手順ではremember
メソッドではsession[:user_id]
が設定されないので、これで問題となっている複雑な分岐処理もテストできるようになります。作成したコードをリスト 9.31に示します。
test/helpers/sessions_helper_test.rb
require 'test_helper'
class SessionsHelperTest < ActionView::TestCase
def setup
@user = users(:michael)
remember(@user)
end
test "current_user returns right user when session is nil" do
assert_equal @user, current_user
assert is_logged_in?
end
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
このとき、テストをもう1つ追加している点にご注目ください。このテストでは、ユーザーの記憶ダイジェストが記憶トークンと正しく対応していない場合に現在のユーザーがnil
になるかどうかをチェックしています。これによって、次のネストしたif
文内のauthenticated?
の式をテストします。
if user && user.authenticated?(cookies[:remember_token])
ところで、リスト 9.31では次のように書いてもよいように思えるかもしれません。
assert_equal current_user, @user
実際、上のように書いても動作します。ただ5.3.4.1で簡単に触れたように、assert_equal
の引数は「期待する値, 実際の値」の順序で書く点に注意してください。
assert_equal <expected>, <actual>
この原則に従うと、リスト 9.31のコードは次のようになります。
assert_equal @user, current_user
結果、リスト 9.31のテストが (期待されていたとおり) red になります。
$ rails test test/helpers/sessions_helper_test.rb
ここまでできれば、current_user
メソッドに仕込んだraise
を削除して元に戻す (リスト 9.33) ことで、リスト 9.31のテストがパスするはずです。
app/helpers/sessions_helper.rb
module SessionsHelper
.
.
.
# 記憶トークン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
.
.
.
end
これでテストスイートは greenになるはずです。
$ rails test
current_user
の複雑な分岐処理をテストできたので、今後は手動で1つ1つ確認しなくても、自信を持って回帰バグをキャッチできます。
9.4 最後に
前々章から第9章までの間に、ユーザー登録からログイン機構に至るまで、広大な知識について幅広く学んできました。基本的な認証機構に必要な残りの作業は、ログイン状態やログイン済みのユーザーIDに基いて、ページへのアクセス権限を制限する実装 (認可モデル) だけです。次の章では、ログイン済みのユーザーであれば自分のプロフィール情報を編集できるようにしていきましょう。
さて、次の章に進む前に、本章の変更をmasterブランチにマージしておきましょう。
$ rails test
$ git add -A
$ git commit -m "Implement advanced login"
$ git checkout master
$ git merge advanced-login
$ git push
Herokuにデプロイしても、Heroku上でマイグレーションを実行するまでの間は一時的にアクセスできない状態 (エラーページ) になるので、ご注意ください。トラフィックの多い本番サイトでは、このような変更を行う前にメンテナンスモードをオンにしておくことが一般的です。
$ heroku maintenance:on
$ git push heroku
$ heroku run rails db:migrate
$ heroku maintenance:off
上のような操作でデプロイとマイグレーションを行うと、一時的にアクセスできない状態の間に標準のメンテナンスページを表示することができます (本チュートリアルで必要になることはありませんが、一度はエラーページを目にしておくのもよいでしょう)。詳しくは、Herokuのエラーに関するページ (英語) にあるドキュメントを参照してください。
9.4.1 本章のまとめ
- Railsでは、あるページから別のページに移動するときに状態を保持することができる。ページの状態を長期間保持したいときは、
cookies
メソッドを使って永続的なセッションにする - 記憶トークンと記憶ダイジェストをユーザーごとに関連付けて、永続的セッションが実現できる
cookies
メソッドを使うと、ユーザーのブラウザにcookiesなどを保存できる- ログイン状態は、セッションもしくはクッキーの状態に基づいて決定される
- セッションとクッキーをそれぞれ削除すると、ユーザーのログアウトが実現できる
- 三項演算子を使用すると、単純なif-then文をコンパクトに記述できる
has_secure_token
メソッドが導入されました。しかし、このメソッドはハッシュ化されていない値をデータベースに保存するため、セキュリティ上の目的から本チュートリアルでは採用しないことにしました。signed
メソッドを使って両方の処理をまとめて行うようになりました。cookiesの暗号化についてはこちらのquoraの記事も参考にしてみてください。11. 読者のJack Fahnestockから、現在の設計だと複数端末のログインに対応できないというフィードバックをもらいました。
- ブラウザAを起動し、“remember me”をチェックしてログインする (ハッシュ化された記憶トークンを
remember_digest
に保存する) - ブラウザBを起動し、“remember me”をチェックしてログインする (ハッシュ化された記憶トークンを
remember_digest
に保存し、ブラウザAが持つ記憶トークンを無効化する) - ブラウザAを閉じる (
current_user
メソッドが永続クッキーを使ってログインするようになる) - ブラウザAを起動する (ブラウザ内に永続クッキーはあるが、
logged_in?
がfalseを返してしまう)
確かに現在の設計ではユーザーが複数の端末からログインすることを想定していないため、ユーザーは2つ以上のブラウザでRemember me機能を使うことができません。現在の設計よりやや複雑になりますが、この問題に対する解決策は記憶ダイジェストを1つのテーブルとして新たに作成し、そのテーブルをユーザーのIDと紐づけることが考えられます。例えば現在のユーザーを見つけるときは、そのテーブルを通して記憶ダイジェストと対応する記憶トークンをチェックするようにします。また、リスト 9.11にあるforget
メソッドも同様に変更し、現在使っているブラウザに対応している記憶ダイジェストのみを削除させる必要があるでしょう。なお、セキュリティのことを考慮して、ユーザーがログアウトをした場合はそのユーザーに紐付いているすべてのダイジェストを削除しておくと良さそうです。
remember user
をカッコなしで書きましたが、三項演算子ではカッコを省略すると文法エラーになります。
Railsチュートリアルは YassLab 社によって運営されています。
コンテンツを継続的に提供するため、書籍・動画・質問対応サービスなどもご検討していただけると嬉しいです。
研修支援や教材連携にも対応しています。note マガジンや YouTube チャンネルも始めたので、よければぜひ遊びに来てください!