Ruby on Rails チュートリアル

実例を使ってRailsを学ぼう

Michael Hartl (マイケル・ハートル)

第4版 目次

推薦の言葉

私が前にいた会社 (CD Baby) は、かなり早い段階でRuby on Railsに乗り換えたのですが、またPHPに戻ってしまいました (詳細は私の名前をGoogleで検索してみてください)。そんな私ですが、Michael Hartl 氏の本を強く勧められたので、その本を使ってもう一度試してみた結果、今度は無事に Rails に乗り換えることができました。それがこの Ruby on Rails チュートリアルという本です。

私は多くの Rails 関連の本を参考にしてきましたが、真の決定版と呼べるものは本書をおいて他にありません。本書では、あらゆる手順が「Rails 流」で行われています。最初のうちは慣れるまでに時間がかかりましたが、この本を終えた今、ついにこれこそが自然な方式だと感じられるまでになりました。また、本書は Rails 関連の本の中で唯一、多くのプロが推奨するテスト駆動開発 (TDD: Test Driven Development) を、全編を通して実践しています。実例を使ってここまで分かりやすく解説された本は、本書が初めてでしょう。極めつけは、Git や GitHub、Heroku の実例に含めている点です。このような、実際の開発現場で使わているツールもチュートリアルに含まれているため、読者は、まるで実際のプロジェクトの開発プロセスを体験しているかのような感覚が得られるはずです。それでいて、それぞれの実例が独立したセクションになっているのではなく、そのどれもがチュートリアルの内容と見事に一体化しています。

本書は、筋道だった一本道の物語のようになっています。私自身、章の終わりにある練習問題もやりながら、この Rails チュートリアルを3日間かけて一気に読破しました1。最初から最後まで、途中を飛ばさずにやるのが一番効果的で有益な読み方です。ぜひやってみてください。

それでは、楽しんでお読みください!

Derek Sivers (sivers.org) CD Baby 創業者

(訳注: たった3分のTEDの動画「社会運動をどうやって起こすか」を観たことがある方もいるのではないでしょうか。その方からの推薦の言葉です。)

謝辞

Ruby on Rails チュートリアルは、私の以前の著書「RailsSpace」と、その時の共著者 Aurelius Prochazka から多くを参考にさせてもらっています。Aure には、協力と本書への支援も含め、感謝したいと思います。また、RailsSpaceRails チュートリアルの編集を担当した Debra Williams Cauley 氏にも謝意を表したく思います。彼女が野球の試合に連れて行ってくれる限り、私は本を書き続けるでしょう。

私にインスピレーションと知識を与えてくれた Rubyist の方々にも感謝したいと思います: David Heinemeier Hansson, Yehuda Katz, Carl Lerche, Jeremy Kemper, Xavier Noria, Ryan Bates, Geoffrey Grosenbach, Peter Cooper, Matt Aimonetti, Mark Bates, Gregg Pollack, Wayne E. Seguin, Amy Hoy, Dave Chelimsky, Pat Maddox, Tom Preston-Werner, Chris Wanstrath, Chad Fowler, Josh Susser, Obie Fernandez, Ian McFarland, Steven Bristol, Pratik Naik, Sarah Mei, Sarah Allen, Wolfram Arnold, Alex Chaffee, Giles Bowkett, Evan Dorn, Long Nguyen, James Lindenbaum, Adam Wiggins, Tikhon Bernstam, Ron Evans, Wyatt Greene, Miles Forrest, Sandi Metz, Ryan Davis, Aaron Patterson, Pivotal Labs の方々、Heroku の方々、thoughtbot の方々、そして GitHub の方々、ありがとうございました。最後に、ここに書ききれないほど多くの読者からバグ報告や提案を頂きました。ご協力いただいた皆様のおかげで、本書の完成度をとことんまで高めることができました。

丁寧なレビュー、技術的なフィードバック、そして役立つ提案をしてくれた Andrew Thai に感謝します。また、Learn Enough to Be Dangerous の共同創業者である Nick Merwin と Lee Donahoe、日々のチュートリアルの制作をサポートしてくれてありがとう。

最後に、たくさんの読者の皆さん、そして、ここに挙げきれないほど多いコントリビューターのみんな、バグ報告や提案をしてくれてありがとう。彼ら/彼女らの多くの手助けに、最高の感謝を。

著者

マイケル・ハートル (Michael Hartl)Ruby on Rails Tutorial という、Web 開発を学ぶときによく参考にされる本の著者です。 また、Learn Enough to Be Dangerous (learnenough.com) 教育系ウェブサイトの創業者でもあります。 以前は、(今ではすっかり古くなってしまいましたが)「RailsSpace」という本の執筆および開発に携わったり、また、 一時人気を博した Ruby on Rails ベースのSNSプラットフォーム「Insoshi」の開発にも携わっていました。 2011年には、Rails コミュニティへの高い貢献が認められて、Ruby Hero Award を受賞しました。

ハーバード大学卒業後、カリフォルニア工科大学物理学博士号を取得。シリコンバレーの有名な起業プログラム Y Combinator の卒業生でもあります。

  1. 3日間で読破できる人は例外です! 実際には数週間〜数ヶ月をかけて読むのが一般的です。 

第9章発展的なログイン機構

8では、基本的なログイン機構を実装しました。しかし最近のウェブサービスでは、(任意で) ユーザーのログイン情報を記憶しておき、ブラウザを再起動した後でもすぐにログインできる機能 (remember me) を備えていることも一般的になってきました。そこで本章では、永続クッキー (permanent cookies) を使ってこの機能を実現していきます。具体的には、まずユーザーのログイン情報を長く記憶する方法 (例えばBitbucketやGitHubが使っている方法) について学びます。その後、[remember me] チェックボックスを使って、ユーザーの任意でログイン情報を記憶する方法について学びます (例えばTwitterやFacebookも使っていますね)。

なお、サンプルアプリケーションの基本的なログイン機構は8で実装できているので、実は本章をスキップして10に進んでしまうことも可能です (同様にして、アカウントの有効化とパスワードの再設定も実はスキップして13に進むことも可能です)。とはいえ、[remember me] 機能の実装方法を学ぶことは今後の開発で大いに役立ちますし、実際、続くアカウントの有効化 (11) やパスワードの再設定 (12) などの高度な機能を実装する上でも本章は欠かせません。また、ウェブ上の至るところで [remember me] を備えたログインフォームがありますが、本章はその中身 (Computer Magic) を知る絶好のチャンスとも言えるでしょう。

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) を行うようにします。

上で説明した設計やセキュリティ上の考慮事項を元に、次の方針で永続的セッションを作成することにします。

  1. 記憶トークンにはランダムな文字列を生成して用いる。
  2. ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
  3. トークンはハッシュ値に変換してからデータベースに保存する。
  4. ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
  5. 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。

上の最後の手順が、ユーザーログインのときの手順と似ていることにご注目ください。ユーザーログインでは、メールアドレスをキーにしてユーザーを取り出し、送信されたパスワードがパスワードダイジェストと一致することを (authenticateメソッドで) 確認します (リスト 8.7)。 つまり、ここでの実装はhas_secure_passwordと似た側面を持ちます。

それでは最初に、必要となるremember_digest属性をUserモデルに追加します (図 9.1)。

user_model_remember_digest
図 9.1: remember_digest属性を追加したUserモデル

図 9.1のデータモデルをアプリケーションに追加するために、以下のマイグレーションを生成します。

$ rails generate migration add_remember_digest_to_users remember_digest:string

(6.3.1のパスワードダイジェストのときのマイグレーションと比較してみましょう。) 前回のマイグレーションと同様、今回のマイグレーション名も_to_usersで終わっています。これは、マイグレーションの対象がデータベースのusersテーブルであることをRailsに指示するためのものです。 今回は種類=stringremember_digest属性を追加しているので、いつものようにRailsによってデフォルトのマイグレーションが作成されます (リスト 9.1)。

リスト 9.1: 記憶ダイジェスト用に生成したマイグレーション db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0]
  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"

2人のユーザーのパスワードが完全に同じであれば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に示します。

リスト 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メソッド (cookiesの保存場所です) を使用してトークンにアクセスできるようにする必要があります。しかも、トークンをデータベースに保存せずに実装する必要があります。 そのためには、6.3の安全なパスワードの問題のときと同様の手法でこれを解決します。あのときは、「仮想の」password属性と、データベース上のセキュアなpassword_digest属性を使用しました。 仮想の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に示します。

リスト 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

演習

  1. コンソールを開き、データベースにある最初のユーザーを変数userに代入してください。その後、そのuserオブジェクトからrememberメソッドがうまく動くかどうか確認してみましょう。また、remember_tokenremember_digestの違いも確認してみてください。
  2. リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。 しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、リスト 9.4 (ややわかりにくい) や、リスト 9.5 (非常に混乱する) の実装でも、正しく動くことを確認してみてください。ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4リスト 9.5の文脈では、selfUser「クラス」を指すことにご注意ください。 わかりにくさの原因の一部はこの点にあります。
リスト 9.4: selfを使ってdigestnew_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
リスト 9.5: class << selfを使ってdigestnew_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は、ひとつのvalue (値) と、オプションのexpires (有効期限) からできています。有効期限は省略可能です。 例えば次のように、20年後に期限切れになる記憶トークンと同じ値をcookieに保存することで、永続的なセッションを作ることができます。

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に設定します。

コラム 9.1. cookiesは今から20年後に切れる (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によって、数値関連の基底クラスであるFixnumクラスに追加されます。

  $ 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も永続化しなくてはなりません。そこで、次のようにsignedpermanentをメソッドチェーンで繋いで使います。

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ではこのメソッドを一般化してみます。

リスト 9.6: 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.3attr_accessor :remember_tokenで定義したアクセサとは異なる点に注意してください (リスト 9.6)。今回の場合、is_password?の引数はメソッド内のローカル変数を参照しています。もうひとつ、remember_digestの属性の使い方にもご注目ください。この使い方はself.remember_digestと同じであり、すなわち6nameemailの使い方と同じになります。実際、remember_digestの属性はデータベースのカラムに対応しているため、Active Recordによって簡単に取得したり保存したりできます (リスト 9.1)。

これで、ログインしたユーザーを記憶する処理の準備が整いました。rememberヘルパーメソッドを追加して、log_inと連携させてみましょう (リスト 9.7)。

リスト 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に示します。

リスト 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
    @current_user ||= User.find_by(id: session[:user_id])
  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) を使用している点にご注目ください。上のコードは動作しますが、今のままではsessioncookiesもそれぞれ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のようになります。

リスト 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のコードでは、新しくログインしたユーザーは正しく記憶されます。実際にログインしてからブラウザを閉じ、アプリケーションを再起動してからもう一度ブラウザでアプリケーションを開いてみると、期待どおり動作していることを確認できます。 その気になれば、ブラウザのcookiesをブラウザで直接調べて結果を確認することもできます (図 9.2)11

アプリケーションに現在残された問題はあと1つだけです。ブラウザのcookiesを削除する手段が未実装なので (20年待てば消えますが)、ユーザーがログアウトできません。これは当然テストスイートでキャッチすべき問題であり、redにならなければなりません。

リスト 9.10: red
$ rails test

演習

  1. ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。
  2. コンソールを開き、リスト 9.6authenticated?メソッドがうまく動くかどうか確かめてみましょう。

9.1.3 ユーザーを忘れる

ユーザーがログアウトできるようにするために、ユーザーを記憶するためのメソッドと同様の方法で、ユーザーを忘れるためのメソッドを定義します。 このuser.forgetメソッドによって、user.rememberが取り消されます。具体的には、記憶ダイジェストをnilで更新します (リスト 9.11)。

リスト 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_idremember_tokenのcookiesを削除していることがわかります。

リスト 9.12: 永続セッションからログアウトする green 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になることを確認してみましょう。

リスト 9.13: green
$ rails test

演習

  1. ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。

9.1.4 2つの目立たないバグ

実は小さなバグが2つ残っていて、この2つのバグは互いに強く関連しています。1つ目のバグは次の通りです。ユーザーは場合によっては、同じサイトを複数のタブ (あるいはウィンドウ) で開いていることもあります。ログアウト用リンクはログイン中には表示されませんが、今のcurrent_userの使い方では、ユーザーが1つのタブでログアウトし、もう1つのタブで再度ログアウトしようとするとエラーになってしまいます。これは、もう1つのタブで "Log out" リンクをクリックすると、current_usernilとなってしまうため、log_outメソッド内のforget(current_user)が失敗してしまうからです (リスト 9.12)12。この問題を回避するためには、ユーザーがログイン中の場合にのみログアウトさせる必要があります。

2番目の地味な問題は、ユーザーが複数のブラウザ (FirefoxやChromeなど) でログインしていたときに起こります。例えば、Firefoxでログアウトし、Chromeではログアウトせずにブラウザを終了させ、再度Chromeで同じページを開くと、この問題が発生します13。FirefoxとChromeを使った具体例で考えてみましょう。ユーザーがFirefoxからログアウトすると、user.forgetメソッドによってremember_digestnilになります (リスト 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])

このとき、usernilであれば1番目の条件式で評価は終了するのですが、実際にはnilではないので2番目の条件式まで評価が進み、そのときにエラーが発生します。原因は、Firefoxでログアウトしたときに (リスト 9.11) ユーザーのremember_digestが削除してしまっているにもかかわらず、Chromeでアプリケーションにアクセスしたときに次の文を実行してしまうからです。

BCrypt::Password.new(remember_digest).is_password?(remember_token)

すなわち上のremember_digestnilになるので、bcryptライブラリ内部で例外が発生します。この問題を解決するには、remember_digestが存在しないときはfalseを返す処理をauthenticated?に追加する必要があります。

テスト駆動開発は、この種の地味なバグ修正にはうってつけです。そこで、2つのエラーをキャッチするテストから書いていくことにしましょう。まずはリスト 8.31の統合テストを元に、redになるテストを作成してみます (リスト 9.14)。

リスト 9.14: ユーザーログアウトのテスト red 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になります。

リスト 9.15: red
$ rails test

次にこのテストを成功させます。具体的にはリスト 9.16のコードで、logged_in?がtrueの場合に限ってlog_outを呼び出すように変更します。

リスト 9.16: ログイン中の場合のみログアウトする green 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)。 この中で、記憶トークンを空欄のままにしていることにご注目ください。記憶トークンが使用される前にエラーが発生するので、記憶トークンの値は何でも構わないのです。

リスト 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になります。

リスト 9.18: red
$ rails test

このテストを greenにするためには、記憶ダイジェストがnilの場合にfalseを返すようにすれば良さそうです (リスト 9.19)。

リスト 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になり、サブタイトルは両方とも修正されるはずです。

リスト 9.20: green
$ rails test

演習

  1. リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。
  2. リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。
  3. 上のコードでコメントアウトした部分を元に戻し、テストスイートが red から greenになることを確認しましょう。

9.2 [Remember me] チェックボックス

9.1.3のコードで、アプリケーションにプロ仕様の完全な認証システムが導入されました。 本章の最後に、[remember me] チェックボックスでログインを保持する方法を解説します。 チェックボックスを追加したモックアップを図 9.3に示します。

images/figures/login_remember_me_mockup
図 9.3: [remember me] チェックボックスのモックアップ

今回の実装は、リスト 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に示します。

リスト 9.21: [remember me] チェックボックスをログインフォームに追加する 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クラスcheckboxinlineをインクルードしています。Bootstrapではこれらをチェックボックスとテキスト「Remember me on this computer”」として同じ行に配置します。 スタイルを整えるため、もう少しCSSルールを追加します (リスト 9.22)。 表示されるログインフォームを図 9.4に示します。

リスト 9.22: [remember me] チェックボックスのCSS 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;
}
images/figures/login_form_remember_me
図 9.4: [remember me] チェックボックスを追加したloginフォーム

ログインフォームの編集が終わったので、チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにします。 信じられないかもしれませんが、必要な準備はすべて終わっているので、実装はわずか1行で終わります。 ログインフォームから送信されたparamsハッシュには既にチェックボックスの値が含まれています。リスト 9.21のフォームに無効な値を入力して実際に送信すれば、ページのデバッグ情報で値を確認することもできます。 特に、次の値は、

params[:session][:remember_me]

チェックボックスがオンのときに’1’になり、オフのときに’0’になります。

paramsハッシュのこの値を調べれば、送信された値に基いてユーザーを記憶したり忘れたりできるようになります14

if params[:session][:remember_me] == '1'
  remember(user)
else
  forget(user)
end

詳細はコラム 9.2で説明しますが、次のような「三項演算子 (ternary operator)」を使うと、このようなif-thenの分岐構造を1行で表すことができます15

params[:session][:remember_me] == '1' ? remember(user) : forget(user)

Sessionsコントローラのcreateに上の行を追加した結果をリスト 9.23に示します。驚くほどコンパクトなコードになりました。 既に読者の皆様は、cost変数の定義に三項演算子を使ったリスト 8.21のコードも理解できるようになったことでしょう。

リスト 9.23: [remember me] チェックボックスの送信結果を処理する 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の実装によって、ログインシステムの実装がついに完了しました。ブラウザでこのチェックボックスを実際にオンにしたりオフにしたりして、動作を確認してみましょう。

コラム 9.2. 10種類の人々

「この世には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"をそれぞれ返します。

演習

  1. ブラウザでcookies情報を調べ、[remember me] をチェックしたときに意図した結果になっているかどうかを確認してみましょう。
  2. コンソールを開き、三項演算子を使った実例を考えてみてください (コラム 9.2)。

9.3 [Remember me] のテスト

[remember me] 機能は既に快調に動作していますが、ここで終わらせずにテストをちゃんと書き、動作をテストで確認できるようにしておくことが重要です。 テストを書く理由のひとつは、今行った実装のエラーをキャッチできるようにすることです。 しかしもっと重要な理由は、ユーザーを永続化するコードの中心部分が、実はまだまったくテストされていないからです。 これらの課題を達成するには、もう少し新しいテストのテクニックを覚える必要がありますが、それによりテストスイートが一段と強力になります。

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.26is_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)。

リスト 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.23users(:michael)と書くと、リスト 8.22のfixtureユーザーを参照していたことを思い出しましょう)。

リスト 9.25: [remember me] チェックボックスのテスト green 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_nil cookies['remember_token']
  end

  test "login without remembering" do
    log_in_as(@user, remember_me: '0')
    assert_nil cookies['remember_token']
  end
end

皆さんが筆者と同じ間違いをしていなければ、このテストは greenになっているはずでしょう。

リスト 9.26: green
$ rails test

演習

  1. リスト 9.25の統合テストでは、仮想のremember_token属性にアクセスできないと説明しましたが、実は、assignsという特殊なテストメソッドを使うとアクセスできるようになります。コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使用します。このメソッドにはインスタンス変数に対応するシンボルを渡します。 たとえば、createアクションで@userというインスタンス変数が定義されていれば、テスト内部ではassigns(:user)と書くことでインスタンス変数にアクセスできます。 本チュートリアルのアプリケーションの場合、Sessionsコントローラのcreateアクションでは、userを (インスタンス変数ではない) 通常のローカル変数として定義しましたが、これをインスタンス変数に変えてしまえば、cookiesにユーザーの記憶トークンが正しく含まれているかどうかをテストできるようになります。 このアイディアに従ってリスト 9.27リスト 9.28の不足分を埋め (ヒントとして?FILL_INを目印に置いてあります)、[remember me] チェックボックスのテストを改良してみてください。
リスト 9.27: 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
リスト 9.28: [remember me] テストを改良するためのテンプレート green 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 FILL_IN, assigns(:user).FILL_IN
  end

  test "login without remembering" do
    log_in_as(@user, remember_me: '0')
    assert_nil cookies['remember_token']
  end
  .
  .
  .
end

9.3.2 [Remember me] をテストする

9.1.2では、それまでの節で実装した永続的セッションが動作するかどうかを手動で確認していました。しかし実は、current_user内のある複雑な分岐処理については、これまでまったくテストが行われていないのです。筆者はこのようなとき、テストを忘れている疑いのあるコードブロック内にわざと例外発生を仕込むというテクニックを使います。つまり、そのコードブロックがテストから漏れていれば、テストはパスしてしまうはずです。コードブロックがテストから漏れていなければ、例外が発生してテストが中断するはずです。現在のコードでこれを行ってみた結果をリスト 9.29に示します。

リスト 9.29: テストされていないブランチで例外を発生するgreen 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になります。

リスト 9.30: 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

テスト手順はシンプルです。

  1. fixtureでuser変数を定義する
  2. 渡されたユーザーをrememberメソッドで記憶する
  3. current_userが、渡されたユーザーと同じであることを確認します

上の手順ではrememberメソッドではsession[:user_id]が設定されないので、これで問題となっている複雑な分岐処理もテストできるようになります。作成したコードをリスト 9.31に示します。

リスト 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 になります。

リスト 9.32: red
$ rails test test/helpers/sessions_helper_test.rb

ここまでできれば、current_userメソッドに仕込んだraiseを削除して元に戻す (リスト 9.31) ことで、リスト 9.33のテストがパスするはずです

リスト 9.33: 例外発生部分を削除する green 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になるはずです。

リスト 9.34: green
$ rails test

current_userの複雑な分岐処理をテストできたので、今後は手動で1つ1つ確認しなくても、自信を持って回帰バグをキャッチできます。

演習

  1. リスト 9.33にあるauthenticated?の式を削除すると、リスト 9.31の2つ目のテストで失敗することを確かめてみましょう (このテストが正しい対象をテストしていることを確認してみましょう)。

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をブラウザに保存して、永続的セッションを作成できます。
  • ログイン状態 (ログインしているかどうか) は、一時セッションのユーザーIDか、永続的セッションの一意な記憶トークンに基いた現在のユーザーが存在しているかどうかで決定されます。
  • セッションのユーザーIDを削除し、ブラウザの永続的cookiesを削除すると、アプリケーションからユーザーがログアウトします。
  • 三項演算子を使用すると、単純なif-thenステートメントをコンパクトに記述することができます。
  1. セッションハイジャックは、セキュリティ上の注意を呼びかけるためにこれを実演するFiresheepアプリケーションによって広く知られるようになりました。Firesheepを使用すると、公共Wi-Fiネットワーク経由で接続したときに多くの有名Webサイトの記憶トークンが丸見えになっていることがわかります。 
  2. Rails 5から、ランダムなトークンを生成するhas_secure_tokenメソッドが導入されました。しかし、このメソッドはハッシュ化されていない値をデータベースに保存するため、セキュリティ上の目的から本チュートリアルでは採用しないことにしました。 
  3. このメソッドは、RailsCastの「remember me」の記事を元に選びました。 
  4. 実際、これでもOKなのです。bcryptのハッシュはソルト化されているので、2人のユーザーのパスワードが本当に一致するのかどうかは、ハッシュからは分かりません。(訳注: 「ソルト」とは、暗号を強化するために加えられる任意の短い文字列です。念には念を入れて「塩ひとつまみ」を加えるというイメージであり、英語の「take it with a grain of salt (半分疑ってかかる)」という言い回しが語源です) 
  5. 記憶トークンが一意に保たれることで、攻撃者はユーザーIDと記憶トークンを両方とも奪い取ることに成功しない限りセッションをハイジャックできなくなります。 
  6. 何人かの開発者は、念のためにさらに何かを付け足した方が良いと思うかもしれません。ただ、その努力の対価は \( 10^{-40} \) 以下の確率で発生する問題に備えるようなものです。これは、もし仮に100万個のトークンを生成したとしても、そのトークンが衝突する可能性は \( 10^{-23} \) 以下である、という意味になります。 
  7. 一般に、あるメソッドがオブジェクトのインスタンスを必要としていない場合は、クラスメソッドにするのが常道です。実際、11.2ではここでの決定が重要になってきます。 
  8. 一般的には、デジタル署名暗号化はそれぞれ違った処理をしますが、Rails 4からは、signedメソッドを使って両方の処理をまとめて行うようになりました。 
  9. 6.3.1で解説したように、「暗号化されていないパスワード (unencrypted password)」という呼び方は正しくありません。ここで言うセキュアなパスワードとは、単にハッシュ化したという意味であり、本格的な暗号化は行われていないからです。 
  10. 筆者はこのような場合、代入式全体をかっこで囲むようにしています。これが比較でないことを思い出せるようにするためです。 
  11. システムでのcookiesの調べ方については、「<ブラウザ名> inspect cookies」でググってください。 
  12. 読者のPaulo Célio Júniorからのご指摘でした。ありがとうございました。 
  13. 読者のNiels de Ronからのご指摘でした。ありがとうございます。 
  14. ユーザーがこのチェックボックスをオフすると、すべてのコンピュータ上のすべてのブラウザからログアウトしますので、注意が必要です。 ブラウザごとにユーザーのログインセッションを記憶する設計に変更すれば、ユーザーにとってもう少し便利にはなりますが、その分セキュリティが低下するうえ、実装も面倒になります。 やる気の余っている方は実装してみてもよいでしょう。 
  15. 以前はremember userをかっこなしで書きましたが、三項演算子ではかっこを省略すると文法エラーになります。 
Railsチュートリアルは,Ruby/Rails のアジャイル開発を得意とする YassLab によって運営・保守されております.
継続的に良いコンテンツを提供する為に,電子書籍解説動画のご購入を検討して頂けると幸いです m(_ _)m
スポンサーシップや商用利用などに関するご相談がありましたら,お問い合わせページよりお気軽にご連絡ください :)