0
0

More than 3 years have passed since last update.

Railsチュートリアル9章まとめ

Posted at

この章でやること

  • よくあるチェックボックスをチェックするとずっとログイン状態を保持できる機能(remember me)を実装する

9.1 Remember me 機能

ユーザーのログイン状態をブラウザを閉じた後でも有効にする[remember me]機能を実装する
この機能を使うと、ユーザーがログアウトをしない限り、ログイン状態を維持することができる
また後半では、この機能を使うかどうかをユーザーに決めてもらうため、[remember me]のチェックボックスをログインフォームに追加する

まずはトピックブランチを作成し、そこで作業をする

$ git checkout -b advanced-login

9.1.1 記憶トークンと暗号化

8章でsessionメソッドを使ってユーザーIDを保存したが、ブラウザを閉じると消えていた。
9章では、セッションの永続化させるため、記憶トークン(remember token)を生成し、cookiesメソッドによる永続的cookiesの作成や、安全性の高い記憶ダイジェスト(remember digest)によるトークン認証にこの記憶トークンを活用する

安全性についてもsessionメソッドで保存した情報は自動的に安全が保たれるが、cookiesメソッドに保存する情報は安全性が保たれていない。
そのため、cookiesを永続化するとセッションハイジャックという攻撃を受ける可能性がある。
この攻撃は、記憶トークンを奪って、特定のユーザーになりすましてログインするというもの。
cookiesを盗み出す方法は4通りあり
(1)管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す
(2)データベースから記憶トークンを取り出す
(3)クロスサイトスクリプティング(XSS)を使う
(4)ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る。

(1)は既にSecure Sockets Layer(SSL)をサイト全体に適用して、ネットワークデータを暗号化で保護し、パケットスニッファから読み取られないようにしている。
(2)は、記憶トークンをそのままデータベースに保存するのではなく、記憶トークンのハッシュ値を保存するようにする
(3)は、Railsによって自動的に対策が行われる。→ビューのテンプレートで入力した内容をすべて自動的にエスケープ。
(4)は、さすがにシステム側での根本的な防衛手段を講じることは不可能。しかし二次被害を最小限に留めることは可能。具体的には、ユーザーが(別端末などで)ログアウトしたときにトークンを必ず変更するようにし、セキュリティ上重要になる可能性のある情報を表示するときはデジタル署名(digital signature)を行うようにする

以上の考慮事項を元に、次の方針で永続的セッションを作成

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

最初に、必要となるremember_digest属性をUserモデルに追加

$ rails generate migration add_remember_digest_to_users remember_digest:string

_to_usersでマイグレーションの対象がデータベースのusersテーブルであることをRailsに指示

今回はマイグレーションファイルをいじる必要がないため、そのままマイグレート

$ rails db:migrate

次は、記憶トークンとして何を使うかを決める。いろいろあるらしいが、基本的には長くてランダムな文字列であればどんなものでもOK
ここでは、Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドを使う
このメソッドは、A–Z、a–z、0–9、"-"、"_"のいずれかの文字(64種類)からなる長さ22のランダムな文字列を返します(64種類なのでbase64と呼ばれる

$ rails console
>> SecureRandom.urlsafe_base64
=> "brl_446-8bqHv87AQzUj_Q"

同一のパスワードを持つユーザーが複数いても問題ないのと同様に、同一の記憶トークンを持つユーザーが複数いても問題なし。
一方で、セッションハイジャックのリスクなどを考えると、トークンは一意である方がより安全ではある
また、先ほどのbase64の文字列では、64種類の文字からなる長さ22の文字列なので、2つの記憶トークンがたまたま完全に一致する(=衝突する)確率はめっちゃ低い(ないとは言えない)

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

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

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: true
  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メソッドを作成すること。
このメソッドは記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをデータベースに保存する。
Userモデルには既にremember_digest属性が追加したが、attr_accessorを使って「仮想の」属性を使って、トークンにアクセスできるようにし、かつ、トークンをデータベースに保存せずに実装する

class User < ApplicationRecord
  attr_accessor :remember_token  #アクセサーで仮想の属性remember_tokenを定義
  .
  .
  .
  def remember
    self.remember_token = ...     #remember_tokenに何かを入れる
    update_attribute(:remember_digest, ...)  #remember_digestカラムの値を書き換える
  end
end

selfを付けないと新たに変数が定義されてしまう。(サクセサーをつけた意味がなくなる)

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

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

実際にuser.rbに記載していく
User.new_tokenで記憶トークンを作成 → User.digestを適用した結果で記憶ダイジェストを更新

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: true
  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    #remember_tokenにランダムトークンを代入
    update_attribute(:remember_digest, User.digest(remember_token))   #remember_digestカラムにremember_tokenをハッシュ化した文字列にアップデート
  end
end

演習

コンソールを開き、データベースにある最初のユーザーを変数userに代入してください。その後、そのuserオブジェクトからrememberメソッドがうまく動くかどうか確認してみましょう。また、remember_tokenとremember_digestの違いも確認してみてください。

irb(main):001:0> user=User.first
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", crea...
irb(main):002:0> user.remember
   (0.1ms)  begin transaction
  User Update (0.6ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2021-02-22 04:38:33.341726"], ["remember_digest", "$2a$12$t9226by7MDWKc6IMxckOq.qDHJ3IqpYgtYq5SXG9GsnNEBgGqiIUS"], ["id", 1]]
   (1.8ms)  commit transaction
=> true
irb(main):003:0> user.remember_token
=> "iiwsuQBoNBEAN0Q1TEo9SA"
irb(main):004:0> user.remember_digest
=> "$2a$12$t9226by7MDWKc6IMxckOq.qDHJ3IqpYgtYq5SXG9GsnNEBgGqiIUS"

remember_tokenは、base64によって長さ22の文字列
remember_digestは、BCryptによって長さ60の文字列

リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、ややわかりにくいリスト 9.4の実装でも、非常に混乱しやすいリスト 9.5の実装でも、いずれも正しく動くことを確認してみてください。ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。

→9.4はテストがパスしたが、9.5はエラーになった...

9.1.2 ログイン状態の保持

cookieメソッドを使ってユーザーの暗号化済みIDと記憶トークンをブラウザの永続cookiesに保存していく
cookieメソッドはsessionのときと同様にハッシュとして扱え、1つのvalue(値)と、オプションのexpires(有効期限)からできている
有効期限は省略可能。例えば、20年後に期限切れになる記憶トークンと同じ値をcookieに保存することで、永続的なセッションを作ることができるようになる

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

上のような20年で期限切れになるcookies設定はよく使われるため、permanentという専用メソッドが使える

cookies.permanent[:remember_token] = remember_token

コラム 9.1. cookiesは今から20年後に切れる(20.years.from_now)

timeヘルパーは結構使えるよ。って話 timeヘルパーは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

メソッドを組み込みクラスに追加できる柔軟性の高さのおかげで、Rubyを拡張することができる

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

cookies[:user_id] = user.id  
#sessionの場合はsession[:user_id]=user.id だった

しかしこのままではIDが生のテキストとしてcookiesに保存されてしまうので、署名付きcookieを使う
署名付きクッキーはcookieをブラウザに保存する前に安全に暗号化するためのもの

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と一致することを確認する。

どうやって一致しているのを確認するのか?

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モデルの中に置く。

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: true
  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)  #アクセサーで定義したremember_tokenと異なる
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
end

remember_digestの属性はデータベースのカラムに対応して、Active Recordによって取得したり保存したりできる

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

app/controllers/sessions_controller.rb
class SessionsController < ApplicationController

  def new
  end

  def create
    user = User.find_by(email: params[:session][:email].downcase)
    if 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のときと同様に、実際のSessionsヘルパーの動作は、rememberメソッド定義のuser.rememberを呼び出すまで遅延され、そこで記憶トークンを生成してトークンのダイジェストをデータベースに保存します。

続いて同様に、cookiesメソッドでユーザーIDと記憶トークンの永続cookiesを作成

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end

  # ユーザーのセッションを永続的にする
  def remember(user)
    user.remember #remember_digestをデータベースに記憶
    cookies.permanent.signed[:user_id] = user.id   #cookiesに署名つきクッキー20年有効を入れる
    cookies.permanent[:remember_token] = user.remember_token  #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

上の@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を使っている点に注目。
このコードでも動作するが、今のままではsessionメソッドもcookiesメソッドもそれぞれ2回ずつ使われてしまい、無駄

if (user_id = session[:user_id]) #user_idにuser_idのセッションを代入した結果)ユーザーIDのセッションが存在すれば
  @current_user ||= User.find_by(id: user_id) #@current_userに代入か同じユーザーを探す
elsif (user_id = cookies.signed[:user_id]) #user_idに署名付きcookiesを代入した結果)ユーザー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のセッションが存在すれば」となる。

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])   #user_idにuser_idのセッションを代入した結果)ユーザーIDのセッションが存在すれば
      @current_user ||= User.find_by(id: user_id) #@current_userに代入か同じユーザーを探す
    elsif (user_id = cookies.signed[:user_id]) #user_idに署名付きcookiesを代入した結果)ユーザーIDのクッキーが存在すれば
      user = User.find_by(id: user_id)#userにuser_idカラムのユーザを代入
      if user && user.authenticated?(cookies[:remember_token]) #もしuserのdigestとremember_tokenのcookieが一致すれば
        log_in user    #userはログインする
        @current_user = 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

あとはユーザーがログアウトできるようにすればOKだが、まだテストはパスしない

演習

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

irb(main):001:0> user=User.firstheck_safe_obj will be removed in Ruby 3.0
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2021-02-17 05:06:38", u...
irb(main):002:0> user.remember
(0.1ms) begin transaction
User Update (0.6ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", "2021-02-22 06:36:34.871294"], ["remember_digest", "$2a$12$KamY0wKcN1dsmA7C/ad5i.e7HvBkisLdU1fiVX8/7ur68LougSOEa"], ["id", 1]]
(2.0ms) commit transaction
=> true
irb(main):004:0> user.authenticated?(user.remember_token) #引数をセットしないとNG
=> true

9.1.3 ユーザーを忘れる

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

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: true
  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) #remember_digestカラムをnilにする
  end
end

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

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

app/helpers/sessions_helper.rb
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 永続的セッションを破棄する
  def forget(user)
    user.forget   #user.rbで定義した機能 digestカラムをnilにする
    cookies.delete(:user_id)
    cookies.delete(:remember_token)
  end

  # 現在のユーザーをログアウトする
  def log_out
    forget(current_user) #上で定義した永続セッションを破棄する機能
    session.delete(:user_id)
    @current_user = nil
  end
end

これでtestがパスする

演習

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

9.1.4 2つの目立たないバグ

実は小さなバグが2つ残っていて、この2つのバグは互いに強く関連

  1. ユーザーは場合によっては、同じサイトを複数のタブ(あるいはウィンドウ)で開いていることもある。ログアウト用リンクはログイン中のみ表示されるが、今のcurrent_userの使い方では、ユーザーが1つのタブでログアウトし、もう1つのタブで再度ログアウトしようとするとエラーになってしまいます。これは、もう1つのタブで "Log out" リンクをクリックすると、current_userがnilとなってしまうため、log_outメソッド内のforget(current_user)が失敗してしまうから。この問題を回避するためには、ユーザーがログイン中の場合にのみログアウトさせる必要がある
  2. 例えば、ユーザーがFirefoxとChromeでログインしていたとして、Firefoxでログアウトする。そして、Chromeではログアウトせずに、ブラウザを終了させ、再度開くとエラーとなってしまう。 この理由として、まずFirefoxでログアウトすると、user.forgetメソッドによってremember_digest(記憶ダイジェスト)がnilとなる。

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

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

def current_user
    if (user_id = session[:user_id])   #nilになる
      @current_user ||= User.find_by(id: user_id) #@current_userに代入か同じユーザーを探す
    elsif (user_id = cookies.signed[:user_id]) #nilになる
      user = User.find_by(id: user_id)#userにuser_idカラムのユーザを代入
      if user && user.authenticated?(cookies[:remember_token]) #もしuserのdigestとremember_tokenのcookieが一致すれば
        log_in user    #userはログインする
        @current_user = user   #@current_userにuserを代入する
      end
    end
  end

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

一方、Chromeを閉じたとき、session[:user_id]はnilになる
(これはブラウザが閉じたときに、全てのセッション変数の有効期限が切れるため)。しかし、cookiesはブラウザの中に残り続けているため、Chromeを再起動してサンプルアプリケーションにアクセスすると、データベースからそのユーザーを見つけることができてしまう。

def current_user
    if (user_id = session[:user_id])   #nilになる
      @current_user ||= User.find_by(id: user_id) #@current_userに代入か同じユーザーを探す
    elsif (user_id = cookies.signed[:user_id]) #nilになる 
      user = User.find_by(id: user_id)#userにuser_idカラムのユーザを代入 探せてしまう
      if user && user.authenticated?(cookies[:remember_token]) #クッキーは残っているため探せてしまう
        log_in user    #userはログインする
        @current_user = user   #@current_userにuserを代入する
      end
    end
  end

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

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

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

テスト駆動開発は、この種の地味なバグ修正にはうってつけなので、テストを書いていく

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   #1回目のログアウト
    assert_not is_logged_in?
    assert_redirected_to root_url
    # 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
    delete logout_path  #ここでredになる
    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

current_userがないために2回目のdelete logout_pathの呼び出しでエラーが発生し、テストは red

パスさせるために、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モデルで直接テストするだけなら簡単に行えるので、
記憶ダイジェストを持たないユーザーを用意し、続いてauthenticated?を呼び出す

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

このテストを green にするためには、記憶ダイジェストがnilの場合にfalseを返すようにすればOK

app/models/user.rb
class User < ApplicationRecord
  .
  .
  .
  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    return false if remember_digest.nil?   remember_digestnilならfalseを返し、終了
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end
end

returnは処理を中途で終了する場合によく使われるテクニック

これでテストはパスする

演習

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

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

裏側のremember_meの仕組みはできたので、ビューに[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_with(url: login_path, scope: :session, local: true) 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 %>
      <%# remember_meのチェックボックス %>
        <%= 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>

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;
}

チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにしていく
先ほどセットした
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
で送信されるparamsハッシュにチェックボックスの値を含んだ

チェックボックスではparams[:session][:remember_me]の値が
チェックボックスがオンのときに'1'になり、オフのときに'0'になる

つまり、以下のコードをかけば、OK

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

ここで「三項演算子(ternary operator)」を紹介。
if-thenの分岐構造を1行で表すことができる。

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

このコードでsession_controllerに記載

app/controllers/sessions_controller.rb
...
  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)
#remember_meが1ならremember(user)を、それ以外ならforgetメソッドを起動
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end
...

end

三項演算子について

論理値? ? 何かをする : 別のことをする で書くことができる
例えば、次のような代入文を三項演算子で置き換えるこ場合

  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"をそれぞれ返すメソッドになる

演習

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

irb(main):001:0> user=User.first
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2021-02-17 05:06:38", u...
irb(main):002:0> user.name
=> "Rails Tutorial"
irb(main):003:0> user.name.nil? ? "nil":"not nil"
=> "not nil"

9.3 [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[:session][:remember_me]の値は’0’または’1’のいずれかになる。
0も1もRubyの論理値ではtrueである。したがって、値は常にtrueになってしまう

こう言った点を見逃さないようにテストが必要

まずはテスト内でユーザーがログインできるようにするためのヘルパーメソッドを定義する。

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

[remember me]チェックボックスの動作を確認するため、2つのテストを作成。
チェックボックスがオンになっている場合とオフになっている場合のテスト

オンの場合
log_in_as(@user, remember_me: '1')
オフの場合はこのようになります。
log_in_as(@user, remember_me: '0')

test/integration/users_login_test.rb
require 'test_helper'

class UsersLoginTest < ActionDispatch::IntegrationTest

  def setup
    @user = users(:michael)
  end
  .
  .
  .
  test "login with remembering" do  #remember_meを1でログイン
    log_in_as(@user, remember_me: '1')
    assert_not_empty cookies[:remember_token] #cookieが空でないか
  end

  test "login without remembering" do 
    # cookieを保存してログイン
    log_in_as(@user, remember_me: '1')
    delete logout_path
    # cookieを削除してログイン
    log_in_as(@user, remember_me: '0')
    assert_empty cookies[:remember_token]#cookieが空になっているか
  end
end

テストはパスする

演習

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

sessions_controller.rb
def create
    @user = User.find_by(email: params[:session][:email].downcase)
    if @user &.authenticate(params[:session][:password])
      log_in @user
      params[:session][:remember_me] == '1' ? remember(@user) : forget(@user)
      redirect_to @user
    else
      flash.now[:danger] = 'メアドかパスワードが間違っています'
      render 'new'
    end
  end
users_login_test.rb
test "login with remembering" do
    log_in_as(@user, remember_me: '1')
    assert_equal cookies[:remember_token], assigns(:user).remember_token
    assert_not_empty cookies[:remember_token]
  end

9.3.2 [Remember me]をテストする

実は、current_user内のある複雑な分岐処理については、これまでまったくテストが行われていない。
このようなとき、テストを忘れている疑いのあるコードブロック内にわざと例外発生を仕込むというテクニックが使える
コードブロックがテストから漏れていれば、テストはパスしてしまう

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 になる

greenになってしまうことが問題

さらに、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

テスト手順

  1. fixtureでuser変数を定義する
  2. 渡されたユーザーをrememberメソッドで記憶する
  3. current_userが、渡されたユーザーと同じであることを確認
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
    #ユーザーの記憶ダイジェストが記憶トークンと正しく対応していない場合に現在のユーザーがnilになるか
  end
end

最後のテストでは、ユーザーの記憶ダイジェストが記憶トークンと正しく対応していない場合に現在のユーザーがnilになるかどうかをチェックしている。
これによって、次の式をテストしている

sessions_helper.rb
if user && user.authenticated?(cookies[:remember_token])
#userが存在かつ、cookiesのremember_tokenとdigestが一致した場合

そして以下の文

assert_equal @user, current_user #同じか比べる
assert_equal current_user, @user

実際、どちらでも動作する。
ただassert_equalの引数は「期待する値, 実際の値」の順序で書く原則なので、
assert_equal <expected>, <actual>
そう書いている

これでテストはredになるはず

current_userメソッドに仕込んだraiseを削除

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

演習

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

まとめ

remember_meの機能について作成した
記憶トークンと暗号化をcookiesで20年間保持し、logoutで削除もできるようにした

0
0
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
0
0