3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Railsチュートリアル 第9章 発展的なログイン機構 - Remember me 機能

Posted at

前提知識

remember me 機能

「ユーザーのログイン情報を記憶し、ブラウザを再起動した後でも、ログイン情報を改めて入力する必要なく直ちにログイン後のユーザー情報にアクセスできるようにする」という機能です。

永続クッキー

英語では「permanent cookies」と呼ばれます。Webアプリケーション側が指定した期間にわたり、ユーザーの端末上に保存されるようにしたcookiesのことを指します。ブラウザプロセスの終了により破棄される「セッションクッキー(session cookies)」に対する概念です。

記憶トークンと暗号化

この項で行うこと

記憶トークン(remember token)を作成します。これから実装する以下のような機能に向けての第一ステップです。

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

セッションハイジャック

永続的cookiesと表裏一体で存在する攻撃手法として、「セッションハイジャック」という手法があります。「記憶トークンを奪って、特定のユーザーになりすましてログインする」という攻撃手法です。このような攻撃を実現するためには、以下のような手法が用いられます。

  • パケットスニッファソフトにより直接cookieを取り出す
    • ネットワークもしくはアプリケーションの管理が甘く、ネットワークパケットを直接抜き取れるようなアプリケーションであることが前提
  • データベースから記憶トークンを取り出す
  • クロスサイトスクリプティングを使う
  • ユーザーが使用している端末を直接操作してアクセスを奪い取る

セッションハイジャックへの対策

「ネットワークパケットを直接抜き取る」という攻撃手法への対策

「サイト全体で、エンドツーエンドの通信に対してSSLを適用する」という対策が代表的です。エンドツーエンドで通信が暗号化されていれば、ネットワークパケットを直接抜き取っても通信内容の機密は保たれます。config/environments/production.rbに対する以下の設定内容は、当該対策に関わるものですね。

config/environments/production.rb
config.force_ssl = true

「データベースから記憶トークンを取り出す」という攻撃手法への対策

「DBMSにおいて、生の記憶トークンではなく、記憶トークンのハッシュ値を保存するようにする」という対策が代表的です。当サンプルアプリケーションの過去の実装において、Userモデルのパスワードに対して同様の実装を行いましたよね。関連するメソッドとしては以下のようなものがあります。

has_secure_password
authenticate

「クロスサイトスクリプティングを使う」という攻撃手法への対策

こちらはRailsそのものに対策の仕組みが実装されています。具体的には、「ビューのテンプレートで入力した内容を自動的にエスケープする」というものです。

「ユーザーが使用している端末を直接操作してアクセスを奪い取る」という攻撃手法への対策

さすがに攻撃そのものに対してWebアプリケーション側で取れる根本的対策はありません。しかしながら、被害を最小限に留める仕組みはあります。以下のようなものです。

  • ユーザーが(別端末などで)ログアウトしたときに必ずトークンを変更するようにする
  • セキュリティ上重要になる可能性のある情報を表示するときはデジタル署名を行う

永続的セッションの実装はどのような方針をとるのか

セキュリティ上考慮するべき点を踏まえ、永続的セッションの実装は以下の方針をとることとします。

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

remember_digest属性と、Userモデルの新たな設計

トークンはハッシュ値に変換してからRDBに保存する

上記の実装方針を踏まえ、「トークンのハッシュ値を保存するための属性」がRDBに必要となります。remember_digestという属性名で当該属性を定義することとします。remember_digest属性追加後のUserモデルの定義は以下の通りになります。

User_full.png

Userモデルの変更を、実際にRDBに反映します。使用するのはrails generate migrationコマンドですね。

# rails generate migration add_remember_digest_to_users remember_digest:string
Running via Spring preloader in process 438
      invoke  active_record
      create    db/migrate/[timestamp]_add_remember_digest_to_users.rb

マイグレーションの実体であるdb/migrate/[timestamp]_add_remember_digest_to_users.rbの内容は以下の通りです。

db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[5.1]
  def change
    add_column :users, :remember_digest, :string
  end
end

password_digestと同様、remember_digestもユーザーが直接読み出すことはありません(かつ、そうさせてはならない属性です)。そのため、インデックスを生成する必要もありません。生成されたマイグレーションには手を加えず、そのまま使います。

# rails db:migrate
== [timestamp] AddRememberDigestToUsers: migrating =========================
-- add_column(:users, :remember_digest, :string)
   -> 0.0164s
== [timestamp] AddRememberDigestToUsers: migrated (0.0169s) ================

改めてpassword_digest属性の実装を振り返る

「トークンのハッシュ値を保存するための属性」といえば、過去に実装した「パスワードのハッシュ値を保存するための属性」と似ています。先ほどのUserモデルの定義における、password_digest属性がそれです。このとき使ったコマンドは以下でした。

rails generate migration add_password_digest_to_users password_digest:string

マイグレーションの実体であるdb/migrate/[timestamp]_add_password_digest_to_users.rbの内容は以下の通りでしたね。

db/migrate/[timestamp]_add_password_digest_to_users.rb
class AddPasswordDigestToUsers < ActiveRecord::Migration[5.1]
  def change
    add_column :users, :password_digest, :string
  end
end

このとき生成されたマイグレーションに手を加えなかったのも同じです。

rails db:migrate

記憶トークンとしてSecureRandom.urlsafe_base64メソッドの結果を使う

記憶トークンとして用いる文字列に求められる性質

記憶トークンとして用いる文字列には、以下のような性質が求められます。

  • 十分な長さを有すること
  • 十分にランダムであること
  • (できれば)一意であること
    • 同一のトークンを持つユーザーが複数いても問題はない
    • 一方で、セッションハイジャックのリスクなどを考え、トークンは一意であるほうが安全とはいえる

SecureRandomライブラリ

Ruby標準のライブラリの一つです。Ruby 2.6.0 リファレンスマニュアルには、以下のような説明があります。

安全な乱数発生器のためのインターフェースを提供するモジュールです。 HTTP のセッションキーなどに適しています。

urlsafe_base64メソッド

SecureRandomライブラリに含まれる特異メソッドの一つです。a-zA-Z0-9-_のみから成るランダムな文字列を返します。引数を指定しなければ、返ってくる文字列の文字数は22となります。

# rails console
>> SecureRandom.urlsafe_base64
=> "eBnhzEgyNc1zWD83O1SQZQ"

上記はSecureRandom.urlsafe_base64メソッドの典型的な実行例です。

SecureRandom.urlsafe_base64の実行結果が意味するところ

引数なしのurlsafe_base64の実行結果は、「64種類の文字からなる長さ22のランダムな文字列」です。ランダム性が十分であれば、このメソッドの実行結果がたまたま完全に一致する可能性は、「$1/64^{22}=2^{-132}\approx10^{-40}$」となります。衝突の可能性は無視できるほど低い確率であり、一意性は十分といえます。

また、メソッド名に urlsafe という語が入っているように、urlsafe_base64の実行結果は、エスケープなしにURLに埋め込むことができます。URLを扱う操作においても、特段の配慮なしに使うことができるわけなのです。URLでトークンを扱う操作の例を以下に列挙します。

  • アカウント有効化のリンクを発行する
  • パスワードリセットのリンクを発行する

Userモデルにトークン生成用メソッドを追加する

app/models/user.rb
  class User < ApplicationRecord
    ...略
+
+   # ランダムなトークンを返す
+   def User.new_token
+     SecureRandom.urlsafe_base64
+   end
  end

ここで重要なのは、「new_tokenメソッドは、Userモデルのオブジェクトを必要としない。ゆえに(インスタンスメソッドではなく)クラスメソッドとして定義する」という実装の方針です。

同じくUserモデル内のクラスメソッドとして、「渡された文字列のハッシュ値を返す」というdigestメソッドが既に実装されています。そのコードは以下です。

app/models/user.rb(抜粋)
# 渡された文字列のハッシュ値を返す
def User.digest(unencrypted_password)
  cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
  BCrypt::Password.create(unencrypted_password, cost: cost)
end

new_tokenメソッドとdigestメソッドにより、「ランダムな文字列を生成し、ハッシュ値に変換してからRDBに保存する」という一連の動作が実現できます。

user.rememberメソッドの作成

user.rememberメソッドとは

user.rememberメソッドは、以下の動作をするメソッドです。

  • 記憶トークンをユーザーと関連付ける
  • トークンに対応する記憶ダイジェストをRDBに保存する

remember_token属性

生の記憶トークンそのもの(remember_token属性とします)は、RDBには保存せず、ユーザー環境のcookiesに保存されます。「Userモデルにはremember_token属性を定義しつつ、RDBにはremember_token属性を定義しない」という実装が求められます。

この関係は、Userモデル上のパスワードの実装における「RDBに定義されるpassword_digest属性と、RDBには定義されないpasswordおよびpassword_confirmation属性との関係」に類似しています。しかしながら、パスワードの実装におけるhas_secure_passwordメソッドのような仕組みは、残念ながらトークンの実装では使えません。remember_token属性のコードは自分で書く必要があります。

attr_accessorメソッドと、remember_token属性の定義

remember_tokenは、Userモデルのインスタンス変数として定義します。cookiesにより永続化され、Userモデルの外から読み書きが行われるインスタンス変数であるため、attr_accessorメソッドによりUserモデルの外から読み書きができるようにします。コードは以下です。

attr_accessor :remember_token

user.rememberメソッドの実装

user.rememberメソッドの実装は以下になります。

def remember
  self.remember_token = User.new_token
  update_attribute(:remember_digest, User.digest(remember_token))
end

以下が重要なポイントです。

  • self.remember_tokenにより、@remember_tokenに値を代入している
    • より厳密には、@remember_tokenに値をセットするremember_tokenメソッドが呼び出されている
  • RDBへの保存処理がupdate_attributeにより行われている
    • saveでもなければupdate_attributesでもない
    • バリデーションを素通りさせる必要があるため

最終的にapp/models/user.rbに追加する内容

app/models/user.rb
  class User < ApplicationRecord
+   attr_accessor :remember_token
    ...略
+
+   # 永続セッションのためにユーザーをデータベースに記憶する
+   def remember
+     self.remember_token = User.new_token
+     update_attribute(:remember_digest, User.digest(remember_token))
+   end
  end

この時点でテストは正しく完了します。

# rails test
Running via Spring preloader in process 478
Started with run options --seed 51133

  24/24: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.72491s
24 tests, 68 assertions, 0 failures, 0 errors, 0 skips

リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。

こうした技巧的な派生コードについては、演習2.1.および演習2.2.で触れてみることにします。

ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4リスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。

演習 - 記憶トークンと暗号化

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

# rails console --sandbox

>> user = User.find(1)
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2019-10-22 22:46:59", updated_at: "2019-10-22 22:46:59", password_digest: "$2a$10$j21OfGX82PY0/BqDcapJmeeo/xaVKgSQ9pEZD8hAp4B...", remember_digest: nil>

>> user.remember
   (0.1ms)  SAVEPOINT active_record_1
  SQL (9.0ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2019-11-05 10:08:44.739223"], ["remember_digest", "$2a$10$6l.Ri3zvU7ux2jQ5nvftC.irkIhGI/BdhYO.K7hDwyDtSY/z7.z8e"], ["id", 1]]
   (0.2ms)  RELEASE SAVEPOINT active_record_1
=> true

>> user 
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2019-10-22 22:46:59", updated_at: "2019-11-05 10:08:44", password_digest: "$2a$10$j21OfGX82PY0/BqDcapJmeeo/xaVKgSQ9pEZD8hAp4B...", remember_digest: "$2a$10$6l.Ri3zvU7ux2jQ5nvftC.irkIhGI/BdhYO.K7hDwyD...">

SQLのUPDATE文が正常に発行され、rememberメソッドそのものもtrueを返してきています。rememberメソッドはうまく動いていると判断できます。

UPDATE文中にremember_tokenが存在しないのは一つのポイントです。

1.2. remember_tokenremember_digestの違いも確認してみてください。

>> user.remember_token
=> "Iiq68oTYkHXriAF-aFx83w"

>> user.remember_digest
=> "$2a$10$6l.Ri3zvU7ux2jQ5nvftC.irkIhGI/BdhYO.K7hDwyDtSY/z7.z8e"

「何らかの関数により生成されたランダムな文字列」という点は同じですが、文字数や使用する文字に違いが見られます。

文字数は以下のとおりです。

  • remember_token…22文字
  • remember_digest…60文字

remember_tokenはURL安全な文字のみで構成される一方、remember_digestにはURL安全でない文字(/など)が含まれます。

remember_digestは、$2a$10$という文字列で始まっています。この部分の内容はBCryptの仕様で決められています。

  • $2a$…BCryptのバージョンを表す
  • 10$…コストパラメータが10であることを表す
>> user.inspect
=> "#<User id: 1, name: \"Rails Tutorial\", email: \"example@railstutorial.org\", created_at: \"2019-10-22 22:46:59\", updated_at: \"2019-11-05 10:08:44\", password_digest: \"$2a$10$j21OfGX82PY0/BqDcapJmeeo/xaVKgSQ9pEZD8hAp4B...\", remember_digest: \"$2a$10$6l.Ri3zvU7ux2jQ5nvftC.irkIhGI/BdhYO.K7hDwyD...\">"

remember_token属性は、inspectメソッドの結果に表示されない」というのも大きな特徴です。inspectの結果に表示させないようにするために、何らかの仕組みがあるのでしょうか。

2.1. テストスイートを実行して、ややわかりにくいリスト 9.4の実装で正しく動くことを確認してみてください。

app/models/user.rb
  class User < ApplicationRecord
    attr_accessor :remember_token
    ...略
    # 渡された文字列のハッシュ値を返す
-   def User.digest(unencrypted_password)
+   def self.digest(unencrypted_password)
      cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
      BCrypt::Password.create(unencrypted_password, cost: cost)
    end
+
+   # ランダムなトークンを返す
+   def self.new_token
+     SecureRandom.urlsafe_base64
+   end
+
+   # 永続セッションのためにユーザーをデータベースに記憶する
+   def remember
+     self.remember_token = User.new_token
+     update_attribute(:remember_digest, User.digest(remember_token))
+   end
  end
# rails test
Running via Spring preloader in process 606
Started with run options --seed 56238

  24/24: [=================================] 100% Time: 00:00:03, Time: 00:00:03

Finished in 3.14859s
24 tests, 68 assertions, 0 failures, 0 errors, 0 skips

テストは通りました。

# rails console --sandbox
>> user=User.find(1)

>> user.remember    
   (0.1ms)  SAVEPOINT active_record_1
  SQL (11.8ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2019-11-06 10:03:03.340289"], ["remember_digest", "$2a$10$eu2GAXNC3acJtvcE4i2/NeVOwr06wWCDIw9Sefyd7wigubxpx75zS"], ["id", 1]]
   (0.2ms)  RELEASE SAVEPOINT active_record_1
=> true

演習 1.1. の処理も正しく実行できることが確認できました。

2.2. テストスイートを実行して、非常に混乱しやすいリスト 9.5の実装でも正しく動くことを確認してみてください。

app/models/user.rb
  class User < ApplicationRecord
    attr_accessor :remember_token
    ...略

-   # 渡された文字列のハッシュ値を返す
-   def self.digest(unencrypted_password)
-     cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
-     BCrypt::Password.create(unencrypted_password, cost: cost)
-   end
-
-   # ランダムなトークンを返す
-   def User.new_token
-     SecureRandom.urlsafe_base64
-   end
+   class << self
+     # 渡された文字列のハッシュ値を返す
+     def digest(unencrypted_password)
+       cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST : BCrypt::Engine.cost
+       BCrypt::Password.create(unencrypted_password, cost: cost)
+     end
+
+     # ランダムなトークンを返す
+     def new_token
+       SecureRandom.urlsafe_base64
+     end
+   end

    # 永続セッションのためにユーザーをデータベースに記憶する
    def remember
      self.remember_token = User.new_token
      update_attribute(:remember_digest, User.digest(remember_token))
    end
  end
# rails test
Running via Spring preloader in process 632
Started with run options --seed 12033

  24/24: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.46399s
24 tests, 68 assertions, 0 failures, 0 errors, 0 skips

テストは通りました。

# rails console --sandbox
>> user=User.find(1)

>> user.remember    
   (0.1ms)  SAVEPOINT active_record_1
  SQL (12.8ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2019-11-06 10:15:34.199120"], ["remember_digest", "$2a$10$l1/8SG9CKjVxTqc1F4Ffp.xsHucOWMNLcTlEpkKxqYwEsmxlDl2Yq"], ["id", 1]]
   (0.1ms)  RELEASE SAVEPOINT active_record_1
=> true

演習 1.1. の処理も正しく実行できることが確認できました。

ログイン状態の保持

これから何をするか

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

cookiesメソッド

上述の処理を実際に行うには、cookiesメソッドを使います。cookiesも、sessionと同様、ハッシュのような形で使うことができます。

個別のcookiesは、1つのvalue(値)と、オプションのexprires(有効期限)からなります。例えば以下のコードは、「記憶トークンと同じ値を有効期限20年のcookiesに保存する」というものです。

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

Rails界隈においては、一般に、有効期限20年のcookiesをもって「永続cookies」とされます。cookies.permanentメソッドを用いた以下のコードも、上述exprires: 20.years.from_now.utcを用いたコードと同じ動作になります。

cookies.permanent[:remember_token] = remember_token 

ユーザーIDをcookiesに保存する基本的な方法

sessionsと記法としては同じです。単にsessionscookiesに変わっただけといえます。

cookies[:user_id] = user_id

ただし、cookiesメソッドをそのまま使うと、保存内容は生のテキスト(平文)になってしまいます。「アプリケーションのcookiesの形式が外部から見えてしまう」というのは、セキュリティ上あまりよろしいとはいえません。

署名付きcookieにより、ユーザーIDをcookiesに安全に保存する

そこで、cookieの安全性を保つ技術である「署名付きcookie」を使うようにします。署名付きcookieを使う際に用いるメソッドはcookies.signedです。

cookies.signed[:user_id] = user_id

cookies.signedは、内部で以下の処理を行っています。

  • cookiesの暗号化
  • cookiesへのデジタル署名の付与

cookies.permanent.signedの意味

cookies.permanentcookies.signedはメソッドチェーンで繋いで使用することができます。

ユーザーIDと記憶トークンはペアで用いられます。そのため、cookieも永続化される必要があります。cookies.permanent.signedというのは、「署名付きcookieを永続化する」という意味にほかなりません。

cookies.permanent.signed[:user_id] = user_id

設定されたcookiesを使う

設定されたcookiesからは、cookies.signedメソッドにより情報を取り出すことができます。今回用いるcookiesには、暗号化された状態で情報が保存されていますが、cookies.signedには、暗号化を解除する機能も実装されています。

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

上記コードは、「cookiesに保存されたユーザーIDをキーとして、Userモデルに紐付けされたRDBを検索する」という動作をします。

記憶トークンの一致を確認する

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

この場面における==演算子(メソッド)およびis_password?メソッドの意味

この場面で==が使えるようになるに至るプロセスは、実にRubyらしいプロセスです。bcrypt gemのソースコードであるbcrypt-ruby/lib/bcrypt/password.rbを見ると、演算子とされる==そのものが再定義されているのです。

def ==(secret)
  super(BCrypt::Engine.hash_secret(secret, @salt))
end
alias_method :is_password?, :==

==メソッドの引数をハッシュ化した上で、==メソッドのレシーバと、通常の==によって比較する」という動作をする、と説明できます。また、同じメソッドが、論理値を返すis_password?メソッドという名称でも定義されています。

結果、以下3つのコードは同じ意味になります。

BCrypt::Password.new(password_digest) == remember_token
BCrypt::Password.new(password_digest).==(remember_token)
BCrypt::Password.new(password_digest).is_password?(remember_token)

「論理値を返すこと」を明確にするために、今回はis_password?メソッドを使います。

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

Userモデルに、記憶トークンを確認するauthenticated?メソッドを定義する

app/models/user.rbに、以下のようにauthenticated?メソッドの定義を追加していきます。

app/models/user.rb
  class User < ApplicationRecord
    attr_accessor :remember_token
    ...略
+
+   # 渡されたトークンがダイジェストと一致したらtrueを返す
+   def authenticated?(remember_token)
+     BCrypt::Password.new(remember_digest).is_password?(remember_token)
+   end
  end

authenticated?メソッドの実装においては、以下が重要なポイントです。

  • authenticated?メソッドの引数のremember_tokenについて
    • インスタンス変数であるself.remember_tokenと同じ名前を使っている
    • インスタンス変数であるself.remember_tokenとは異なる実体を指す
  • authenticated?メソッド内のremember_digestについて
    • インスタンス変数であるself.remember_digestと同じ実体を指す
    • remember_tokenとは異なり、self.が省略できる

この挙動の違いを説明するにあたり、ポイントになるのは、「remember_digestはRDBのカラムと紐付けされている。一方remember_tokenはRDBのカラムと紐付けされていない。」という点です。

attr_accessorなどによってゲッター・セッターメソッドが定義されたインスタンス変数の場合、ゲッター・セッターメソッドを呼び出す際にself.は省略できません。しかしながら、「Active RecordによってRDBのカラムと紐付けされたインスタンス変数」については、Active Recordに実装された仕組みにより、self.を省略しても参照できる形でインスタンス変数が定義されます。そういえば、nameemailself.なしでUserモデルのインスタンス変数に直接アクセスしていましたね。

Sessionsヘルパーに、永続Cookiesへの保存処理を実装する

app/helpers/sessions_helper.rb
  module SessionsHelper

    ...略
+
+   # ユーザーのセッションを永続的にする
+   def remember(user)
+     user.remember
+     cookies.permanent.signed[:user_id] = user.id
+     cookies.permanent[:remember_token] = user.remember_token
+   end

    ...略
  end

今回追加したSessionsHelper#rememberでは、以下の処理を行っています。

  • ユーザーのremember_digest属性を更新する
  • 暗号化したユーザーのIDを永続cookiesに保存する
  • ユーザーの記憶トークンを永続cookiesに保存する

Sessionsコントローラーに、永続cookiesへの保存処理を実装する

SessionsHelper#rememberまでの実装が済んでいるなら、app/controllers/sessions_controller.rbにはremember userの1行を追加するだけです。

app/controllers/sessions_controller.rb
  class SessionsController < ApplicationController
    ...略
    def create
      user = User.find_by(email: params[:session][:email].downcase)
      if user && user.authenticate(params[:session][:password])
        log_in user
+       remember user
        redirect_to user
      else
        ...略
      end
    end

   ...略
  end

永続cookiesによるログインを実装する

app/helpers/sessions_helper.rb
def current_user
  if session[:user_id]
    @current_user ||= User.find_by(id: session[:user_id])
  end
end

「ログイン中のユーザー」を表すcurrent_userメソッドの現在の実装は、上記のとおりです。しかしながら現在の実装は、一時セッションしか扱っていません。永続セッションを扱えるようにするためには、current_userメソッドの実装を変更する必要があります。

新たなcurrent_userメソッドの実装方針は以下です。

  1. session[:user_id]が存在すれば、session[:user_id]でログイン
  2. session[:user_id]が存在しなければ、cookies[:user_id]からユーザーを取り出し、対応する永続セッションにログイン

基本的な実装内容

current_userメソッドの新たな実装(基本)
def current_user
  if session[:user_id]
    @current_user ||= User.find_by(id: session[:user_id])
  elsif  cookies.signed[:user_id]
    user = User.find_by(id: cookies.signed[:user_id])
    if user && user.authenticated?(cookies[:remember_token])
      log_in user
      @current_user = user
    end
  end
end

user && user.authenticated?というのは、「ユーザーが存在し、かつ認証トークンが正しければtrue」という意味になります。基本的なログイン機構の実装でも同様のコードが登場しました。

ただ、この実装内容では、sessionメソッドもcookies.signedメソッドもそれぞれ2回呼び出されることになります。まだ改善の余地がありそうですね。

基本的な実装内容からのリファクタリング

ということで、ローカル変数を使ってコードを改善します。

current_userメソッドの新たな実装(改善語)
def current_user
  if (user_id = session[:user_id])
    @current_user ||= User.find_by(user_id)
  elsif (user_id = cookies.signed[:user_id])
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])
      log_in user
      @current_user = user
    end
  end
end

sessioncookies.signed、いずれのメソッドも呼び出しは1回限りになりました。ただ、そのコードの中身については、少々紛らわしいので説明が必要です。

if (user_id = foobar)の意味

if (user_id = session[:user_id])

例えば上記のコードは、下記のコードと同様の意味になることを想定して使われています。「(ユーザーIDにユーザーIDのセッションを代入した結果) ユーザーIDのセッションが存在すれば」という意味です。

unless (user_id = session[:user_id]).nil?

ポイントは以下です。

  • ==ではなく=である
    • 等価比較ではなく代入である
  • Rubyにおいては、オブジェクトを真偽値に変換する(!!)と、falsenil以外は全てtrueとして評価される
    • この場面でuser_idfalseになることは想定されない

実際のcurrent_userメソッドの定義

以上を踏まえた上で、app/helpers/sessions_helper.rbcurrent_userメソッドを定義します。

app/helpers/sessions_helper.rb
  module SessionsHelper
    ...略

+   # 現在ログイン中のユーザーを返す(いる場合)
+   def current_user
+     if (user_id = session[:user_id])
+       @current_user ||= User.find_by(id: user_id)
+     elsif (user_id = cookies.signed[:user_id])
+       user = User.find_by(id: user_id)
+       if user && user.authenticated?(cookies[:remember_token])
+         log_in user
+         @current_user = user
+       end
+     end
+   end

    ...略
  end

無事永続cookiesが発行されるようになります。以下は、実際に発行された永続cookiesの例です。

スクリーンショット 2019-11-08 19.31.09.png スクリーンショット 2019-11-08 19.28.05.png

名前もきちんとuser_idremember_tokenですね。

ログアウトできない!

現状では、(cookiesを直接削除するか、20年経過しない限り)このアプリケーションからログアウトする手段がありません。テストも通りません。

# rails test
Running via Spring preloader in process 107
Started with run options --seed 55952

 FAIL["test_login_with_valid_information_followed_by_logout", UsersLoginTest, 2.62310190000062]
 test_login_with_valid_information_followed_by_logout#UsersLoginTest (2.62s)
        Expected at least 1 element matching "a[href="/login"]", found 0..
        Expected 0 to be >= 1.
        test/integration/users_login_test.rb:33:in `block in <class:UsersLoginTest>'

  24/24: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.62620s
24 tests, 66 assertions, 1 failures, 0 errors, 0 skips

test/integration/users_login_test.rbの33行目において「/login へのハイパーリンクが1つ見つからなければならないのに、実際には1つも見つからない」ということでテストが失敗しています。

test/integration/users_login_test.rb(29〜33行目)
delete logout_path
assert_not is_logged_in?
assert_redirected_to root_url
follow_redirect!
assert_select "a[href=?]", login_path
  • /logout に対してdeleteアクションが正常に発行され、正常に完了しいる
    • session[:user_id]nilになる
    • リダイレクト先は / である
  • しかしながら、リダイレクト先の / に「Log in」リンクは存在しない

次に実装すべきは、永続cookiesが実装された状態からユーザーがログアウトする機能です。

演習 - ログイン状態の保持

1. ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。

上述「実際のcurrent_userメソッドの定義」の通りです。

2. コンソールを開き、リスト 9.6authenticated?メソッドがうまく動くかどうか確かめてみましょう。

以下のユーザーが対象であることを前提とします。

スクリーンショット 2019-11-08 21.52.20.png

また、以下の永続cookiesが存在することを前提とします。

スクリーンショット 2019-11-08 21.49.30.png

remember_tokenの値はiwYAfqnByUCCT7RnWS4IgQとなっています。

# rails console --sandbox

>> user = User.find(2)
=> #<User id: 2, ...略>
>> user.authenticated?("iwYAfqnByUCCT7RnWS4IgQ")
=> true

>> SecureRandom.urlsafe_base64
=> "Eky2ZYTXB_a7gq-s5fv4ig"
>> user.authenticated?("Eky2ZYTXB_a7gq-s5fv4ig")
=> false
  • 永続cookiesのremember_tokenの値を引数とするとtrueが返ってきた
  • SecureRandom.urlsafe_base64で適当に生成したトークンを引数とするとfalseが返ってきた

上述の結果から、authenticated?メソッドはうまく動いているようです。

ユーザーを忘れる

Userモデルにforgetメソッドを実装する

永続cookiesが実装された状態からユーザーがログアウトする機能として、Userモデルにforgetメソッドを実装します。具体的には、「remember_digest属性の内容をnilで上書きする(そして、その変更をRDBに保存する)」という動作をするメソッドです。

def forget
  update_attribute(:remember_digest, nil)
end

Userモデルなので、コードを追加するのはapp/models/user.rbです。

app/models/user.rb
  class User < ApplicationRecord
    attr_accessor :remember_token
    ...略
+
+   # ユーザーのログイン情報を破棄する
+   def forget
+     update_attribute(:remember_digest, nil)
+   end
+ end

Sessionsヘルパーに追加する、永続的セッションを破棄するforgetメソッド

内容は以下のとおりです。

def forget(user)
  user.forget
  cookies.delete(:user_id)
  cookies.delete(:remember_token)
end

Sessionsヘルパーのforgetメソッドで実際に行う処理は以下のとおりとなります。

  • Userモデルのforgetメソッドを呼び出している
    • RDBに保存されたremember_digestを破棄する
  • 以下の永続cookiesを削除している
    • 暗号化されたユーザーID
    • 認証トークン

Sessionsヘルパーに、永続的セッションを破棄してログアウトする処理を追加する

app/helpers/sessions_helper.rbに、forgetメソッド、ならびにlog_outメソッド内でのforgetメソッドの呼び出しを追加します。

app/helpers/sessions_helper.rb
  module SessionsHelper
    ...略
+
+   # 永続的セッションを破棄する
+   def forget(user)
+     user.forget
+     cookies.delete(:user_id)
+     cookies.delete(:remember_token)
+   end

    # 現在のユーザーをログアウトする
    def log_out
+     forget(current_user)
      session.delete(:user_id)
      @current_user = nil
    end
  end

改めてテストを実行する

# rails test
Running via Spring preloader in process 136
Started with run options --seed 25135

  24/24: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.09096s
24 tests, 68 assertions, 0 failures, 0 errors, 0 skips

この時点でテストは問題なく通ります。

演習 - ユーザーを忘れる

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

スクリーンショット 2019-11-08 22.45.45.png

ログイン中の時点では、remember_tokenuser_idという、有効期限が20年後に設定された2つのcookiesが存在します。

スクリーンショット 2019-11-08 22.45.53.png

ログアウトすると、remember_tokenuser_idという2つのcookiesはいずれも削除されました。

2つの目立たないバグ

バグの内容

その1「複数のタブから複数回ログアウト操作をするとエラーになる」

以下の操作をすると、現状ではRailsのエラー画面が表示される事態が発生してしまいます。

  1. 同一ブラウザの複数タブ(または複数ウィンドウ)で、ログイン中のユーザーページを開く
  2. 1つのタブで「Log out」をクリックし、ログアウト操作を行う
  3. 別のタブで「Log out」をクリックする

エラー画面のスクリーンショットを以下に掲載します。発生するエラーは、SessionsController#destroyNoMethodErrorエラーです。

スクリーンショット 2019-11-09 23.46.31.png

エラーの原因は、「current_usernilであるにもかかわらず、current_userを引数としたforgetメソッドを呼び出してしまう」という操作にあります。しかしながら、「ユーザーが通常のブラウジングで行いうる操作に対して例外を投げてしまう」というのはよろしくありません。

その2「複数のWebブラウザでログインし、そのうちの一つからログアウトすると、別のWebブラウザを終了して再起動したときにエラーになる」

以下の操作をすると、現状ではRailsのエラー画面が表示される事態が発生してしまいます。

  1. 複数のWebブラウザ(例えばFirefoxとSafari)で、特定のユーザーにログインする
  2. 1つのWebブラウザ(例えばFirefox)で「Log out」をクリックし、ログアウト操作を行う
  3. 別のWebブラウザ(例えばSafari)で、ログアウトせずに終了する
  4. ログアウトしていないほうのWebブラウザ(例えばSafari)を再起動し、アプリケーションの任意のページを表示する

エラー画面のスクリーンショットを以下に掲載します。発生するエラーは、表示しようとしたページのBCrypt::Errors::InvalidHashエラーです。今回は表示しようとしたページが / なので、エラーはStaticPages#homeで発生しています。

スクリーンショット 2019-11-09 23.57.50.png

エラーの原因は、「一つのWebブラウザでログアウト操作を行った時点でRDBからremember_digestの属性値が削除されるにもかかわらず、別のWebブラウザからremember_tokenが入力されてしまう」という操作にあります。

もう少し詳しい説明

1つ目のWebブラウザでログアウトすると、Sessionsヘルパーのlog_outメソッドが呼び出されます。結果、以下の処理が行われます。

  • 1つ目のWebブラウザの永続cookiesが削除される
  • 1つ目のWebブラウザの一時cookiesが削除される

結果、以下のcurrent_userメソッドの戻り値はnilになります。

def current_user
  if (user_id = session[:user_id])
    @current_user ||= User.find_by(id: user_id)
  elsif (user_id = cookies.signed[:user_id])
    user = User.find_by(id: user_id)
    if user && user.authenticated?(cookies[:remember_token])
      log_in user
      @current_user = user
    end
  end
end

一方、2つ目のWebブラウザの内部状態は以下のようになります。

  • 2つ目のWebブラウザのプログラムを終了した時点で、2つ目のWebブラウザの一時cookiesは破棄される
  • 一連の動作において、2つ目のWebブラウザの永続cookiesは削除されない

結果、以下の文の評価結果がtrueとなります。

elsif (user_id = cookies.signed[:user_id])

処理は以下の文まで進み…

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

Userモデルのauthenticated?メソッドが呼び出され…

User#authenticated?
def authenticated?(remember_token)
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

BCrypt::Password.new(remember_digest)メソッドでエラーとなります。「1つ目のWebブラウザにおける操作により、既にremember_digestの内容はnilである。パスワードのハッシュとしてnilという値はありえないはずなのに、それを引数としてBCrypt::Password.newメソッドが呼び出されてしまう。結果、例外を投げる」というわけです。

テストによるバグの解決

ここは「テストによりバグを解決する」という局面です。実際には、以下のテストを書いていきます。

  • 2番目のウィンドウでログアウトするユーザーを想定したテスト
  • 記憶ダイジェストを持たないユーザーに対する動作のテスト

2番目のウィンドウでログアウトするユーザーを想定したテスト

テストを書く

  require 'test_helper'

  class UsersLoginTest < ActionDispatch::IntegrationTest
    ...略

    test "login with valid information followed by logout" do
      get login_path
      post login_path, params: { session: { email: @user.email, password: 'password' } }
      assert_redirected_to @user
      follow_redirect!
      assert_template 'users/show'
      assert_select "a[href=?]", login_path, count: 0
      assert_select "a[href=?]", logout_path
      assert_select "a[href=?]", user_path(@user)
      delete logout_path
      assert_not is_logged_in?
      assert_redirected_to root_url
+     # 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
+     delete logout_path
      follow_redirect!
      assert_select "a[href=?]", login_path
      assert_select "a[href=?]", logout_path,      count: 0
      assert_select "a[href=?]", user_path(@user), count: 0
    end
  end

テストを実行する(通らない)

テストを実行してみます。結果はエラーを返して終了です。

# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 101
Started with run options --seed 8392

ERROR["test_login_with_valid_information_followed_by_logout", UsersLoginTest, 2.0612237000004825]
 test_login_with_valid_information_followed_by_logout#UsersLoginTest (2.06s)
NoMethodError:         NoMethodError: undefined method `forget' for nil:NilClass
            app/helpers/sessions_helper.rb:35:in `forget'
            app/helpers/sessions_helper.rb:42:in `log_out'
            app/controllers/sessions_controller.rb:18:in `destroy'
            test/integration/users_login_test.rb:33:in `block in <class:UsersLoginTest>'

  2/2: [===================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.18225s
2 tests, 13 assertions, 0 failures, 1 errors, 0 skips

forgetメソッドのレシーバーがnilであることに起因するNoMethodErrorエラー」というメッセージが出ていますね。実際に発生しているエラーを捕捉できています。

テストが通るようにする

Railsチュートリアル本文における解決策としては、「log_outlogged_in?trueの場合に限り呼び出すようにする」というものが挙げられています。

app/controllers/sessions_controller.rb
  class SessionsController < ApplicationController
    ...略

    def destroy
-     log_out
+     log_out if logged_in?
      redirect_to root_url
    end
  end

再度テストを実行してみましょう。

# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 114
Started with run options --seed 58185

  2/2: [===================================] 100% Time: 00:00:01, Time: 00:00:01

Finished in 2.00099s
2 tests, 16 assertions, 0 failures, 0 errors, 0 skips

無事テストが通りました。

記憶ダイジェストを持たないユーザーに対する動作のテスト

「複数のWebブラウザプログラムからのアクセスを想定した統合テストを書く」というのは、正直かなり困難です。しかしながら、「記憶ダイジェストを持たないユーザーを想定した統合テストを書く」というのは容易です。実際には、以下の手順でテストを進めていきます。

  1. 記憶ダイジェストを持たないユーザーを用意する
  2. 当該ユーザーに対して、authenticated?メソッドを実行する
  3. authenticated?メソッドがfalseを返せばテスト成功

「記憶ダイジェストを持たないユーザー」というのは、UserTest#setupメソッドで定義する@userとは別に用意する必要があります。@userに対しては、authenticated?メソッドの実行結果がtrueになるからです。

テストを書く

実際のテストコードは以下の通りです。

test "authenticated? should return false for a new user with nil digest" do
  assert_not @user.authenticated?('')
end

test/models/user_test.rbにテストを追加していきます。

test/models/user_test.rb
  require 'test_helper'

  class UserTest < ActiveSupport::TestCase
    ...略
+
+   test "authenticated? should return false for a new user with nil digest" do
+     assert_not @user.authenticated?('')
+   end
  end
authenticated?の引数が空文字列である理由

authenticated?の引数が空文字列になっています。その理由を説明するために、ここで改めてUserモデルにおけるauthenticated?メソッドの実装を見てみましょう。

User#authenticated?
def authenticated?(remember_token)
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

remember_digestがダイジェストとして不正な文字列である場合、is_password?が呼び出される以前、BCrypt::Password.newの時点でエラーになります。今回実装するテストでは、このような場合に@user.authenticated?('')falseを返さなければならないという条件です。

BCrypt::Password.newが値を戻し、is_password?まで進めば、is_password?の実装上、戻り値はtruefalseになります。ゆえに、authenticated?の引数は空文字列でも何でも構わない、ということになります。

テストを実行する(通らない)

テストを実行してみます。結果はエラーを返して終了です。

# rails test test/models/user_test.rb
Running via Spring preloader in process 127
Started with run options --seed 13861

ERROR["test_authenticated?_should_return_false_for_a_new_user_with_nil_digest", UserTest, 0.17453030000069703]
 test_authenticated?_should_return_false_for_a_new_user_with_nil_digest#UserTest (0.18s)
BCrypt::Errors::InvalidHash:         BCrypt::Errors::InvalidHash: invalid hash
            app/models/user.rb:31:in `new'
            app/models/user.rb:31:in `authenticated?'
            test/models/user_test.rb:75:in `block in <class:UserTest>'

  12/12: [=================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.33746s
12 tests, 20 assertions, 0 failures, 1 errors, 0 skips

BCrypt::Errors::InvalidHashエラー」というメッセージが出ていますね。実際に発生しているエラーを捕捉できています。

テストが通るようにする

Railsチュートリアル本文における解決策としては、「記憶ダイジェストがnilである場合、authenticated?メソッドがfalseを返すようにする」というものが挙げられています。

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
    ...略
  end

再度テストを実行してみましょう。

# rails test test/models/user_test.rb
Running via Spring preloader in process 140
Started with run options --seed 13423

  12/12: [=================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.27720s
12 tests, 21 assertions, 0 failures, 0 errors, 0 skips

無事テストが通りました。

演習 - 2つの目立たないバグ

1. リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。

app/controllers/sessions_controller.rb
  class SessionsController < ApplicationController
    ...略
    def destroy
-     log_out if logged_in?
+     log_out# if logged_in?
      redirect_to root_url
    end
  end

上記変更を保存した上で、以下の操作を行います。

  1. Firefoxの複数タブで、ログイン中のユーザーページを開く
  2. 1つのタブで「Log out」をクリックし、ログアウト操作を行う
  3. 別のタブで「Log out」をクリックする

usernilであるのにforgetメソッドを呼び出してしまい、結果NoMethodErrorが発生します。

スクリーンショット 2019-11-10 16.47.49.png

rails serverのログ画面には、以下のスタックトレースが表示されています。

app/helpers/sessions_helper.rb:35:in `forget'
app/helpers/sessions_helper.rb:42:in `log_out'
app/controllers/sessions_controller.rb:18:in `destroy'

スクリーンショットの内容、ならびにrails serverに表示されたトレースを見ると、SessionsController#destroyからSessionsHelper#forgetへエラーの原因となるメソッド呼び出しが波及していっていることがわかります。

test/integration/users_login_test.rbの統合テストも通りません。

# rails test test/integration/users_login_test.rb
Running via Spring preloader in process 166
Started with run options --seed 55314

ERROR["test_login_with_valid_information_followed_by_logout", UsersLoginTest, 2.165016299999479]
 test_login_with_valid_information_followed_by_logout#UsersLoginTest (2.17s)
NoMethodError:         NoMethodError: undefined method `forget' for nil:NilClass
            app/helpers/sessions_helper.rb:35:in `forget'
            app/helpers/sessions_helper.rb:42:in `log_out'
            app/controllers/sessions_controller.rb:18:in `destroy'
            test/integration/users_login_test.rb:33:in `block in <class:UsersLoginTest>'

  2/2: [===================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.19785s
2 tests, 13 assertions, 0 failures, 1 errors, 0 skips

2. リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。

app/models/user.rb
  class User < ApplicationRecord
    ...略

    # 渡されたトークンがダイジェストと一致したらtrueを返す
    def authenticated?(remember_token)
-     return false if remember_digest.nil?
+     # return false if remember_digest.nil?
      BCrypt::Password.new(remember_digest).is_password?(remember_token)
    end

    ...略
  end

上記変更を保存した上で、以下の操作を行います。

  1. FirefoxとSafariで、特定のユーザーにログインする
  2. Firefoxで「Log out」をクリックし、ログアウト操作を行う
  3. Safariで、ログアウトせずに終了する
  4. Safariを再起動し、アプリケーションの任意のページを表示する

SafariでBCrypt::Errors::InvalidHashエラーが発生します。

スクリーンショット 2019-11-10 16.34.28.png

rails serverのログ画面には、以下のスタックトレースが表示されています。

app/models/user.rb:32:in `new'
app/models/user.rb:32:in `authenticated?'
app/helpers/sessions_helper.rb:21:in `current_user'
app/helpers/sessions_helper.rb:30:in `logged_in?'

スクリーンショットの内容、ならびにrails serverに表示されたトレースを見ると、BCrypt::Password.new(remember_digest)でエラーが発生していることがわかります。

User#authenticated?
def authenticated?(remember_token)
  # return false if remember_digest.nil?
  BCrypt::Password.new(remember_digest).is_password?(remember_token) # エラー
end

test/models/user_test.rbの統合テストも通りません。

# rails test test/models/user_test.rb
Running via Spring preloader in process 153
Started with run options --seed 35463

ERROR["test_authenticated?_should_return_false_for_a_new_user_with_nil_digest", UserTest, 0.3806449999992765]
 test_authenticated?_should_return_false_for_a_new_user_with_nil_digest#UserTest (0.38s)
BCrypt::Errors::InvalidHash:         BCrypt::Errors::InvalidHash: invalid hash
            app/models/user.rb:32:in `new'
            app/models/user.rb:32:in `authenticated?'
            test/models/user_test.rb:75:in `block in <class:UserTest>'

  12/12: [=================================] 100% Time: 00:00:00, Time: 00:00:00

Finished in 0.41116s
12 tests, 20 assertions, 0 failures, 1 errors, 0 skips

3. 上のコードでコメントアウトした部分を元に戻し、テストスイートがredからgreenになることを確認しましょう。

コードを以下のように変更します。

app/controllers/sessions_controller.rb
  class SessionsController < ApplicationController
    ...略
    def destroy
-     log_out# if logged_in?
+     log_out if logged_in?
      redirect_to root_url
    end
  end
app/models/user.rb
  class User < ApplicationRecord
    ...略

    # 渡されたトークンがダイジェストと一致したらtrueを返す
    def authenticated?(remember_token)
-     # return false if remember_digest.nil?
+     return false if remember_digest.nil?
      BCrypt::Password.new(remember_digest).is_password?(remember_token)
    end

    ...略
  end

アプリケーション全体に対するテストを実行します。

# rails test                              
Running via Spring preloader in process 179
Started with run options --seed 8193

  25/25: [=================================] 100% Time: 00:00:02, Time: 00:00:02

Finished in 2.48940s
25 tests, 69 assertions, 0 failures, 0 errors, 0 skips

無事テストが通りました。

3
4
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
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?