はじめに
筆者は非IT業界から独学でRails学習中で、備忘録目的で執筆しています。
個人的に本章はこれまでの章より難易度が高かったです💦(2周目確認・整理が必須と感じました、不足等あれば訂正致しますのでおっしゃっていただければ幸いです)
筆者は安川さん講義の動画版を模写するような形で学んだため、演習でなく全体を流す程度に参考にしてもらえたら嬉しいです。
<参考>
Ruby on Rails チュートリアル 第9章 永続的セッション(cookies remember me 記憶トークン ハッシュ)を解説
個人的に非常に理解しやすい記事でした。
(Cookieの攻撃手法などは改めて追記したいほどです)
9.1 Remember me 機能
Remember me とは
主にアカウント認証に用いられる機能の一つで、ユーザーがログイン時に入力したアカウント情報をサーバ側で持つ(UI視点での)機能。
ログインのときに「このユーザーIDとパスワードを情報を記録しますか?」のあれ。
< 参考 >
認証におけるRemember Meの仕組み
Remember-Me認証とSpringSecurity
今回のRemember me導入にはCookiesを用いる。
Cookies
Cookiesはユーザー(クライアント側)のブラウザにあるデータ保存領域。
<参考>
セッションとかクッキーとかよくわからないのでRailsチュートリアルでWebアプリケーション作りながら勉強してみた
前記事8章の基本的なログイン機構のsessionメソッドではユーザーIDを保存できたが、この情報はブラウザを閉じると消えてしまう。
なので今回はセッションの永続化の第一歩として記憶トークン (remember token) を生成しcookiesメソッドによる永続的Cookiesの作成や、安全性の高い記憶ダイジェスト (remember digest) によるトークン認証にこの記憶トークンを活用し、セキュリティを考慮して以下の方針で永続的セッションを作成する。(公式より)
< 手順 >
1. 記憶トークンはランダムな文字列を生成して用いる。
2. ブラウザのcookiesにトークンを保存するときは有効期限を設定する。
3. トークンはハッシュ値に変換してからDBに保存する。
4. ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
5. 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでDBを検索し、記憶トークンのcookiesがDB内のハッシュ値と一致することを確認する。
※ トークンとはパスワードの平文と同じような秘密情報(コンピューターが作成・管理する情報)のこと。
ブランチ作成&チェックアウト
git checkout -b advanced-login
まずはパスワードダイジェストと同じように記憶トークンを保存する場所remember_digestの作成
$ rails generate migration add_remember_digest_to_users remember_digest:string
確認後、
$ rails db:migrate
手順1の記憶トークンのためのランダム文字列を出す。urlsafeはURLで使える文字列をランダムで生成するもの。これでできた文字列をユーザーさんのブラウザに置かせてもらう。
トークン生成用メソッドを追加する
# ランダムなトークンを返す
def User.new_token
SecureRandom.urlsafe_base64
end
rememberメソッドを追加する。このメソッドは、記憶トークンをユーザーと関連付け、記憶トークンに対応する記憶ダイジェストをDBに保存する。
ハッシュ変換するためhas_secureパスワードと同じように実装するが、attr_accessor(メソッドを定義するメソッド)を使って仮想のremember_token属性をUserクラスに追加する(rememberはよく使われるのでこれで下記意義メソッドも定義される)。
参考
Rails Tutorialの知識から【ポートフォリオ】を作って勉強する話 #9 永続セッション, cookie編
クッキー(cookie)とは?初心者でも分かるように図解
attr_accessor :remember_token
|| 下記の略
\\//
\/
def remember=(taken)
@remember = token
end
def remember
@remember
end
今回はインスタンスメソッドなので、(ローカル変数にならないよう)self
を使う。
class User < ApplicationRecord
attr_accessor :remember_token
#省略
# 永続セッションのためにユーザ-をデータベースに記憶する(クッキー認証のための準備 トークン残し)
def remember
# new_tokenを発行する
self.remember_token = User.new_token
# remember_digestの中にUser.digest(remember_token)を入れる
# update_attributeで無駄にvalidationをかける必要がなくハッシュ化して入る
self.update_attribute(:remember_digest,User.digest(remember_token))
end
ユーザのブラウザ内にあるキー🔑とremember_digestを使って認証させる必要がある。今回はauthenticateメソッドのレシーバに署名付きuser_id(cookie)を使う。これはcookieをブラウザに保存する前に安全に暗号化するためのもの(暗号化したuser_id)でブラウザに置いておく。
authenticateメソッド
引数を渡すと暗号化し、その文字列がパスワード(〇〇digest等)と一致するとUserオブジェクトを返し、間違っているとfalseを返すメソッド(公式第6章より)。
ユーザー(ブラウザ)保存のクッキーとremember_digestを一致させるのにauthenticateメソッドを使うが、このメソッドは@user(今回はemailでなく署名付きで暗号化されたuser_id ex.数字892350)を復号化して(数字892350 → user_id:5)をfind_byメソッドで呼び出してユーザーオブジェクトを引っ張って、その後authenticateメソッドで認証がはじまる。
引数にremember_tokenを渡せばDBの中身と確認できる(左selfが自分、右がDB)
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(self.remember_digest).is_password?(remember_token)
end
def create
log_in user
remember user #=> SessionsHelperの。 ログイン後にrememberでnew_token発行してDB保存 save to DB
#=> (cokies[:token] クッキー追加必要なのでsessionsヘルパーに引数付きremember(user)を追加する)
#略
ユーザーを記憶する
# ユーザーのセッションを永続的にする
def remember(user) # => DB: remember_digest
user.remember
cookies.permanent.signed[:user_id] = user.id #=> coolies(クッキーに指定)、permanent(期限指定20年とする)、signed→暗号化
cookies.permanent[:remember_token] = user.remember_token
end
ヘルパー内のcurrent_userメソッドに追加する。
# 記憶トークンcookieに対応するユーザーを返す(新型)
#=> ユーザオブジェクトが帰るか、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]) #=> signedで復号化
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token])
log_in user
@current_user = user
end
end
end
9.1.3 ユーザーを忘れる
ユーザーのログアウトのためにはユーザーを忘れる(DBから消す)必要があるので
# ユーザーのログイン情報を破棄する
def forget
self.update_attribute(:remember_digest, nil)
end
Cookie側からも消す
# 永続的セッション(Cookie)を削除する
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
9.1.4 2つの目立たないバグ
1つ目のバグは、この2つのタブで順にログアウトさせると、current_userがnilとなってlog_outメソッド内のforget(引数current_user → nilで)失敗してエラーになる(SS参照)。
ユーザーがログイン中の場合にのみログアウト(log_outメソッドを実行)させるため、sessionsコントローラを編集する。
# DELETE /logout
def destroy
log_out if logged_in? # ログイン中の場合のみログアウト(log_outメソッドを実行)する
redirect_to root_url
end
回帰バグを防ぐため統合テストにrootへリダイレクト後にdeleteを追記して確かめる。
assert_redirected_to root_url
# 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
delete logout_path
テストしてRED → GREEN。
もう一つのバグは、2種類のブラウザで以下のようなときに発生。
1.ChromeとFirefoxでログイン(両方にCookieが入る)
2.Chromeでログアウト
3.Firefoxでログアウトせず閉じる
4.Firefoxでホームにアクセスしようとすると、エラー発生。
ChromeではログアウトしてるのでCookieの中身は削除され、(user.forgetメソッドによって)remember_digestもnilでBCryptは失敗するが、
FirefoxではCookieはあるが、remember_digestがnilなのでBCryptが失敗するだけでなく(デフォルトで)エラーを返してしまう。
digestダイジェストが存在しない場合のauthenticated?のテストとauthenticated?メソッドにBCrypt前にreturnを追加。
test "authenticated? should return false for a user with nil digest" do
assert_not @user.authenticated?('')
end
def authenticated?(remember_token)
return false if remember_digest.nil? #authenticated?を更新して、ダイジェストが存在しない場合に対応
BCrypt::Password.new(self.remember_digest).is_password?(remember_token)
end
備考: failuresとerrorsの違い
failures → 期待された値にならなかったときに出る。クライアントが欲しいシステムまたはコンポーネントの機能が性能要件を満足していない時などで発生。テスターが開発中に発見できる問題とされる。
errors → 期待の値とか関係なく異常・例外的なケースに出る。変数名のミス、間違ったログイン、誤ったループ条件などで発生する。筆者がチュートリアル迷走中によく現れる
<参考>
wrong、extra、error、bug、failure、faultの違い
9.2 [Remember me] チェックボックス
[remember me] チェックボックスをログインフォームview画面に追加する
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
.checkbox {
margin-top: -10px;
margin-bottom: 10px;
span {
margin-left: 20px;
font-weight: normal;
}
}
#session_remember_me {
width: auto;
margin-left: 0;
}
チェックだけして「Log in」でエラーを表示すると、チェックなしの文字列「'0'」が選択されているのでok(「'1'」はあり)
remember_me: '0'
ログインフォームの編集が終わったので、チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにする。
if user && user.authenticate(params[:session][:password])
log_in user
# [remember me] チェックボックスの送信結果を処理する
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
9.3 [Remember me] のテスト
9.3.1 [Remember me] ボックスをテストする
単体テスト、統合テストとも同じメソッド名でテストヘルパーに入れる。
統合テストの特徴としては下記が注意どころ。
1.ActionDispatch::IntegrationTestクラスの中で定義
2.統合テストではsessionを直接取り扱えない為、代わりにSessionsリソースにpostリクエストを送信することで代用
class ActiveSupport::TestCase
省略
# テストユーザーとしてログインする
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
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
raiseの追加
raiseはコードブロック中に例外を発生させるもので、Kernelモジュールのインスタンスメソッドにあたる。
<参考>
begin~rescue~ensureとraiseを利用した例外処理の流れと捕捉について
今回はテストしていなかったcurrent_use内にraiseを挿入し、もう一度テストがパスすれば、この部分がテストされていない(マズい)ことがわかる。
# 記憶トークン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])
raise # テストがパスすれば、この部分がテストされていない(マズい状況な)ことがわかる
user = User.find_by(id: user_id)
raiseで例外を入れてもテストが通過(error 0)してしまうので、別途ファイルにテストを追加する。
テスト内容としては以下の通り(公式より)
1. user変数(@user)を定義
2. 渡されたユーザー(@user)をrememberメソッドで記憶
3. current_userが、渡されたユーザー(@user)と同じであることを確認
4. ユーザーの記憶ダイジェストが記憶トークンと正しく対応していない場合に現在のユーザーがnilになるかどうかをチェック
# 永続的セッションのテスト
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
無事エラー確認できたのでok(raise外すとGREEN)
Gitコミット、herokuデプロイ(本番意識ならメンテナンスモード意識などが必要だが、今回は利用中のユーザー無視のためmasterへ)。
$ git add -A
$ git commit -m "Finish ch09"
$ git checkout master
$ git merge advanced-login
$ git push heroku master
$ heroku run rails db:migrate
備考: メンテナンスモード
herokuにデプロイしても、heroku上でマイグレーションを実行するまでの間は一時的にアクセスできない状態 (エラーページ) になるのでトラフィック( (一定時間に)通信回線やネットワーク上で送受信される信号やデータ量のこと )の多い本番サイトでは変更する前にメンテナンスモードをオンが推奨とされる。
$ heroku maintenance:on
$ git push heroku
$ heroku run rails db:migrate
$ heroku maintenance:off
2つ開いて片方でログイン/ログアウトしてCookieクリア等の確認ができれば完了!
終わりに
最後まで見て頂きありがとうございました!