LoginSignup
1

More than 3 years have passed since last update.

Rails Tutorial 第6版 学習まとめ 第9章

Last updated at Posted at 2020-06-20

概要

この記事は私の知識をより確実なものにするためにRailsチュートリアル解説記事を書くことで理解を深め
勉強の一環としています。稀にとんでもない内容や間違えた内容が書いてあるかもしれませんので
ご了承ください。
できればそれとなく教えてくれますと幸いです・・・

出典
Railsチュートリアル第6版

この章でやること

・ユーザーの任意でログイン情報を記憶しておき、ブラウザを再起動してもログインできる機能を追加する。

Remember me機能

↑でも述べた通りブラウザを閉じてもログインを保持する機能を実装する(Remember me)
トピックブランチを作成して作業を始める。

記憶トークンと暗号化

これからの作業や作成物がなかなか難しいので先回り知識を確認する。

・トークンとは
コンピュータが使うパスワードのようなもの。
パスワードは人間が作成して人間が管理するがトークンはコンピュータが作成してコンピュータが管理する。

・永続的cookiesと一時セッションについて
前章で作成した一時セッションはsessionメソッドを使って、cookiesにブラウザ終了時が有効期限のセッションを作成した。
今回はcookiesメソッドを使って期限が無限(正確には20年ほど)のセッションを作成する。
cookiesメソッドではsessionメソッドと違い情報が保護されないかつ、セッションハイジャックと呼ばれる攻撃の的になるため
ユーザーIDと記憶トークンをセットでcookiesに保存し、ハッシュ化したトークンをDBに保存することで
セキュリティを確保する。

・具体的にどういう処理で実装するのか
1. cookiesメソッドを使って暗号化したユーザーIDと記憶トークンをブラウザに保存
2. DBにはハッシュ化した記憶トークン(記憶ダイジェスト)を同時に保存しておく。
3. 次回アクセス時はブラウザに保存されている期限付きcookiesのトークンとDBに保存された記憶ダイジェストを比較して
ログイン処理を自動で行う。

大まかに内容を確認したので
さっそくDBに記憶ダイジェスト(remember_digest)を追加する。
rails g migration add_remember_digest_to_users remember_digest:string
以前説明した通りファイル名末尾にto_usersとつけることでusersテーブルにカラムを追加すると勝手に認識してくれる。

remember_digestはユーザーが読み出せる内容ではないのでインデックスを追加する必要もない。
そのため、このままマイグレートする。

記憶トークンを作成するにあたり、何を使うかだが
長くてランダムな文字列が好ましい。
SecureRandomモジュールのurlsafe_base64メソッドが用途的にマッチしているのでこれを使っていく。
このメソッドは64種の文字を用いて、長さ22のランダム文字列を返すメソッド。
記憶トークンはこのメソッドを使って自動生成することにする。

>> SecureRandom.urlsafe_base64
=> "Rr2i4cNWOwhtDeVA4bnT2g"
>> SecureRandom.urlsafe_base64
=> "pQ86_IsKILLv4AxAnx9iHA"

パスワードと同じくトークンはほかのユーザ⁻と重複しても問題ないが、一意なものを使うことで
ユーザーIDとトークンの両方が奪われでもしない限りはセッションハイジャックなどにもつながらない。

新規でトークンを作成する(生成する)メソッドをuserモデルに定義していく。

  def User.new_token
    SecureRandom.urlsafe_base64
  end

このメソッドもユーザオブジェクトは不要のためクラスメソッドとして定義する。

つぎにrememberメソッドを作成していく。
このメソッドではDBにトークンに対応した記憶ダイジェストを保存する。
DBにremember_digestは存在するがremember_tokenは存在しない。
DBに保存したいのはダイジェストのみだがユーザーオブジェクトに紐づいたトークンに対するダイジェストを保存したいので
トークン属性にもアクセスしたい。
つまりパスワードの時と同じく仮想の属性としてトークンが必要になる。
パスワード実装時はhas_secure_passwordが自動生成してくれたが、今回は
attr_accessorを使ってremember_tokenを作成する。

user.rb
class User < ApplicationRecord
  attr_accessor :remember_token

  # before_save { self.email.downcase! }
  # has_secure_password
  # VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-]+(\.[a-z\d\-]+)*\.[a-z]+\z/i
  # validates :name, presence: true, length:{maximum: 50}
  # validates :email, presence: true, length:{maximum: 255},
  #                   format: {with: VALID_EMAIL_REGEX},uniqueness: true
  # 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
end

rememberメソッドの1行目の
self.remember_token = User.new_token
selfを書かないとremember_tokenというローカル変数が作成されてしまうためここでは必須。

ここではパスワードにアクセスできないためupdate_attributeはバリデーションを素通りさせるために使っている。

演習

1.しっかり動く。
remember_tokenは22文字のランダム生成文字列
remember_digestはそれらのハッシュ化文字列になっていることが見てわかる。

>> user.remember
   (0.1ms)  begin transaction
  User Update (2.4ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2020-06-17 14:30:27.202627"], ["remember_digest", "$2a$12$u8cnBDCQX85e9gBHbQWWNeJq.mxKq8hhpt/FSYMtm2rI2ljWIhRLi"], ["id", 1]]
   (6.1ms)  commit transaction
=> true
>> user.remember_token
=> "lZaXgeF42y5XeP-EEPzstw"
>> user.remember_digest
=> "$2a$12$u8cnBDCQX85e9gBHbQWWNeJq.mxKq8hhpt/FSYMtm2rI2ljWIhRLi"
>> 

2.どちらも動作は同じ
class << selfを使うとendの間まではすべてクラスメソッドとして定義される。
ここでのselfキーワードはインスタンスオブジェクトではなくUserクラスそのものを表しているため認識違いに注意。

ログイン状態の保持

永続cookiesに保存するためにはcookiesメソッドを使う。
sessionとおなじくハッシュとして使える。

cookiesはvalue(値)とexpires(有効期限)を持っていて

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

とすることでcookies[:remember_token]に有効期限が20年のremember_tokenの値を保存できる。
また有効期限が20年というのはよく使われるのでRailsには専用メソッドが追加されていて

cookies.permanent[:remember_token] = remember_token

としても同じ効果になる。

またユーザーIDも永続cookiesに保存するがそのまま保存するとIDがそのまま保存されてしまい、
cookiesがどのような形式で保存されているのか、攻撃者にバレバレになってしまうため、
暗号化する。
暗号化には署名付きcookieを使う。

cookies.signed[:user_id] = user.id
これで安全に暗号化して保存できる。

もちろんユーザーIDも永続cookiesとして保存する必要があるのでpermanentメソッドをつないで使う。
cookies.permanent.signed[:user_id] = user.id

このようにユーザーIDと記憶トークンをセットでcookiesに入れることで
ユーザーがログアウトするとログインできなくなる(DBのダイジェストが削除されるため)

最後にブラウザに保存されたトークンとDBのダイジェストを比較する方法だが
secure_passwordのソースコードを一部パクり

BCrypt::Password.new(remember_digest) == remember_token
このようなコードを使う。
このコードだとremember_digestとremember_tokenを直接比較している。
実は、Bcryptで==演算子が再定義されており、このコードは
BCrypt::Password.new(remember_digest).is_password?(remember_token)
という動作をしている。
これを利用して記憶ダイジェストと記憶トークンを比較するauthenticated?メソッドを定義する。

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

ここでのremember_digestはself.remember_digestと同じである。
DBの記憶ダイジェストと引数に渡した記憶トークンを比較して正しければtrueを返す

さっそくsessions_controllerのログイン処理部にremember処理を追加する。

  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

ここではrememberヘルパーメソッドを使う。(まだ定義していない)

↓rememberヘルパーメソッド
rb:sessions_helper.rb
def remember(user)
user.remember
cookies.signed.permanent[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end

わかりづらいので補足。
Userモデルに定義したrememberメソッドで
user.rb
def remember
self.remember_token = User.new_token
update_attribute(:remember_digest,User.digest(remember_token))
end

ユーザーオブジェクトに対して記憶トークンと記憶ダイジェストを生成する。

sessions_helperに定義したrememberメソッドで
1.Userモデルのrememberメソッドを呼び出し、トークンとダイジェストを生成。
2.cookiesにユーザーIDを暗号化して保存
3.cookiesに1で生成したトークンを保存

の流れ。
メソッド名が被っているので注意。

これでユーザー情報をcookiesに安全に保存できるようになったがログイン状態を見て
動的にレイアウトを変更するために使っていたcurrent_userメソッドが一時セッションにしか
対応していないため修正する。

  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 &. authenticated?(cookies[remember_token])
        log_in user
        @current_user = user
      end
    end
  end

・user_idというローカル変数を使うことでコードの重複を減らしている。
・ブラウザを開いた初回実行時は永続cookiesの処理が行われ、同時にログイン処理も行われるため
ブラウザを閉じるまでは@current_userにユーザーが保存されている。

現時点だとログアウト処理(永続cookies)を削除する方法がないため
ログアウトできない。
(すでにあるログアウトアクションだと一時セッションを削除するだけなので、永続cookiesから情報を取り出して
自動でログインしてしまうためログアウトができない。)

演習

1.ある。
image.png

2.動く。

>> user = User.first
   (1.1ms)  SELECT sqlite_version(*)
  User Load (0.2ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ?  [["LIMIT", 1]]
=> #<User id: 1, name: "take", email: "take.webengineer@gmail.com", created_at: "2020-06-14 02:57:10", updated_at: "2020-06-18 15:18:53", password_digest: [FILTERED], remember_digest: "$2a$12$tAZFCVr39lkPONLS4/7zneYgOE5pcYDM2kX6F1yKew2...">
>> user.remember
   (0.1ms)  begin transaction
  User Update (2.8ms)  UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ?  [["updated_at", "2020-06-18 15:23:21.357804"], ["remember_digest", "$2a$12$h3K3aZSBmXB7wGkNdsBrS.2/UaawMQ199DGMvTDU8upvvOKCzbeba"], ["id", 1]]
   (10.3ms)  commit transaction
=> true
>> user.authenticated?(user.remember_token)
=> true

ユーザーを忘れる

現在、永続cookiesを削除していないため、ログアウトできない。
この問題を解決するためにforgetメソッドを定義する。
このメソッドで記憶ダイジェストをnilにする。
さらにsessions_helperにもforgetメソッドを定義することで
こちらではcookiesに保存されたユーザーIDと記憶トークンも削除する。

  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

一応ログアウト処理の流れをサラッとおさらい。
1. ユーザオブジェクトに保存された記憶ダイジェストをnilにする(Userモデルのforgetメソッド)
2. cookiesのユーザーIDと記憶トークンを削除(sessions_helperのforgetメソッド)
3. 一時セッションのユーザIDを削除
4. カレントユーザ(現在ログイン中のユーザー)をnilにする。

演習

1.削除されている。(実行画面は省略)なおChromeだと以前と同じく一時セッションが残ってしまっているがアプリの動作上は
問題ない。

2つの目立たないバグ

現時点で二つのバグが残っている。かなり面倒なので詳細に説明していく。

1つ目のバグ
複数のタブでログインしていて、タブ1でログアウトした後、タブ2でもログアウトした時。
タブ1内でlog_outメソッドを使ってログアウトした後だとcurrent_userがnilになっている。
この状態でもう一度ログアウトしようとすると削除するcookieが見つからないため失敗する。

2つ目のバグ
別ブラウザで(Chrome、Firefoxなど)ログインしている時。
1. Firefoxでログアウトするとremember_digestがnilになる。
2. Chromeを閉じると一時セッションは削除されるがcookiesは残るため、ユーザーIDからユーザーを見つけることができてしまう。
3. user.authenticated?メソッドで比較するremember_digestがFirefox側で既に削除されているため
比較対象がなくなりエラーが発生する。

このバグを修正するためにまずはバグをキャッチするテストを書き
それを修正するコードを書く。

delete logout_path
これをログインテストのログアウト処理後にもう一度挿入することで2回ログアウトを再現する。

このテストをパスさせるためには
ログイン中だけログアウト処理を行うようにすればいい。

  def destroy
    log_out if logged_in?
    redirect_to root_url
  end

2つ目のバグについて、テストで異なるブラウザ環境を再現するのは難しいため、
Userモデルのremember_digestに関してのテストにとどめる。
具体的にはremember_digestがnilの時にはfalseを返すことをテストする。

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

テストをパスさせるためにauthenticated?メソッドを改良する
rb
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end

remember_digestがnilの場合には即座にreturnキーワードでfalseを返し処理を終了させる。

これで2つのバグが修正される。

演習

1.エラーが発生する。(実行画面は省略。)
2.これもエラーが発生する(EdgeとChrome)
3.確認済み。

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

次はRememberme機能には欠かせない、チェックボックスを実装する(チェックした時だけ記憶する機能)

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

ラベルの内側に配置する理由に関してはhttps://html-coding.co.jp/annex/dictionary/html/label/
このサイトがわかりやすい
つまりラベルに指定されている者のどこをクリックしてもチェックボックスを押したのと同じ動作にできる。

CSSで形を整えたら準備完了。
チェックボックスでparams[:session][:remember_me]に1 or 0が入るようになったので
1の時に記憶するようにすればいい。

三項演算子を使って実装すると

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

remember userの行をこれに差し替えるだけ
ちなみに三項演算子は

  条件文 ? trueの時の処理 : falseの時の処理

という形式で書ける。
ちなみにparamsの数値はすべて文字列で記録されているため条件文の1は''で囲わないと
必ずfalse分が実行されてrememberできなくなるので注意

演習

1.↑でも注意書きを書いたがparamsの条件は'1'としないとうまくいかない。うまくいけばcookiesに値が保存されて
うまく動く。

2.

>> hungry = true
=> true
>> hungry ? puts("I'm hungry now") : puts("I'm not hungry now")
I'm hungry now
=> nil

[Remember me]のテスト

Remembermeが実装できたのでテストも作成していく。

[Remember me]ボックスをテストする

直前の三項演算子で実装したparams[:session][:remember_me] == '1' ? remember(user) : forget(user)
という部分はプログラムを触っている人だと1(真)0(偽)ということで
params[:session][:remember_me] ? remember(user) : forget(user)
と書きたくなるが、チェックボックスはあくまで1と0を返す。
Rubyでは1と0は真偽値ではなくどちらもtrueとして扱われるためこのように書くのは間違いになる。
このようなミスをキャッチできるテストを書かなければならない。

ユーザーを記憶するためにはログインが必要になる。今までは逐一postメソッドを使ってparamsハッシュを送っていたが
毎回やるのはさすがに手間なのでログイン用のメソッドを定義する。
log_inメソッドとの混乱を防ぐためにlog_in_asメソッドとして定義する。

test_helper
class ActiveSupport::TestCase
  # Run tests in parallel with specified workers
  parallelize(workers: :number_of_processors)

  # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
  fixtures :all
  include ApplicationHelper
  # Add more helper methods to be used by all tests here...
  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メソッドをActionDispatch::IntegrationTestとActiveSupport::TestCaseで2回別々に定義しているのは
統合テストではsessionメソッドを使えないから。
そのため統合テストでは代わりにpostリクエストを使ってログインしている。

どちらのテストも同じ名前にすることで統合テストでも単体テストでもログインしたい時には何も気にせずlog_in_asメソッド
を呼べばいい。

log_in_asメソッドを定義したのでRemember_meのテストを実装する。

  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

log_in_as(@user, remember_me:'1')
デフォルト値を設定しているため本来不要だが比較しやすいようremember_me属性も入力している。

演習

1.↑の統合テストでは仮想属性remember_tokenにアクセスできないためcookiesが空でないことだけをテストしていたが
assignsメソッドを使うことで直前にアクセスしたアクションのインスタンス変数を取得できる。
上のテストの例ではlog_in_asメソッド内でsessions_controllerのcreateアクションにアクセスしているため
createアクションで定義されたインスタンス変数の値をシンボルを使って読みだすことができる。
具体的には
現在createアクションで使われているのはuserというローカル変数なのでこれに@をつけて@userという
インスタンス変数に変えてしまうことでassignsメソッドが読み出せるようになる。
あとはテストでassigns(:user)とすることで@userを読み出せる。

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
  end
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] = "Invalid email/password combination"
      render 'new'
    end
  end

[Remember me]をテストする

sessions_helperにログイン処理やセッション関連のヘルパーメソッドを実装してきたが
current_userメソッドの分岐処理に関してテストが行われていない。
その証拠に何も関連性のない適当な文字列を代入してもテストがパスしてしまう。

GREENのテスト↓

sessions_helper.rb
  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 &.authenticated?(cookies[:remember_token])
        log_in user
        @current_user = user
      end
    end

これはまずいのでsessions_helperようのテストファイルを作成する。

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

1つ目のテストでは記憶したユーザとcurrent_userが同じかどうか確かめ、ログインしているかも確かめている。
こうすることでテストがcookiesにユーザーIDが存在した際に中身の処理が動いているか確認できる。

2つ目のテストではremember_digestを書き換えることでrememberメソッドで記録したremember_tokenと対応させない
ようにした際にcurrent_userが期待通りnilを返す、つまりauthenticated?メソッドが
正しく動作しているかテストしている。

また、補足としてassert_equalメソッドは第1引数と第2引数を入れ替えても動作するが
書き方は第1引数に期待値、第2引数に実際の値と書かなければならないことに注意
このように書かなければエラーが発生した際にログの表示がかみ合わなくなってしまう。

そしてこの段階ではもちろんテストは通らない。

入れておいた全く関係のない文を削除することでテストがパスする。
これでcurrent_userのどの分岐もテストできるようになったため回帰バグもキャッチできる。

演習

1.記憶トークンと記憶ダイジェストが正しく対応していなくともuserが存在するだけでif文を通過してしまうため
返り値がnilでなくなってしまう。つまりテストも失敗する。

 FAIL["test_current_user_returns_nil_when_remember_digest_is_wrong", #<Minitest::Reporters::Suite:0x000055b13fd67928 @name="SessionsHelperTest">, 1.4066297989993473]
 test_current_user_returns_nil_when_remember_digest_is_wrong#SessionsHelperTest (1.41s)
        Expected #<User id: 762146111, name: "Michael Example", email: "michael@example.com", created_at: "2020-06-20 15:38:57", updated_at: "2020-06-20 15:38:58", password_digest: [FILTERED], remember_digest: "$2a$04$uoeG1eJEySynSb.wI.vyOewe9s9TJsSoI9vtXNYJxrv..."> to be nil.
        test/helpers/sessions_helper_test.rb:15:in `block in <class:SessionsHelperTest>'

↑current_userの返り値がnilになることが期待されているのに対し、userオブジェクトが返ってしまっていることを
エラーとして出力している。

前の章へ

次の章へ

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
What you can do with signing up
1