# 9章
Remember me機能
ユーザがログアウトしない限り、ログイン状態を維持する機能。
## 記憶トークンと暗号化
cookiesを盗み出す有名な方法
(1)管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す
→Secure Sockets Layer(SSL)をサイト全体に適用して、ネットワークデータを暗号化で保護
(2)データベースから記憶トークンを取り出す
→記憶トークンをそのままデータベースに保存するのではなく、記憶トークンのハッシュ値を保存するようにします
(3)クロスサイトスクリプティング(XSS)を使う
→Railsによって自動的に対策
(4) ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る。
→デジタル署名を行うようにします。
方針トークンを用いる
「トークン」とは、パスワードの平文と同じような秘匿されるべき情報を指す。パスワードとトークンとの一般的な違いは、パスワードは使用者が自身で作成・管理する情報であるのに対し、トークンはコンピューターなどが生成した情報である。
1.記憶トークンにはランダムな文字列を生成して用いる。
2.ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
3.トークンはハッシュ値に変換してからデータベースに保存する。
4.ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
5.永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。
まず、ユーザモデルにremember_digestを追加する。

このようにするには、以下のコードを実行する。
```bash
rails generate migration add_remember_digest_to_users remember_digest:string
```
実行した結果、マイグレーションファイルをできる。
**db/migrate/20200220013914_add_remember_digest_to_users.rb**
```ruby
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0]
def change
add_column :users, :remember_digest, :string
end
end
```
追加したいremember_digestが入力されていることを確認して、マイグレーション
```bash
rails db:migrate
```
記憶トークンとして、Ruby標準ライブラリのSecureRandomモジュールにあるurlsafe_base64メソッドを使う。64種類の文字から長さ22の文字列をランダム生成してくれる。
ユーザーを記憶するには、記憶トークンを作成して、そのトークンをダイジェストに変換したものをデータベースに保存する。
また、ユーザオブジェクトが必要ないので、Userモデルのクラスメソッドとして作成する。
**app/models/user.rb**
```ruby
class User < ApplicationRecord
....
# ランダムなトークンを返す「トークン生成メソッド」
def User.new_token
SecureRandom.urlsafe_base64
end
end
```
次に、user.rememberメソッドを作成する。機能としては、記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをデータベースに保存。attr_accessorを使って「仮想の」属性を作成する。要するにremember_token(記憶トークン)カラムは作成していない。なので、いつものようにいきなりuser.remember_tokenとはできない→attr_accessorメソッドでremember_tokenを宣言し、Userインスタンスに値を持たせる際には暗号化しremember_digestとする。
**app/models/user.rb**
```ruby
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
```
selfキーワードを与えると、このユーザに対してのremember_token属性を設定することができる。
最初にUser.new_tokenで記憶トークンを作成し、続いてUser.digestを適用した結果で記憶ダイジェストを更新している。
## 演習
1.コンソールを開き、データベースにある最初のユーザーを変数userに代入してください。その後、そのuserオブジェクトからrememberメソッドがうまく動くかどうか確認してみましょう。また、remember_tokenとremember_digestの違いも確認してみてください。
A.
```bash
irb(main):001:0> user = User.first
User Load (0.0ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2020-01-17 11:27:03", updated_at: "2020-01-23 03:43:47", password_digest: "$2a$10$WLz7WQ1kgVgFE9evtpQsk.e58ZJWQICoWLF2RXwbh0O...", remember_digest: nil>
irb(main):002:0> user.remember
(0.0ms) begin transaction
SQL (2.0ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", "2020-02-20 02:18:11.707177"], ["remember_digest", "$2a$10$3SyKsBJUFU0cMhbpp0c.sOGC.ITRm3ScrrlUebGRXZN5u1ceoWrDm"], ["id", 1]]
(6.0ms) commit transaction
=> true
irb(main):003:0> user.remember_token
=> "vCm7BWXl6oJBoc6xFF4l0w"
irb(main):004:0> user.remember_digest
=> "$2a$10$3SyKsBJUFU0cMhbpp0c.sOGC.ITRm3ScrrlUebGRXZN5u1ceoWrDm"
irb(main):005:0>
```
2.
リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、ややわかりにくいリスト 9.4の実装でも、非常に混乱しやすいリスト 9.5の実装でも、いずれも正しく動くことを確認してみてください。ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。
A.
省略。
## ログイン状態の保持
cookiesメソッドを使い、永続セッションを作成する。
ユーザーIDをcookiesに保存するには、以下のようになる。
```ruby
cookies[:user_id] = user.id
```
しかし、IDが生のテキストとしてcookiesに保存されてしますので、アカウントが奪われる可能性がある。この改善策として、署名付きcookieを使う。これは、cookieをブラウザに保存する前に安全に暗号化する。
```ruby
cookies.signed[:user_id] = user.id
```
そして、cookieも永続化するために、permanentという、期限が20年なるメソッドを繋げる。
```ruby
cookies.permanent.signed[:user_id] = user.id
```
取り出す際にも
```ruby
User.find_by(id: cookies.signed[:user_id])
```
このようにして取り出せる。
次に、渡されたトークンがユーザーの記憶ダイジェストと一致することを確認する。
```ruby
BCrypt::Password.new(remember_digest) == remember_token
```
の代わりに、is_password?という論理値メソッドを使うことで、復号化せずに比較できる。
Userモデルに追加する。
**app/models/user.rb**
```ruby
class User < ApplicationRecord
attr_accessor :remember_token
...
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
end
```
これらにより、ユーザーを記憶する処理の準備が揃った。
まず、rememberヘルパーメソッドを追加して、log_inと連携させる。
**app/controllers/sessions_controller.rb**
```ruby
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
log_in user
remember user
redirect_to user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
log_out
redirect_to root_url
end
end
```
これにより、rememberメソッド定義のuser.rememberを呼び出すまで遅延され、そこで記憶トークンを生成してトークンのダイジェストをデータベースに保存する。
次に、cookiesメソッドでユーザーIDと記憶トークンの永続cookiesを作成する。
**app/helpers/sessions_helper.rb**
```ruby
module SessionsHelper
# ユーザーのセッションを永続的にする
def remember(user)
user.remember
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
end
```
また、current_userメソッドでは一時セッションしか扱っていないので、以下のように修正する。
**app/helpers/sessions_helper.rb**
```ruby
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
```
ここで扱われている。
```ruby
if (user_id = session[:user_id])
```
この文は、「(ユーザーIDにユーザーIDのセッションを代入した結果) ユーザーIDのセッションが存在すれば」という意味になる。
## 演習
1.ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。
A.
あった。
2.コンソールを開き、リスト 9.6のauthenticated?メソッドがうまく動くかどうか確かめてみましょう。
A.
```bash
irb(main):001:0> user = User.first
User Load (0.0ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<User id: 1, name: "Example User", email: "example@railstutorial.org", created_at: "2020-01-17 11:27:03", updated_at: "2020-02-20 03:29:17", password_digest: "$2a$10$WLz7WQ1kgVgFE9evtpQsk.e58ZJWQICoWLF2RXwbh0O...", remember_digest: "$2a$10$jQXmqxGiT.EwnKiFGZZE.elJeJDEeYtJJpIBYgaDTAH...">
irb(main):002:0> user.remember
(0.0ms) begin transaction
SQL (4.0ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", "2020-02-20 03:34:24.837165"], ["remember_digest", "$2a$10$BMXa0VyROkm7hejQwVZ9we9T.AgSVuwuD6gMgQx5xUy88FYYd2G1S"], ["id", 1]]
(6.0ms) commit transaction
=> true
irb(main):003:0> user.authenticated?(user.remember_token)
=> true
```
## ユーザを忘れる
ユーザがログアウトできるように、ユーザーを忘れるためのメソッドを定義。
このuser.forgetメソッドによって、user.rememberが取り消す。具体的には、記憶ダイジェストをnilで更新。
**app/models/user.rb**
```ruby
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
```
ヘルパーを追加。
**app/helpers/sessions_helper.rb**
```ruby
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
```
log_outヘルパーメソッドから、forgetメソッドを呼び出す。forgetヘルパーメソッドでは、user_idとremember_tokenのcookiesを削除している。
テストでGreenになる。
## 演習
1.ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。
A.
クッキーが削除された。
## 2つの目立たないバグ
1つ目は同じサイトをブラウザでもしくは、タブで開いている場合、一方をログアウトし、もう一方もログアウトしようとするとエラーを吐く。
2つ目は異なるブラウザで開き、片方をログアウトし、もう片方をログアウトせずに開くとエラーを吐く。
このような対処をテスト駆動開発にて解決する。
**test/integration/users_login_test.rb**
```ruby
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
.
.
.
test "login with valid information followed by logout" do
get login_path
post login_path, params: { session: { email: @user.email,
password: 'password' } }
assert is_logged_in?
assert_redirected_to @user
follow_redirect!
assert_template 'users/show'
assert_select "a[href=?]", login_path, count: 0
assert_select "a[href=?]", logout_path
assert_select "a[href=?]", user_path(@user)
delete logout_path
assert_not is_logged_in?
assert_redirected_to root_url
# 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
delete logout_path
follow_redirect!
assert_select "a[href=?]", login_path
assert_select "a[href=?]", logout_path, count: 0
assert_select "a[href=?]", user_path(@user), count: 0
end
end
```
この場合、REDになるが、logged_in?がtrueの場合に限ってlog_outを呼び出すように変更することでGREENになる。
**app/controllers/sessions_controller.rb**
```ruby
class SessionsController < ApplicationController
.
.
.
def destroy
log_out if logged_in?
redirect_to root_url
end
end
```
二つ目の問題は、Userモデルで直接テストする。
ダイジェストが存在しない場合のauthenticated?のテスト
**test/models/user_test.rb**
```ruby
test "authenticated? should return false for a user with nil digest" do
assert_not @user.authenticated?('')
end
```
記憶ダイジェストがnilの場合、falseを返すようにする。
**app/models/user.rb**
```ruby
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
```
このようにすることでバグを解決できる。
## 演習
1.リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。
A.
エラーが表示される。
2.リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。
A,
エラーが表示される。
3.上のコードでコメントアウトした部分を元に戻し、テストスイートが red から greenになることを確認しましょう。
A.
省略。
## [Remember me] チェックボックス
ログインページにチェックボックスを追加する。
**app/views/sessions/new.html.erb**
```html
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
```
また、CSSも追加。
**app/assets/stylesheets/custom.scss**
```css
.checkbox {
margin-top: -10px;
margin-bottom: 10px;
span {
margin-left: 20px;
font-weight: normal;
}
}
#session_remember_me {
width: auto;
margin-left: 0;
}
```
remember meの送信結果を反映
**app/controllers/sessions_controller.rb**
```ruby
class SessionsController < ApplicationController
def new
end
def create
user = User.find_by(email: params[:session][:email].downcase)
if user && user.authenticate(params[:session][:password])
log_in user
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
redirect_to user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
log_out if logged_in?
redirect_to root_url
end
end
```
## 演習
1.ブラウザでcookies情報を調べ、[remember me] をチェックしたときに意図した結果になっているかどうかを確認してみましょう。
A.
省略。
2.コンソールを開き、三項演算子を使った実例を考えてみてください (コラム 9.2)。
A.
省略。
## [Remember me] ボックスをテストする
コントローラの単体テストを使い、ログイン済みのユーザをテストする。テスト用のlog_in_asメソッドをtest_helperファイルのActiveSupport::TestCaseクラス内で定義。
また、統合テストでもlog_in_asメソッドをActionDispatch::IntegrationTestクラスの中で定義する。ただし、統合テストではsessionを直接取り扱えないため、Sessionリソースに対してpostを送信することで代用する。
このように名前を同じにすることで単体テストか、統合テストか意識せずにテストすることができる。
**test/test_helper.rb**
```ruby
ENV['RAILS_ENV'] ||= 'test'
.
.
.
class ActiveSupport::TestCase
fixtures :all
# テストユーザーがログイン中の場合にtrueを返す
def is_logged_in?
!session[:user_id].nil?
end
# テストユーザーとしてログインする
def log_in_as(user)
session[:user_id] = user.id
end
end
class ActionDispatch::IntegrationTest
# テストユーザーとしてログインする
def log_in_as(user, password: 'password', remember_me: '1')
post login_path, params: { session: { email: user.email,
password: password,
remember_me: remember_me } }
end
end
```
log_in_asメソッドが定義できたので、
**test/integration/users_login_test.rb**
```ruby
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
.
.
.
test "login with remembering" do
log_in_as(@user, remember_me: '1')
assert_not_empty cookies['remember_token']
end
test "login without remembering" do
# クッキーを保存してログイン
log_in_as(@user, remember_me: '1')
delete logout_path
# クッキーを削除してログイン
log_in_as(@user, remember_me: '0')
assert_empty cookies['remember_token']
end
end
```
このように書ける。そして、テストが通るか確認。
## 演習
1.リスト 9.25の統合テストでは、仮想のremember_token属性にアクセスできないと説明しましたが、実は、assignsという特殊なテストメソッドを使うとアクセスできるようになります。コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使います。このメソッドにはインスタンス変数に対応するシンボルを渡します。例えばcreateアクションで@userというインスタンス変数が定義されていれば、テスト内部ではassigns(:user)と書くことでインスタンス変数にアクセスできます。本チュートリアルのアプリケーションの場合、Sessionsコントローラのcreateアクションでは、userを (インスタンス変数ではない) 通常のローカル変数として定義しましたが、これをインスタンス変数に変えてしまえば、cookiesにユーザーの記憶トークンが正しく含まれているかどうかをテストできるようになります。このアイデアに従ってリスト 9.27とリスト 9.28の不足分を埋め (ヒントとして?やFILL_INを目印に置いてあります)、[remember me] チェックボックスのテストを改良してみてください。
A.
**app/controllers/sessions_controller.rb**
```ruby
class SessionsController < ApplicationController
def new
end
def create
@user = User.find_by(email: params[:session][:email].downcase)
if @user && @user.authenticate(params[:session][:password])
log_in @user
params[:session][:remember_me] == '1' ? remember(@user) : forget(@user)
redirect_to @user
else
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
end
end
def destroy
log_out if logged_in?
redirect_to root_url
end
end
```
**test/integration/users_login_test.rb**
```ruby
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
def setup
@user = users(:michael)
end
.
.
.
test "login with remembering" do
log_in_as(@user, remember_me: '1')
assert_equal cookies['remember_token'], assigns(:user).remember_token
end
test "login without remembering" do
# クッキーを保存してログイン
log_in_as(@user, remember_me: '1')
delete logout_path
# クッキーを削除してログイン
log_in_as(@user, remember_me: '0')
assert_empty cookies['remember_token']
end
.
.
.
end
```
## [Remember me] をテストする
current_user内のある複雑な分岐処理については、これまでまったくテストが行われていない。
Sessionsヘルパーのテストでcurrent_userを直接テストする。
テスト手順は以下の通り
1.fixtureでuser変数を定義する
2.渡されたユーザーをrememberメソッドで記憶する
3.current_userが、渡されたユーザーと同じであることを確認します
```bash
$ touch test/helpers/sessions_helper_test.rb
```
**test/helpers/sessions_helper_test.rb**
```ruby
require 'test_helper'
class SessionsHelperTest < ActionView::TestCase
def setup
@user = users(:michael)
remember(@user)
end
test "current_user returns right user when session is nil" do
assert_equal @user, current_user
assert is_logged_in?
end
test "current_user returns nil when remember digest is wrong" do
@user.update_attribute(:remember_digest, User.digest(User.new_token))
assert_nil current_user
end
end
```
## 演習
1.リスト 9.33にあるauthenticated?の式を削除すると、リスト 9.31の2つ目のテストで失敗することを確かめてみましょう (このテストが正しい対象をテストしていることを確認してみましょう)。
A.
省略。
## 最後に
```bash
$ rails test
$ git add -A
$ git commit -m "Implement advanced login"
$ git checkout master
$ git merge advanced-login
$ git push
```
また、herokuでマイグレーションを実行する間はアクセスできないようにしておく。
```bash
$ heroku maintenance:on
$ git push heroku
$ heroku run rails db:migrate
$ heroku maintenance:off
```
## まとめ
テストを分岐してテスト本来の威力を発揮しなくなったり、テストの分岐の複雑さも発生して、理解が追いつかない。