###概要
この記事は私の知識をより確実なものにするためにRailsチュートリアル解説記事を書くことで理解を深め
勉強の一環としています。稀にとんでもない内容や間違えた内容が書いてあるかもしれませんので
ご了承ください。
できればそれとなく教えてくれますと幸いです・・・
###この章でやること
・ユーザーの任意でログイン情報を記憶しておき、ブラウザを再起動してもログインできる機能を追加する。
###Remember me機能
↑でも述べた通りブラウザを閉じてもログインを保持する機能を実装する(Remember me)
トピックブランチを作成して作業を始める。
####記憶トークンと暗号化
これからの作業や作成物がなかなか難しいので先回り知識を確認する。
・トークンとは
コンピュータが使うパスワードのようなもの。
パスワードは人間が作成して人間が管理するがトークンはコンピュータが作成してコンピュータが管理する。
・永続的cookiesと一時セッションについて
前章で作成した一時セッションはsessionメソッドを使って、cookiesにブラウザ終了時が有効期限のセッションを作成した。
今回はcookiesメソッドを使って期限が無限(正確には20年ほど)のセッションを作成する。
cookiesメソッドではsessionメソッドと違い情報が保護されないかつ、セッションハイジャックと呼ばれる攻撃の的になるため
ユーザーIDと記憶トークンをセットでcookiesに保存し、ハッシュ化したトークンをDBに保存することで
セキュリティを確保する。
・具体的にどういう処理で実装するのか
- cookiesメソッドを使って暗号化したユーザーIDと記憶トークンをブラウザに保存
- DBにはハッシュ化した記憶トークン(記憶ダイジェスト)を同時に保存しておく。
- 次回アクセス時はブラウザに保存されている期限付き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を作成する。
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ヘルパーメソッド
def remember(user)
user.remember
cookies.signed.permanent[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
わかりづらいので補足。
Userモデルに定義したrememberメソッドで
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から情報を取り出して
自動でログインしてしまうためログアウトができない。)
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
一応ログアウト処理の流れをサラッとおさらい。
- ユーザオブジェクトに保存された記憶ダイジェストをnilにする(Userモデルのforgetメソッド)
- cookiesのユーザーIDと記憶トークンを削除(sessions_helperのforgetメソッド)
- 一時セッションのユーザIDを削除
- カレントユーザ(現在ログイン中のユーザー)をnilにする。
#####演習
1.削除されている。(実行画面は省略)なおChromeだと以前と同じく一時セッションが残ってしまっているがアプリの動作上は
問題ない。
####2つの目立たないバグ
現時点で二つのバグが残っている。かなり面倒なので詳細に説明していく。
1つ目のバグ
複数のタブでログインしていて、タブ1でログアウトした後、タブ2でもログアウトした時。
タブ1内でlog_outメソッドを使ってログアウトした後だとcurrent_userがnilになっている。
この状態でもう一度ログアウトしようとすると削除するcookieが見つからないため失敗する。
2つ目のバグ
別ブラウザで(Chrome、Firefoxなど)ログインしている時。
- Firefoxでログアウトするとremember_digestがnilになる。
- Chromeを閉じると一時セッションは削除されるがcookiesは残るため、ユーザーIDからユーザーを見つけることができてしまう。
-
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?メソッドを改良する
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に値が保存されて
うまく動く。
>> 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メソッドとして定義する。
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を読み出せる。
test "login with remembering" do
log_in_as(@user, remember_me: '1')
assert_equal cookies[:remember_token] , assigns(:user).remember_token
end
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のテスト↓
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
ようのテストファイルを作成する。
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オブジェクトが返ってしまっていることを
エラーとして出力している。