# 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を追加する。 ![userモデル](https://railstutorial.jp/chapters/images/figures/user_model_remember_digest.png) このようにするには、以下のコードを実行する。   ```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 ``` ## まとめ テストを分岐してテスト本来の威力を発揮しなくなったり、テストの分岐の複雑さも発生して、理解が追いつかない。