LoginSignup
34
16

More than 3 years have passed since last update.

Ruby on Rails チュートリアル 第9章 永続的セッション(cookies remember me 記憶トークン ハッシュ)を解説

Last updated at Posted at 2019-01-09

近況報告

エンジニア転職成功しました。YouTubeもはじめました。

前回の続き

著者略歴
YUUKI
ポートフォリオサイト:Pooks
現在:RailsTutorial2周目

第9章 難易度 ★★★★ 4時間

挫折しないRailsチュートリアルの進め方を先にお読みください↓↓

Railsチュートリアルで挫折しない3つのポイント

8章では一時的なセッション(クッキー)を使ってログイン機構を作ったが、
この章では任意でユーザーのログイン情報を記憶できるチェックボックスを追加し、
ブラウザを再起動した後でもすぐにログインできる機能
remember meを作る。

この機能を実現する為に、永続クッキーpermanent cookiesを使う。

まずはユーザーのログイン情報を永続的に記憶する方法を学び、
次にremember meチェックボックスを使って、ユーザーの任意でログイン情報を記憶する方法を学ぶ。

remember me機能の実装方法を学ぶことは、アカウントの有効化やパスワードの再設定などの高度な機能を実装する上でも役立ってくるので、ぜひしっかり学びたい。

9.1 Remember me 機能

remember me機能は、ユーザーが明示的にログアウトしない限り、ログイン状態を維持することができるようになる。

まずは、トピックブランチを作って作業gitを移動する。

$ git checkout -b advanced-login

9.1.1 記憶トークンと暗号化

セッション永続化の為に

  • 記憶トークン(remember token)を作成、
  • cookiesメソッドによる永続的cookiesの作成、
  • 安全性の高い記憶ダイジェスト(remember digest)によるトークン認証

する。

前章ではsessionメソッドに一時的な情報を保存したので安全が保たれるが、
cookiesメソッドに保存する情報は永続化することもあって、セッションハイジャックなどの様々な攻撃を受ける可能性がある。

cookiesを盗み出す有名な方法は4通り

①管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す
②DBから記憶トークンを取り出す
③クロスサイトスクリプティング(XSS)を使う
④ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る

最初の問題は7章で通信をSSL化したので、パケットスニッファから読み取られないようにした。
2番目の問題は記憶トークンをそのままDBに保存するのではなく、記憶トークンの代わりにハッシュ値を保存するようにする。
この問題に関しては6章でpassword_digestを使ってハッシュ化したパスワードを保存したので大丈夫
3番目の問題に関してはRailsによって自動的に対策が行われる。
具体的には、ビューのテンプレートで入力した内容を全て自動的にエスケープ(無効化)したので大丈夫。

問題は4番目で、流石にシステム側で完全に防衛するのは不可能。
だが、2次被害を最小限に留めることは可能。

具体的には、ユーザーがログアウトした時にトークンを必ず変更するようにし、
セキュリティ上重要になる可能性のある情報を表示する時には、デジタル署名(digital signature)を使う。

つまりトークンが盗まれても大丈夫なようにするってこと。

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

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

上記の手順はユーザーがログインする時の手順と似ている。

ユーザーログインでは、メールアドレスをキーにユーザーを取り出し、送信されたパスワードがパスワードダイジェストと一致することを、authenticateメソッドで確認してログインしていた。

つまり、ここでの実装はhas_secure_passwordと似た側面を持つ。

とりあえずremember_digest(記憶ダイジェスト)をUserモデルに追加する

image.png

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

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

$ rails g migration add_remember_digest_to_users remember_digest:string

usersテーブル(Userモデルの表)はすでに作成されているので、remember_digest:stringでrember_digest属性のデータ型をStringに指定したレコードをマイグレーションファイルで生成した。

ファイル名をto_usersと書くことで、マイグレーションの変更対象がusersテーブルであることをRailsに指示している。

[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メソッドを用いる。

$ rails c
>> SecureRandom.urlsafe_base64
=> "Xjqf9D02yrYA2_Mc9u6nlw"

同一のパスワードを持つユーザーが複数いても問題ないのと同様に、
同一の記憶トークンを持つユーザーが複数いても問題ない。

一方で、セッションハイジャックのリスクなどを考えると、トークンは一意である方がより安全。
何故なら同一のトークンが沢山あれば平文を予想できないから。

また、先ほどのbase64の文字列は、64種類の文字・長さ22の文字列なので、2つの記憶トークンがたまたま完全に一致する確率は2の-132乗から10の-40乗なので、トークンが衝突することはまずありえない。

さらに、base64はURLを安全にエスケープするためにも用いられる。
そのため、base64を採用すれば、第10章でアカウントの有効化のリンクやパスワードリセットのリンクでも同じトークンジェネレータを使えるようになる。

つまり、URLのトークン化・パスワードのトークン化にbase64が使えるってこと。

ユーザーを記憶するには、記憶トークンを作成して、そのトークンをダイジェストに変換したものをDBに保存する。
fixtureをテストする時にdigestメソッドを作っていたので、新規トークンを作成するためにnew_tokenメソッドを作成できる。
この新しいdigestメソッドではユーザーオブジェクトが不要な為、Userモデルのクラスにクラスメソッドとして作成できる。

user.rb
  #password_digestの文字列をハッシュ化して、ハッシュ値として返す
  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                                                            # Userクラスにnew_tokenを渡したクラスメソッドを作成
    SecureRandom.urlsafe_base64                                                 # SecureRandomモジュールにbase64でランダムな文字列を生成
  end

さしあたっての実装計画として、user.rememberメソッドを作成する。
このメソッドは記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをDBに保存する。

既にDBにあるremember_digest属性とは別に、remember_tokenをDBに保存せずに、user.remember_tokenメソッド(cookiesの保存場所)を使ってトークンにアクセスできるようにする必要がある。

そのために、6章で行った安全なパスワードの問題の時と同様の手法でこれを解決する。

6章では仮想的なpassword属性とpassword_digest属性を使ったが、password属性はhas_secure_passwordメソッドで自動的に作成していた。

今回はその為のコードを、remember_token変数に自分で書いていく。

この実装の為、attr_accessor(インスタンス変数の定義)属性を作成する。

user.rb
class User < ApplicationRecord
  # インスタンス変数の定義
  attr_accessor :remember_token

  # ランダムなトークンを返す
  def User.new_token                                                            # Userクラスにnew_tokenを渡したクラスメソッドを作成
    SecureRandom.urlsafe_base64                                                 # SecureRandomモジュールにbase64でランダムな文字列を生成
  end

  # 記憶トークンをユーザーオブジェクトに代入し、DBのデータを更新する。
  def remember
    self.remember_token =                                                       # 
    update_attribute(:remember_digest, )                                        # 
  end

このように、定義したインスタンス変数remeber_tokenをUserクラスオブジェクトの属性として扱うには、selfメソッド(オブジェクト自身)に対してインスタンス変数を渡す。

selfを付けないと新たに変数が定義されてしまうので気を付ける。

rememberメソッド2行目では
update_attributeメソッドを使ってDBに対して記憶ダイジェストを更新せよという命令を出している。

update_attributeメソッドには、バリデーションを回避するという特性があるので、
これを利用してパスワードなどを設定せずに属性値を更新できる。

以上の点を考慮し、

Base64で作成した記憶トークン

ハッシュ関数(Bcrypt)でハッシュ化

ハッシュ値(記憶ダイジェスト)として作成できるにする。

user.rb
  #渡された文字列をハッシュ化して、ハッシュ値として返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

  # ランダムなトークンを返す
  def User.new_token                                                            # Userクラスにnew_tokenを渡したクラスメソッドを作成
    SecureRandom.urlsafe_base64                                                 # SecureRandomモジュールにbase64でランダムな文字列を生成
  end

  # 記憶トークンをUserオブジェクトのremember_token属性に代入し、DBに記憶ダイジェストとして保存
  def remember
    self.remember_token = User.new_token                                        # 記憶トークンをremember_token属性に代入 
    update_attribute(:remember_digest, User.digest(remember_token))             # DBのremember_token属性値をBcryptに渡してハッシュ化して更新
  end

演習

1:RailsConsoleでuser変数にDBにある最初のユーザーを代入し、rememberメソッドが使えるか確認

$ rails c
Running via Spring preloader in process 14636
Loading development environment (Rails 5.1.6)
>> user = User.first
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "YUUKI", email: "yuukitetsuyanet@gmail.com", created_at: "2018-12-29 11:56:17", updated_at: "2018-12-29 11:56:17", password_digest: "$2a$10$SN6Plpt.OfT083vHx5IkR.1qxNjD1gVOGLArYothggk...", remember_digest: nil>
>> user.remember
   (0.1ms)  begin transaction
  SQL (1.9ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2019-01-03 13:06:29.723663"], ["remember_digest", "$2a$10$i15VXXY4C3NtzBhaQXUC.OMi3tk3OJ.6CMOZldkSgoeCcl2nGlRAO"], ["id", 1]]
   (6.6ms)  commit transaction
=> true

2:明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義したが、
それよりRuby的に正しいクラスメソッドの定義方法でテストがパスするか確認してみる。

  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

テストはパス。

user.rb
  # Userクラスに対して定義する

  class << self

  # 渡された文字列のハッシュ値を返す

    def digest(string)
      cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :  # Userクラスにnew_tokenを渡したクラスメソッドを作成
                                                    BCrypt::Engine.cost         # SecureRandomモジュールにbase64でランダムな文字列を生成
      BCrypt::Password.create(string, cost: cost)
    end

    def new_token                                                               # 記憶トークンをremember_token属性に代入 
      SecureRandom.urlsafe_base64                                               # DBのremember_token属性値をBcryptに渡してハッシュ化して更新
    end
  end

9.1.2 ログイン状態の保持

user.rememberメソッドが動作するようになったので、ユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存して、永続セッションを作成する準備ができた。

これを実際に行うためcookiesメソッドを使う。
このメソッドはハッシュとして扱える。

個別のcookiesのハッシュ中身は

value(値) オプションのexpires(有効期限)

からできている。(有効期限は省略可能)

例えば、20年後に期限切れになる記憶トークンと、同じ値をcookieに保存することで、永続的なセッションを作成できる。

cookies[:remember_token] = { value:   remember_token,
                             expires: 20.years.from_now.utc }

cookiesの記憶トークンシンボル(最初は空)
に指定した値を代入している。

上記のように20年で期限切れになるcookies設定はよく使われている。
Railsではpermanentという専用のメソッドが追加されたほど。

cookies.permanent[:remember_token] = remember_token

cookiesメソッドについて

4章で、Rubyは組み込みクラスを含むあらゆるクラスにメソッドを追加できることを学んだ。

例えば、Stringクラスにpalindrome?というメソッドを新規作成し、そこに文字列を定義してSteingクラスに直接埋め込む(継承したクラスを作らずに)ことが可能。

また、Railsのblank?メソッドはクラス継承の中でもBasicObjectの次のObjectクラスという上の部分に追加されているメソッドだということがわかった。
これにより、"".blank?" ".blank?としてもtrueになる。
なぜなら、オブジェクトクラスは全てのクラスの継承元で、blank?はStringクラスではなくObjectクラスで定義されているので、文字列として定義していなければtrue(Stringを継承していないから)

cookies.permanentメソッドでは、cookiesが20年後に期限切れになる(20.years.from_now)ように指定している。

timeヘルパーはRailsによって、数値関連の基底クラスであるFixnumクラスに追加される。

>> 1.year.from_now
=> Sat, 04 Jan 2020 07:56:39 UTC +00:00
>> 10.weeks.ago
=> Fri, 26 Oct 2018 07:57:08 UTC +00:00

また、Railsではこのようなヘルパーも追加している。

> 1.kilobyte
=> 1024
>> 5.megabytes
=> 5242880

上のヘルパーは、ファイルのアップロードに容量の制限を与えるのに便利。

メソッドを組み込みクラスに追加できるおかげで、拡張的な使い方ができる。

cookiesの作り方

ユーザーIDをcookiesに保存するには、sessionメソッドで使ったのと同じパターンを使う。

cookies[:user_id] = user.id

しかし、このままではユーザーIDが生のテキストとしてcookiesに代入されてしまい、攻撃者にIDを盗まれてしまうかもしれない
これを避けるために、署名付きcookieを使う。

これを使うことで、cookieをブラウザに保存する前に安全に暗号化できる。

cookies.signed[:user_id] = user.id

signedメソッドに渡されたcookieは、なりすましを防ぐデジタル署名と、暗号化をまとめて実行される。

さらに、ユーザーidと記憶トークンはペアで扱う必要があるので、cookieも永続かしなくてはならない。
そこで、signedpermanentをメソッドチェーンで繋ぐ。

cookies.permanent.signed[:user_id] = user.id

これで、cookiesは20年期限付き(permanent)で、署名付きで暗号化されたcookieとして使えるようになった。

例えばページビューでcookiesからユーザーを取り出す場合

User.find_by(id: cookies.signed[:user_id])

このように使う。
id:ハッシュの中でcookies.signedとしているのは、署名付きで暗号化されたクッキーのユーザーidを探している為である。(要は、user_idを暗号化して検索してるってこと)
保存する時にsignedメソッドを使ったので、idをサーチする時もsignedメソッドを使う。

permanentメソッドを使ってないのは、cookieの有効期限は保存する時しか使う必要がない為。

ユーザーidを探す流れを示すと

ユーザーがidとパスワードを入力

ユーザーidはcookies.signedで暗号化し、暗号化したidと一致するユーザーidを探す

パスワードはbase64でトークン化(記憶トークン)し、BCrypt(ハッシュ関数)でハッシュ化する

入力したパスワードを元に作られたハッシュ値と、既にDBにある記憶ダイジェストが一致することを確認

ユーザーの取り出しに成功

このようになる。

つまり、ユーザーが入力したidとパスワードを暗号化した値が、DBにある暗号化された値と一致すればユーザーを取り出す。これで成り済ましを防止出来る。

また、攻撃者がidとパスワードのcookieを奪い取ったとしても、最後のBCryptによる、記憶トークンと記憶ダイジェストの一致が不可能なため、ログインできないということ

この「記憶トークンと記憶ダイジェストの一致」を設計するため、今からその実装をする。

この一致をbcryptで確認する為の方法は様々あり、例えばsecure passwordのソースコードには

BCrypt::Password.new(password_digest) == unencrypted_password

このような比較を行なっている。

これを参考にして、

BCrypt::Password.new(remember_digest) == remember_token

このようなコードを使って記憶トークンと記憶ダイジェストを比較し、同一であればtrueを返す。

しかし、本来であればbcryptのハッシュは復号化できない。
が、bcrypt gemの機能によって、比較に使っている==演算子が再定義されている。

つまり、実際のコードは

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

となる。

is_password?は論理値メソッドであり、==の代わりに比較として使える。

これを実際に実現するために、Userモデルにauthenticated?メソッドを、Userモデルの中に置く。

user.rb
  # Userクラスに対して定義する

  class << self

  # 渡された文字列のハッシュ値を返す

    def digest(string)
      cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :  # Userクラスにnew_tokenを渡したクラスメソッドを作成
                                                    BCrypt::Engine.cost         # SecureRandomモジュールにbase64でランダムな文字列を生成
      BCrypt::Password.create(string, cost: cost)
    end

    def new_token                                                               # 記憶トークンをremember_token属性に代入 
      SecureRandom.urlsafe_base64                                               # DBのremember_token属性値をBcryptに渡してハッシュ化して更新
    end
  end

  # 記憶トークンをUserオブジェクトのremember_token属性に代入し、DBに記憶ダイジェストとして保存
  def remember
    self.remember_token = User.new_token                                        # 記憶トークンをremember_token属性に代入 
    update_attribute(:remember_digest, User.digest(remember_token))             # DBのremember_token属性値をBcryptに渡してハッシュ化して更新
  end

    # 引数として受け取った値を記憶トークンに代入して暗号化(記憶ダイジェスト)し、DBにいるユーザーの記憶ダイジェストと比較、同一ならtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)          # DBの記憶ダイジェストと、受け取った記憶トークンを記憶ダイジェストにした値を比較
  end

ここで定義されているremember_tokenはグローバル変数定義のattr_accessorで書かれたものとは違い、新たにremember_token変数を定義(作成)している点に注意。

つまり、is_password?の引数として渡したremember_tokenはローカル変数で、新たに定義したremember_tokenの値を参照している。

remember_digestの属性はデータベースのカラムとして使っている為、
Active Recordによって簡単に取得したり保存できたりする。

これで、ログインしたユーザーを記憶する処理の準備が整ったので、rememberヘルパーメソッドを追加し
log_inと連携する。

sessions_controller.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)                                                              # login_inメソッドにuser(ログイン時にユーザーが送ったメールとパスと同一の、DBにいるユーザー)を引数として渡す
    session[:user_id] = user.id                                                 # ユーザーidをsessionのuser_idに代入(ログインidの保持)
  end

  # ユーザーのセッションを永続的にする
  def remember(user)                                                            # rememberメソッドにuser(ログイン時にユーザーが送ったメールとパスと同一の、DBにいるユーザー)を引数として渡す
    user.remember                                                               # ログイン時のユーザーと同一のDBのユーザーに、記憶トークンを生成して記憶ダイジェストにハッシュ化したハッシュ値を持たせて保存
    cookies.permanent.signed[:user_id] = user.id                                # ログイン時のユーザーidを、有効期限(20年)と署名付きの暗号化したユーザーidとしてcookiesに保存
    cookies.permanent[:remember_token] = user.remember_token                    # ログイン時の記憶トークンを、有効期限(20年)を設定して新たなremember_tokenに保存。Userモデルにて、ログインユーザーと同一ならtrueを返す
  end

かなりややこしいが、簡単にまとめると、先程のauthenticated?メソッドを実行させて、DBにあるハッシュ値とcookiesの値が同じかどうかを確認し、同じであればtrueを返して、永続的セッション(cookies)を作成している。

ただし、ログインするユーザーはブラウザで有効な記憶トークンを得られるように記憶されているが、
current_userメソッドでは一時セッションしか扱っておらず、このままでは正常に動作しない。

つまり、今のままでは現在のログイン中ユーザーがいない場合、全部sessionID(一時的なid)がcookieとして保存されてしまう。

@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

上のコードでは、sessionメソッドとcookiesメソッドをそれぞれ二回ずつ使っているので、これを綺麗にするためローカル変数を使う。

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のセッションが存在すればtrueとなる。

要は代入とsession[:user_id]があるかどうかを一回で条件式にしているってこと。

これを、current_userへルパーとして定義する。

sessions_helper.rb
  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])                                            # 一時的なセッションユーザーがいる場合処理を行い、user_idに代入
      @current_user ||= User.find_by(id: user_id)                               # 現在のユーザーがいればそのまま、いなければsessionユーザーidと同じidを持つユーザーをDBから探して@current_user(現在のログインユーザー)に代入
    elsif (user_id = cookies.signed[:user_id])                                  # user_idを暗号化した永続的なユーザーがいる(cookiesがある)場合処理を行い、user_idに代入
      user = User.find_by(id: user_id)                                          # 暗号化したユーザーidと同じユーザーidをもつユーザーをDBから探し、userに代入
      if user && user.authenticated?(cookies[:remember_token])                  # DBのユーザーがいるかつ、受け取った記憶トークンをハッシュ化した記憶ダイジェストを持つユーザーがいる場合処理を行う
        log_in user                                                             # 一致したユーザーでログインする
        @current_user = user                                                    # 現在のユーザーに一致したユーザーを設定
      end
    end
  end

これで、実際にログインしたユーザーが正しく記憶されるか確認。

スクリーンショット 2019-01-04 23.35.18.png

すげー。有効期限が20年後になってる。

あとはブラウザのcookiesを削除する機能を実装し、ユーザーをログアウトできるようにする。

演習

1:ログイン後のブラウザにuser_idもあることを確認

スクリーンショット 2019-01-04 23.41.11.png

2:コンソールでauthenticated?メソッドがうまく動くかどうか確認

>> user = User.first
  User Load (0.2ms)  SELECT  "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "YUUKI", email: "yuukitetsuyanet@gmail.com", created_at: "2018-12-29 11:56:17", updated_at: "2019-01-04 14:32:10", password_digest: "$2a$10$SN6Plpt.OfT083vHx5IkR.1qxNjD1gVOGLArYothggk...", remember_digest: "$2a$10$736cnA9/U0iThwKwDJHqXe88TjcX9FG6OC4RdW/XqJJ...">
>> user.remember
   (0.1ms)  begin transaction
  SQL (3.0ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2019-01-04 15:10:13.701209"], ["remember_digest", "$2a$10$Nc9Ohai1JWWPXTFkiXlScum/SZB/4osCcKxacg6HOvRkT3NP2M5I."], ["id", 1]]
   (8.7ms)  commit transaction
=> true
>> user.authenticated?(user.remember_token)
=> true
>> 

9.1.3 ユーザーを忘れる

ユーザーがログアウトできるようにするため、ユーザーを記憶するためのメソッドと同様の方法で、
ユーザーを忘れるためのメソッドを定義する。

user.forgetメソッドでuser.remember(永続セッションDB保存)を取り消す。

具体的には記憶ダイジェストをnilにする。

user.rb
  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)                                     # DBにある記憶ダイジェストをnilにする
  end

これで永続セッションを終了させる準備が整ったので、
forgetメソッドを呼び出し、さらにcookiesからuser_id(ユーザーID)とremember_token(記憶トークン)を破棄する。

そしてログアウトするために、現在のユーザー(@current_user)をnilにし、sessionにdeleteメソッドを渡したlog_outメソッドを、sessions_helper.rbに定義する。

sessions_helper.rb
  #永続的セッションを破棄する

  def forget(user)                                                              # ログイン時に送ったuserのIDとパスワードと同一のユーザーを引数として渡す
    user.forget                                                                 # userに対してforgetメソッドを呼び出し、記憶ダイジェストをnilにする
    cookies.delete(:user_id)                                                    # cookiesのuser_idを削除
    cookies.delete(:remember_token)                                             # cookiesのremeber_tokenを削除
  end

  # ユーザーをログアウトする

  def log_out
    forget(current_user)                                                        # 引数として現在のログインユーザーを受け取り、forgetメソッドで記憶ダイジェストを削除
    session.delete(:user_id)                                                    # セッションのuser_idを削除する
    @current_user = nil                                                         # 現在のログインユーザーをnil(空に)する
  end

かなりややこしいけど、とりあえず現在のログインユーザーのクッキー(ユーザーIDと)を削除し、DBにあるユーザーの記憶ダイジェストを削除、最後にログインユーザーを削除する。

これでテストはパスする。

演習

ログアウト後に、ブラウザに対応するcookiesが削除されているか確認

スクリーンショット 2019-01-05 20.52.25.png

ログアウト後

スクリーンショット 2019-01-05 20.53.26.png

記憶トークンが消えて、user_idのcookieが削除されている。

9.1.4 小さなバグ

現在のログアウトメソッドだと、例えば二つのタブを用意して、片方でログアウトした後に、もう片方でログアウトするとエラーとなる。

これは、current_userをnilにした後に、再びlog_outメソッドを実行すると、引数のcurrent_userを受け取れない(nilだから)ため。

  def log_out
    forget(current_user)                                                        # 引数として現在のログインユーザーを受け取り、forgetメソッドで記憶ダイジェストを削除
    session.delete(:user_id)                                                    # セッションのuser_idを削除する
    @current_user = nil                                                         # 現在のログインユーザーをnil(空に)する
  end

この問題を回避するためには、ユーザーがログイン中の場合にのみログアウトさせる必要がある。

さらにもう一つの問題がある。
例えば、ユーザーがFirefoxとChromeでログインしていたとして、Firefoxでログアウトする。
そして、Chromeではロウアウトせずに、ブラウザを終了させ、再度開くとエラーとなってしまう。

この理由として、まずFirefoxでログアウトすると、user.forgetメソッドによってremember_digest(記憶ダイジェスト)がnilとなる。

この時点で、Firefoxでまだアプリが正常に動作しているはずなので、log_outメソッドによってユーザーidが削除される。

user_idが消えたことにより、current_userメソッドのユーザーidの条件式で、どちらもfalseとなる。

  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])                                            # 一時的なセッションユーザーがいる場合処理を行い、user_idに代入
      @current_user ||= User.find_by(id: user_id)                               # 現在のユーザーがいればそのまま、いなければsessionユーザーidと同じidを持つユーザーをDBから探して@current_user(現在のログインユーザー)に代入
    elsif (user_id = cookies.signed[:user_id])                                  # user_idを暗号化した永続的なユーザーがいる(cookiesがある)場合処理を行い、user_idに代入
      user = User.find_by(id: user_id)                                          # 暗号化したユーザーidと同じユーザーidをもつユーザーをDBから探し、userに代入
      if user && user.authenticated?(cookies[:remember_token])                  # DBのユーザーがいるかつ、受け取った記憶トークンをハッシュ化した記憶ダイジェストを持つユーザーがいる場合処理を行��
        log_in user                                                             # 一致したユーザーでログインする
        @current_user = user                                                    # 現在のユーザーに一致したユーザーを設定
      end
    end
  end

結果として、current_userメソッドの最終的な評価結果は、nilとなる。

一方、Chromeを閉じた時、session[:user_id]はnilとなる。
(ブラウザを閉じた時に全てのセッション変数の有効期限が切れるため)

しかし、cookiesはブラウザの中に残り続けているため、Chromeを再起動すると、DBからユーザーを見つけられてしまう。

  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])                                            # 一時的なセッションユーザーがいる場合処理を行い、user_idに代入
      @current_user ||= User.find_by(id: user_id)                               # 現在のユーザーがいればそのまま、いなければsessionユーザーidと同じidを持つユーザーをDBから探して@current_user(現在のログインユーザー)に代入
    elsif (user_id = cookies.signed[:user_id])                                  # user_idを暗号化した永続的なユーザーがいる(cookiesがある)場合処理を行い、user_idに代入
      user = User.find_by(id: user_id)                                          # 暗号化したユーザーidと同じユーザーidをもつユーザーをDBから探し、userに代入
      if user && user.authenticated?(cookies[:remember_token])                  # DBのユーザーがいるかつ、受け取った記憶トークンをハッシュ化した記憶ダイジェストを持つユーザーがいる場合処理を行��
        log_in user                                                             # 一致したユーザーでログインする
        @current_user = user                                                    # 現在のユーザーに一致したユーザーを設定
      end
    end
  end

これの、

    elsif (user_id = cookies.signed[:user_id])                                  # user_idを暗号化した永続的なユーザーがいる(cookiesがある)場合処理を行い、user_idに代入
      user = User.find_by(id: user_id)                                          # 暗号化したユーザーidと同じユーザーidをもつユーザーをDBから探し、userに代入

が評価される。

この時、userがnilであればその時点で処理は終了するものの、user = User.find_by(id: user_id)で実際にuserを定義しているために、2番目の条件式まで進む。

if user && user.authenticated?(cookies[:remember_token])                  # DBのユーザーがいるかつ、受け取った記憶トークンをハッシュ化した記憶ダイジェストを持つユーザーがいる場合処理を行��

ここでエラーが発生する。
原因はFirefoxでログアウトした時に、forgetメソッドでremember_digestが削除してしまっているにもかかわらず、Chromeでアプリにアクセスした際にauthenticatedメソッドの

BCrypt::Password.new(remember_digest).is_password?(remember_token)          # DBの記憶ダイジェストと、受け取った記憶トークンを記憶ダイジェストにした値を比較

を実行してしまうから。

ここで、remember_digestはnilなので、bcryptライブラリ内部でエラーが発生する。

この問題を解決するには、remember_digestが存在しない時にはfalseを返す処理をauthenticated?に追加する必要がある。

まずは、この二つのバグをキャッチするテストを書いていく。

users_login_test.rb
    #ログアウト用
    delete logout_path                                                          # ログアウトリンクが消えたらtrue
    assert_not is_logged_in?                                                    # テストユーザーのセッションが空、ログインしていなければ(ログアウトできたら)true
    assert_redirected_to root_url                                               # Homeへ飛べたらtrue

    #2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
    delete logout_out
    follow_redirect!                                                            # リダイレクト先(root_url)にPOSTリクエストが送信ができたらtrue
    assert_select "a[href=?]", login_path                                       # login_path(/login)がhref=/loginというソースコードで存在していればtrue
    assert_select "a[href=?]", logout_path,      count: 0                       # href="/logout"が存在しなければ(0なら)true
    assert_select "a[href=?]", user_path(@user), count: 0                       # michaelのidを/user/:idとして受け取った値が存在しなければtrue
  end

統合テストにdelete logout_pathを追加し、2回目のログアウトでcurrent_userがないためテストが失敗することを確認。

次にこのテストを成功させるため、destroyメソッドに、ログアウトする時はログインしている時という条件式を追加する。

sessions_controller.rb
  def destroy
    log_out if logged_in?                                                       # ユーザーがログインしていればログアウトする。
    redirect_to root_url                                                        # homeへ移動
  end

これで1番目の問題は解決する。

次に2番目。

これは統合テストでシミュレートするのは困難なので、user_test.rbで実装する。

記憶ダイジェストを持たないユーザーを用意し、authenticated?を呼び出す。
ユーザーにはsetupメソッドにある@userを使う。

user_test.rb
  test "authenticated? should return false for a user with nil digest" do       # authenticatedメソッドで記憶ダイジェストを暗号化できるか検証
    assert_not @user.authenticated?('')                                         # @userのユーザーの記憶ダイジェストと引数で受け取った値が同一ならfalse、異なるならtrueを返す
  end

テストはエラー。

ERROR["test_authenticated?_should_return_false_for_a_user_with_nil_digest", UserTest, 0.038657466997392476]
 test_authenticated?_should_return_false_for_a_user_with_nil_digest#UserTest (0.04s)
BCrypt::Errors::InvalidHash:         BCrypt::Errors::InvalidHash: invalid hash
            app/models/user.rb:41:in `new'
            app/models/user.rb:41:in `authenticated?'
            test/models/user_test.rb:69:in `block in <class:UserTest>'

そもそも@userには記憶ダイジェストが存在しないので、BCrypt::Password.new(nil)なってエラーが発生する。

これをパスするには、
ユーザーモデルの41行目、つまりauthenticated?メソッドで記憶ダイジェストがnilの場合、自動でfalseを返すようにすればいい。

user.rb
  # 引数として受け取った値を記憶トークンに代入して暗号化(記憶ダイジェスト)し、DBにいるユーザーの記憶ダイジェストと比較、同一ならtrueを返す
  def authenticated?(remember_token)
    return false if remember_digest.nil?                                        # 記憶ダイジェストがnilの場合、falseを戻り値として返す
    BCrypt::Password.new(remember_digest).is_password?(remember_token)          # DBの記憶ダイジェストと、受け取った記憶トークンを記憶ダイジェストにした値を比較
  end

ここでは、記憶ダイジェストがnilの場合、returnでfalseを返すことで、即座にメソッドを終了している点に注目

これは、処理を中途で終了する場合によく使われる。

これでテストはパスし、サブタイトルは両方とも修正される。

演習

1:destroyメソッドの条件式をコメントアウトし、2つのログイン済みのタブによるバグを確かめてみる。

スクリーンショット 2019-01-06 0.22.02.png

OK

2:authenticated?メソッドのreturnをコメントアウトし、ログイン済みの二つのブラウザを用意して、片方をログアウト、もう片方のブラウザを閉じて再起動してアプリにアクセスしてみて、バグが発生するか確認する。

なんかSafariでBootstrapが効かずにログアウトひらけなかったのでまた今度・・・

3:コメントアウトした部分を元に戻して、テストがパスするか確認

確認済み。

9.2 Remember me チェックボックス

プロ仕様の完全な認証システムが導入されたが、ユーザーにログイン状態を保持するか選択させるremember meチェックボックスを導入する。

チェックしなかったら一時セッション(session)へ、チェックしたら永続的なセッション(cookies)へ送るのはなんとなくわかるね。

image.png

出典:図 9.3: [remember me] チェックボックスのモックアップ

これを実装するために、まずはログインフォームにチェックボックスを追加するところからはじめる。

チェックボックスは、他のラベル、テキストフィールド、パスワードフィールド、送信ボタン、と同様にヘルパーメソッドで作成できる。

ただし、チェックボックスが正常に動作するためには、ラベルの内側に配置する必要がある。

<%= f.label :remember_me, class: "checkbox inline" do %>
 <%= f.check_box :remember_me %>
 <span>Remember me on this computer</span>
<% end %>

これをnewビューに組み込むと

new.html.erb
      <%= f.label :remember_me, class: "checkbox inline" do %>
        <%= f.check_box :remember_me %>
        <span>ログイン状態を保持する</span>
      <% end %>

ここで、checkboxinlineクラスの二つを使っている点に注目。

これらは、Bootstrapで第一引数はチェックボックスに、第二引数はspanタグの値に適用させる。

このスタイルを整えるため、CSSにルールを追加する。

custrom.scss
.checkbox {
  margin-top: -10px;
  margin-bottom: 10px;
  span {
    margin-left: 20px;
    font-weight: normal;
  }
}

#session_remember_me {
  width: auto;
  margin-left: 0;
}

スクリーンショット 2019-01-06 1.59.31.png

これでログインフォーム用のビューは完成。

あとは、チェックボックスがオンの時にユーザーを記憶し、オフの時には記憶しないように実装する。
この実装はわずか1秒で終わる。そのからくりとして、ログインフォームから送信されたparamsハッシュには、既にチェックボックスの値が含まれているから。

例えば、わざとフォーム送信が失敗する値で送信すると、sessionのremember_meのハッシュにこのような値が含まれている。

session: !ruby/object:ActionController::Parameters
    parameters: !ruby/hash:ActiveSupport::HashWithIndifferentAccess
      email: ''
      password: ''
      remember_me: '0'

これは、チェックボックスがオフ、つまり0という意味を表している。
チェックボックスがオンなら1が記入される。

これがRailsのremember me機能。

受け取り側はparamsハッシュなので、これを利用してチェックボックスがオン(1)の時はremember(user)メソッドを実行し、永続的セッションを保存。
チェックボックスがオフ(0)の時はforget(user)メソッドを実行し、永続的セッションを破棄するよう、sessions_controllerのcreateメソッド(ログイン処理)の条件式で処理する。

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

これを一行で書くために、三項演算子(ternary operator)を使う。

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

このコードを使うことで、コンパクトなコードを実現。

sessions_controller.rb
  def create
    user = User.find_by(email: params[:session][:email].downcase)               # paramsハッシュで受け取ったemail値を小文字化し、email属性に渡してUserモデルから同じemailの値のUserを探して、user変数に代入
    if user && user.authenticate(params[:session][:password])                   # user変数がデータベースに存在し、なおかつparamsハッシュで受け取ったpassword値と、userのemail値が同じ(パスワードとメールアドレスが同じ値であれば)true
      log_in user                                                               # sessions_helperのlog_inメソッドを実行し、sessionメソッドのuser_id(ブラウザに一時cookiesとして保存)にidを送る
      params[:session][:remember_me] == '1' ? remember(user) : forget(user)     # ログイン時、sessionのremember_me属性が1(チェックボックスがオン)ならセッションを永続的に、それ以外なら永続的セッションを破棄する
      redirect_to user                                                          # ログインしたユーザーのページにリダイレクト
    else
      flash.now[:danger] = 'Invalid email/password combination'                 # flashメッセージを表示し、新しいリクエストが発生した時に消す
      render 'new'                                                              # newビューの出力
    end
  end

ちなみに、cost変数にも三項演算子が使われていた。

      cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :  # Userクラスにnew_tokenを渡したクラスメソッドを作成
                                                    BCrypt::Engine.cost         # SecureRandomモジュールにbase64でランダムな文字列を生成

なるほどそういう条件式だったのね。

これでログインシステムが完成。

演習

1:ブラウザでcookies情報を調べ、remember me をチェックした時の結果を確認。

スクリーンショット 2019-01-06 19.59.53.png

オフの時

スクリーンショット 2019-01-06 23.38.33.png

オンの時

2:コンソールで三項演算子を使った実例を考えてみる。

>> user = User.find(1)
>> name = user.name
=> "yuuki"
>> name.blank? ? "空だよ" : "空じゃない"
=> "空じゃない"

Ok

9.3 Remember me のテスト

Remember meのテスト実装には新しいテストのテクニックを覚える必要がある

9.3.1 Remember me ボックスのテストをする。

たとえば次のコードを

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

こういう風にかいてみる。

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

こうすると、paramsの値が存在すればremember_meを返し、存在しなければforgetを返す。

これも1(true)と0(false)を返しているので。
しかし、Rubyの論理値では0も1もtrueとなるので、値は常にtrueになってしまい、チェックボックスは常にオンになっているのと同じ動作になってしまう。

なので、 == '1'と条件を指定する必要がある。

ユーザーが記憶されるにはログインが必要。
つまり、テスト内でユーザーがログインできるようにするために、まずはヘルパーメソッドを定義する。

今まではpostメソッドと有効なsessionハッシュを使ってログインしていたが、毎回指定するのは面倒なので、log_in_asというヘルパーメソッドを作成してテスト用の特別なログインができるようにして、無駄な繰り返しを排除する。

def log_in_as(user)
 session[:user_id] = user.id
end

これを、ActiveSupport::TestCaseクラス内で定義してみる。

class ActiveSupport::TestCase
 fixtures :a: 

 # テストユーザーがログイン中の場合にtrueを返す
 def is_logged_in?
  !session[:user_id].nil?
 end

 # テストユーザーとしてログインする
 def log_in_as(user)
   session[:user_id] = user.id
 end
end

統合テストでも同じヘルパーを実装していく。

ただし、統合テストではsessionを直接取り扱うことができないので、代わりにSessionsリソースに対してpostを送信することで代用する。

メソッド名は単体テストと同じ、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

統合テストで扱うヘルパーなのでActionDispatch::IntegrationTestクラスの中で定義する。

これにより、単体テストか統合テストかを意識せずに、ログイン済み状態をテストする時はlog_in_asメソッドを呼び出せる。

ここで、この二つのlog_in_asヘルパーをtest_helperにまとめておく。

test_helper.rb
ENV['RAILS_ENV'] ||= 'test'
require File.expand_path('../../config/environment', __FILE__)
require 'rails/test_help'
require "minitest/reporters"
Minitest::Reporters.use!

class ActiveSupport::TestCase
  fixtures :all
  include ApplicationHelper
  # 単体テスト用

  # テストユーザーがログイン中の場合にtrueを返す
  def is_logged_in?
    !session[:user_id].nil?                                                     # セッションが空ならfalse、空じゃない(ログインしていれば)true
  end

  # テストユーザーとしてログインする
  def log_in_as(user)
    session[:user_id] = user.id
  end
end

class ActionSupport::IntegrationTest
  # 統合テスト用

  # テストユーザーとしてログインする
  def log_in_as(user, password: 'password', remember_me: '1')                   # ログイン時のユーザーとして、チェックボックスにチェックを入れてる(1)
    post login_path, params: { session: { email: user.email,                    # /login に対してparamsとしてsessionハッシュに各属性の値が入れて送信
                                          password: password,
                                          remember_me: remember_me } }
  end
end

ここでremember me チェックボックスの動作確認のため、2つのテストを作成。

チェックボックスがオンになっている場合とオフになっている場合のテストを書く。

log_in_as(@user, remember_me '1')

オンに対するテスト

log_in_as(@user, remember_me: '0')

オフに対するテスト

remember_meのデフォルト値は1なので、remember_me '1'は省略しても良い。

users_login_test.rb

  def setup                                                                     # テスト用にレイアウトで使えるユーザーを定義
    @user = users(:michael)                                                     # fixtureで定義したmichaelのデータ(レイアウト有効ユーザー)をusersで受け取り、@userに代入
  end

  test "login with remembering" do                                              # ログイン時に記憶トークンがcookiesに保存されているか検証
    log_in_as(@user, remember_me: '1')                                          # michaelが有効な値でログインできて、なおかつチェックマーク付けていればtrue
    assert_not_empty cookies['remember_token']                                  # 記憶トークンが空でなければtrue
  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

演習

1:assignsという特殊なテストメソッドを使うと仮想のremember_token属性にアクセスできるようになる。
コントローラで定義したインスタンス変数にアクセスするには、テスト内部でassignsメソッドを使う。
Sessionsコントローラで定義されているuser変数はローカル変数だが、これをインスタンス変数に変えてしまえば、cookiesにユーザー記憶トークンが正しく含まれているかどうかテストできるようになる。

このアイデアにしたがって、チェックボックスのテストを改良してみる。

sessions_controller.rb
  def create
    @user = User.find_by(email: params[:session][:email].downcase)              # paramsハッシュで受け取ったemail値を小文字化し、email属性に渡してUserモデルから同じemailの値のUserを探して、user変数に代入
    if @user && @user.authenticate(params[:session][:password])                 # user変数がデータベースに存在し、なおかつparamsハッシュで受け取ったpassword値と、userのemail値が同じ(パスワードとメールアドレスが同じ値であれば)true
      log_in @user                                                              # sessions_helperのlog_inメソッドを実行し、sessionメソッドのuser_id(ブラウザに一時cookiesとして保存)にidを送る
      params[:session][:remember_me] == '1' ? remember(@user) : forget(@user)   # ログイン時、sessionのremember_me属性が1(チェックボックスがオン)ならセッションを永続的に、それ以外なら永続的セッションを破棄する
      redirect_to @user                                                          # ログインしたユーザーのページにリダイレクト
    else
      flash.now[:danger] = 'Invalid email/password combination'                 # flashメッセージを表示し、新しいリクエストが発生した時に消す
      render 'new'                                                              # newビューの出力
    end
  end

まずはcreateアクション内のuserをインスタンス変数@userに変更する。

sessions_controller.rb
  test "login with remembering" do                                              # ログイン時に記憶トークンがcookiesに保存されているか検証
    log_in_as(@user, remember_me: '1')                                          # michaelが有効な値でログインできて、なおかつチェックマーク付けていればtrue
    assert_equal cookies['remember_token'], assigns(:user).remember_token       # 記憶トークンが空でなければtrue
  end

次にcookiesの記憶トークンと、コントローラで定義したインスタンス変数にテストの内部からアクセスするために、assignsメソッドを使って、インスタンス変数の記憶トークンの値が同一かどうかテストする。

4 tests, 19 assertions, 0 failures, 0 errors, 0 skips

同じなのでテストはパスする。

9.3.2 Remember me をテストする

current_user内のある複雑な分岐処理については、全くテストが行われていない。

このような時の対処方法は、テストを忘れている疑いのあるコードブロック内にわざと例外発生を仕込む。

例えば、そのコードブロックがテストから漏れていれば、例外が発生してテストは中断するはず。

ということで、実際にいれてみる。

sessions_helper.rb
  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])                                            # 一時的なセッションユーザーがいる場合処理を行い、user_idに代入
      @current_user ||= User.find_by(id: user_id)                               # 現在のユーザーがいればそのまま、いなければsessionユーザーidと同じidを持つユーザーをDBから探して@current_user(現在のログインユーザー)に代入
    elsif (user_id = cookies.signed[:user_id])                                  # user_idを暗号化した永続的なユーザーがいる(cookiesがある)場合処理を行い、user_idに代入
      raise                                                                     # テストがパスすれば、この部分がテストされていないことがわかる
      user = User.find_by(id: user_id)                                          # 暗号化したユーザーidと同じユーザーidをもつユーザーをDBから探し、userに代入
      if user && user.authenticated?(cookies[:remember_token])                  # DBのユーザーがいるかつ、受け取った記憶トークンをハッシュ化した記憶ダイジェストを持つユーザーがいる場合処理を行う
        log_in user                                                             # 一致したユーザーでログインする
        @current_user = user                                                    # 現在のユーザーに一致したユーザーを設定
      end
    end
  end

この段階でテストすると

7 tests, 35 assertions, 0 failures, 0 errors, 0 skips

テストはパスする。

上記のコードが正常でないことがわかった以上、これは問題。
さらに、current_userをリファクタリングするのであれば、同時にテストを作成しておくことも重要。
(コレは後の章でやる)

単体テスト(test_helper.rb)では、log_in_asヘルパーメソッドでは、session[:user_id]と定義してしまっているので、このままではcurrent_userメソッドが抱えている複雑な分岐処理を統合テストでチェックすることが非常に困難。

なので、Sessionsヘルパーのテストでcurrent_userを直接テストすれば、この制約を突破することができる。

とりあえず直接テストできるsessions_helper_test.rbを作成。

$ touch test/helpers/sessions_helper_test.rb

①fixtureでuser変数を定義する
②渡されたユーザーをrememberメソッドで記憶する
③current_userが、渡されたユーザーと同じであることを確認する。

このrememberメソッドではsession[:user_id]が設定されていないので、コレで問題となっている複雑な分岐処理もテストできるようになる。

sessions_helper_test.rb
require 'test_helper'

class SessionsHelperTest < ActionView::TestCase

    # 永続的セッションのテスト

    def setup
      @user = users(:michael)                                                   # fixtureにあるmichaelをユーザーとして定義
      remember(@user)                                                           # ユーザーをrememberの引数として受け取って記憶する
    end

    test"current_user returns right user when sessions is nil" do
      assert_equal @user, current_user                                          # current_user(現在のログインユーザー)とmichaelが同じかどうかテスト
      assert is_logged_in?                                                      # テストユーザーがログイン中ならtrueを返す、何らかの理由でログイン失敗したらfalse
    end

    test "current_user returns nil when remember digest is wrong" do
      @user.update_attribute(:remember_digest, User.digest(User.new_token))     # @userの記憶ダイジェストが、ハッシュ化した記憶トークンを暗号化した値と同じなら、記憶ダイジェストを更新する
      assert_nil current_user                                                   # 現在のユーザーがnilならtrue(@userが更新できない場合、現在のユーザーがnilになるかどうか検証)
    end
end

この時の3番目のテストによって

if user && user.authenticated?(cookies[:remember_token])

コレがネストされている。

また、

assert_equal current_user, @user

このように書いても起動するが、assert_equalの引数は期待する値,実際の値の順序で書いている点に注意。

assert_equal <expected>, <actual>

この原則にしたがって

assert_equal @user, current_user

コレだと期待通りテストが失敗する。

きちんとテストされていることを確認できたので、raiseを消して元に戻す。

sessions_helper.rb
  # 記憶トークンcookieに対応するユーザーを返す
  def current_user
    if (user_id = session[:user_id])                                            # 一時的なセッションユーザーがいる場合処理を行い、user_idに代入
      @current_user ||= User.find_by(id: user_id)                               # 現在のユーザーがいればそのまま、いなければsessionユーザーidと同じidを持つユーザーをDBから探して@current_user(現在のログインユーザー)に代入
    elsif (user_id = cookies.signed[:user_id])                                  # user_idを暗号化した永続的なユーザーがいる(cookiesがある)場合処理を行い、user_idに代入
      user = User.find_by(id: user_id)                                          # 暗号化したユーザーidと同じユーザーidをもつユーザーをDBから探し、userに代入
      if user && user.authenticated?(cookies[:remember_token])                  # DBのユーザーがいるかつ、受け取った記憶トークンをハッシュ化した記憶ダイジェストを持つユーザーがいる場合処理を行う
        log_in user                                                             # 一致したユーザーでログインする
        @current_user = user                                                    # 現在のユーザーに一致したユーザーを設定
      end
    end
  end

これでテストが通る。

9 tests, 38 assertions, 0 failures, 0 errors, 0 skips

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

演習

1:current_userのauthenticated?を消して、sessions_helper_testの二つ目のテストで失敗することを確認。

 FAIL["test_current_user_returns_nil_when_remember_digest_is_wrong", SessionsHelperTest, 0.07657214600112638]
 test_current_user_returns_nil_when_remember_digest_is_wrong#SessionsHelperTest (0.08s)
        Expected #<User id: 762146111, name: "Michael Example", email: "michael@example.com", created_at: "2019-01-09 10:46:29", updated_at: "2019-01-09 10:46:29", password_digest: "$2a$04$P4ISyWK63RNwm5g9ByPqQO/szWF5p81S3GB2aXnrx3D...", remember_digest: "$2a$04$aqpPsot2..8Vw30D4WFb4u6xAJTUyInLyoCJNdsQPza..."> to be nil.
        test/helpers/sessions_helper_test.rb:19:in `block in <class:SessionsHelperTest>'

 FAIL["test_current_user_returns_right_user_when_sessions_is_nil", SessionsHelperTest, 0.09185708699988027]
 test_current_user_returns_right_user_when_sessions_is_nil#SessionsHelperTest (0.09s)
        Expected false to be truthy.
        test/helpers/sessions_helper_test.rb:14:in `block in <class:SessionsHelperTest>'

ログイン失敗したのでfalseが返ってきた(テストが失敗した)のでOK

 9.4 最後に

いつも通り、本章の変更をmasterブランチにマージ

rails t
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

このようにデプロイとマイグレーションを行うと、一時的にアクセスできない状態の間に標準のメンテナンスページを表示することができる。

ちなみに,BootStrapが起動しない場合はassetsフォルダ内のファイルをコンパイル(最適化)する。

$ rake assets:precompile

これで、navメニューが正しく使える。

第10章へ

単語集

  • トークン(token)

パスワードみたいなものだが、パスワードと違って、ユーザーが作成せずコンピュータなどが情報を生成する。
クッキー内情報やリンク文字列として利用される。
要はコンピュータが生成したパスワード

  • 記憶トークン

有効期限が設定されたトークン。
cookiesの中に保存する

  • ダイジェスト

DBに保存するパスワードみたいな情報。トークンから生成する。

  • セッションハイジャック

記憶トークンを奪って、特定のユーザーになりますしてログインし、情報を盗む攻撃。

  • クロスサイトスクリプティング

通称XSS。ユーザーが閲覧したサイト内に悪意のあるスクリプトを埋め込み、強制実行させて悪意ある別サイトに転送してユーザーの情報を盗む行為。

  • self

指定したオブジェクトのインスタンス自身を指すメソッド。単体で使用した場合は、「クラス」自身を指す

  • signed

デジタル署名と暗号化をまとめて行えるRailsのメソッド。

  • authenticated?

引数に渡したパラメータを暗号化し、password_digestの値と一致したらtrueを返すメソッド。

使い方

@user.authenticate?("test")  #@userとtestが同じ値ならtrueを返す
  • assigns

コントローラ内で定義されたインスタンス変数(@変数名)にテスト内部からアクセスするメソッド

  • raise

明示的にエラーを発生させ、処理を中断させたい時に使う。

  • assert_nil

nilかどうか検証

34
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
34
16