静的型付け言語原理主義者によるRailsチュートリアル(第9章)
第9章 発展的なログイン機構
- 永続クッキーを使用してブラウザを再起動した後でもすぐにログインできる機能を実装する
9.1 Remember me 機能
- ユーザーのログイン状態をブラウザを閉じた後でも有効にする remember me 機能を実装していく
- ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができる
9.1.1 記憶トークンと暗号化
- セッションの永続化の第一歩として記憶トークンを生成し、
cookies
メソッドによる永続的 cookies の生成や、安全性の高い記憶ダイジェストによるトークン認証にこの記憶トークンを活用する session
メソッドで保存した情報は自動的に安全性が保たれるが、cookies
メソッドに保存する情報はそうではない- cookies を永続化するとセッションハイジャックを受ける可能性がある
- cookies を盗み出す有名な方法とその対処法
- パケットスニッファで直接 cookies を取り出す
- SSL でネットワークデータを暗号化
- DBから記憶トークンを取り出す
- 記憶トークンのハッシュ値を保存
- XSS
- ビューのテンプレートで入力した内容は Rails によって自動的に対策される
- 直接端末を操作
- ログアウト時にトークンを変更し、デジタル署名を用いた情報表示で二次被害を最小限に留める
- パケットスニッファで直接 cookies を取り出す
- 上記を考慮して永続的セッションを作成する
- 記憶トークンはランダム文字列で生成
- トークンは有効期限を設けて cookies に保存
- トークンはハッシュ値に変換して DB に保存
- cookies に保存するユーザー ID は暗号化する
- 永続ユーザー ID を含む cookies を受け取ったら、その ID で DB を検索し、記憶トークンの cookies が DB 内のハッシュ値を一致するかを確認する
- 最初に
remember_digest
属性を User モデルに追加する
rails generate migration add_remember_digest_to_users remember_digest:string
- 相変わらず簡単にマイグレーションが作成される
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0]
def change
add_column :users, :remember_digest, :string
end
end
- 記憶ダイジェストはユーザーが直接読み出すことはないので、
remember_digest
カラムにインデックスを追加する必要はない- 上記マイグレーションをそのまま使用する
- 記憶トークンとして何を使うか
- Ruby 標準ライブラリの
SecureRandom
モジュールにあるurlsafe_base64
メソッドがこの用途にぴったり
- Ruby 標準ライブラリの
- 同一のパスワードを持つユーザーが複数いても問題ないように、同一の記憶トークンを持つユーザーが複数しても問題はない
- とはいえトークンは一意である方が安全
- ユーザーを記憶するには記憶トークンを作成し、そのトークンをダイジェストに変換したものを DB に保存する
- User モデルのクラスメソッドとして新しいトークン作成用の
new_token
メソッドを追加する
class User < ApplicationRecord
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
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
end
- 記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストを DB に保存するために
user.remember
メソッドを作成する - マイグレーションは実行済みなので、User モデルには
remember_digest
属性が追加されているが、remember_token
属性はまだ追加されていないuser.remember_token
メソッドを使ってトークンにアクセスできるようにし、かつトークンを DB に保存せずに実装する必要があるattr_accessor
を使って仮想の属性を作成する
- 最初に
User.new_token
で記憶トークンを作成し、続いてUser.digest
を適用した結果で記憶ダイジェストを更新する
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
9.1.2 ログイン状態の保持
- ユーザーの暗号化済み ID と記憶トークンをブラウザの永続 cookies に保存して永続セッションを作成する準備ができた
- 実際に行うには
cookies
メソッドを使う session
のときと同様にハッシュとして扱える- 個別の cookie は
value
とexpires
からできている
- 個別の cookie は
permanent
メソッドで20年で期限切れになる cookie 設定が可能
- 実際に行うには
- ユーザー ID を cookies にそのまま保存するとセキュリティ上危険なため、cookie をブラウザに保存する前に安全に暗号化する署名付き cookie を使う
- ユーザー ID と記憶トークンはペアで扱う必要があるため、cookie も永続化する
cookies.permanent.signed[:user_id] = user.id
- cookies を設定すると、以後のページのビューで cookies からユーザーを取り出せるようになる
User.find_by(id: cookies.signed[:user_id])
cookies.signed[:user_id]
では自動的にユーザー ID の cookies の暗号が解除され、元に戻る- 続いて bcrypt を使って
cookies[:remember_token]
がremember_digest
と一致することを確認する - 仮に攻撃者が暗号化された ID を奪った場合、記憶トークンがなければそのまま攻撃者がログイン可能になってしまう
- 現在の設計の場合、本物のユーザーがログアウトすると攻撃者はログインできない設計になっている
- 最後に渡されたトークンがユーザーの記憶ダイジェストと一致することを確認する
- User モデルに
authenticated?
メソッドを作る
- User モデルに
class User < ApplicationRecord
attr_accessor :remember_token
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
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
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
end
- ログインしてユーザーを保存するため、
remember
ヘルパーメソッドを追加してlog_in
と連携させてみる
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
log_in
のときと同様に、実際の Sessions ヘルパーの動作はremember
メソッド定義のuser.remember
を呼び出すまで遅延され、そこで記憶トークンを生成してトークンのダイジェストを DB に保存する- 続いて
cookies
メソッドでユーザー ID と記憶トークンの永続cookies
を作成する
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
# ユーザーのセッションを永続的にする
def remember(user)
user.remember
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
# 現在ログインしているユーザーを返す (いる場合)
def current_user
if session[:user_id]
@current_user ||= User.find_by(id: session[:user_id])
end
end
# ユーザーがログインしていればtrue、その他ならfalseを返す
def logged_in?
!current_user.nil?
end
# 現在のユーザーをログアウトする
def log_out
session.delete(:user_id)
@current_user = nil
end
end
- 上記コードでは一時セッション用に作成した
current_user
が正常に動作しない- 永続セッションの場合は
session[:user_id]
が存在すれば一時セッションからユーザーを取り出し、それ以外の場合はcookies[:user_id]
からユーザーを取り出して、対応する永続セッションにログインする必要がある
- 永続セッションの場合は
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
# ユーザーのセッションを永続的にする
def remember(user)
user.remember
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
# 記憶トークン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
# ユーザーがログインしていればtrue、その他ならfalseを返す
def logged_in?
!current_user.nil?
end
# 現在のユーザーをログアウトする
def log_out
session.delete(:user_id)
@current_user = nil
end
end
- 残りの問題はブラウザの cookies を削除してユーザーをログアウトさせること
9.1.3 ユーザーを忘れる
- ユーザーがログアウトできるようにするために、
user.forget
メソッドを定義するuser.remember
が取り消される- 記憶ダイジェストを
nil
で更新する
class User < ApplicationRecord
attr_accessor :remember_token
before_save { self.email = email.downcase }
validates :name, presence: true, length: { maximum: 50 }
VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
validates :email, presence: true, length: { maximum: 255 },
format: { with: VALID_EMAIL_REGEX },
uniqueness: { case_sensitive: false }
has_secure_password
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
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
end
- 永続セッションを終了するには、
forget
ヘルパーメソッドを追加してlog_out
ヘルパーメソッドから呼び出す
module SessionsHelper
# 渡されたユーザーでログインする
def log_in(user)
session[:user_id] = user.id
end
.
.
.
# 永続的セッションを破棄する
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
- これでログアウトできるようになり、テストも通るようになった
9.1.4 2つの目立たないバグ
- 小さなバグが2つ残っている
- ひとつは複数タブで同じサイトを開いているときに、片方でログアウトした後、もう片方でログアウトしようとするとエラーになること
- ユーザーがログイン中にのみログアウトさせる必要がある
- もうひとつはユーザーが複数のブラウザでログインしている場合に起こる
- 片方でログアウトし、もう片方でログアウトせずにブラウザを終了して再度起動したときに問題が発生する
- 終了した方のブラウザの中に
cookies
が残り続けているため、DB からユーザーを見つけることができてしまう
- 終了した方のブラウザの中に
- 片方でログアウトし、もう片方でログアウトせずにブラウザを終了して再度起動したときに問題が発生する
- ひとつは複数タブで同じサイトを開いているときに、片方でログアウトした後、もう片方でログアウトしようとするとエラーになること
- 1つ目の問題への対応
logged_in?
が true の場合に限ってlog_out
を呼び出すように修正する
class SessionsController < ApplicationController
.
.
.
def destroy
log_out if logged_in?
redirect_to root_url
end
end
- 2つ目の問題への対応
- 統合テストで2種類のブラウザをシュミレートするのは困難
- その代わりに User モデルで直接テストする
- 記憶ダイジェストを持たないユーザーを用意し、
authenticated?
を呼び出す
require 'test_helper'
class UserTest < ActiveSupport::TestCase
def setup
@user = User.new(name: "Example User", email: "user@example.com",
password: "foobar", password_confirmation: "foobar")
end
.
.
.
test "authenticated? should return false for a user with nil digest" do
assert_not @user.authenticated?('')
end
end
- 上記テストが成功するように修正する
class User < ApplicationRecord
.
.
.
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
end
9.2 [Remember me] チェックボックス
- [remember me] チェックボックスをログインフォームに追加してでログインを保持する
- チェックボックスはヘルパーメソッドで作成可能
- チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにする
- 必要な準備は終わっているので実装は1行で完了する
- 本当に???
- 必要な準備は終わっているので実装は1行で完了する
- [remember me] チェックボックスの送信結果を処理する
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
9.3 [Remember me] のテスト
9.3.1 [Remember me] ボックスをテストする
- テスト内でユーザーがログインするためのヘルパーメソッド
log_in_as
を定義session
を直接操作して:user_id
キーにuser.id
の値を代入する
- 統合テストでも同様のヘルパーメソッドを実装する
- 統合テストでは
session
を直接取り扱うことができないので、代わりに Sessions リソースに対してpost
を送信することで代用する
- 統合テストでは
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
- チェックボックスの動作を確認するため、チェックボックスがオンになっている場合とオフになっている場合のテストを作成する
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
9.3.2 [Remember me] をテストする
current_user
内のある複雑な分岐処理についてはこれまでまったくテストが行われていない- テスト漏れが疑われる箇所に例外処理を入れて確認する
- 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])
raise # テストがパスすれば、この部分がテストされていないことがわかる
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token])
log_in user
@current_user = user
end
end
end
.
.
.
end
- 上記コードでテストが通ってしまうので、コードは正常ではない
log_in_as
ヘルパーメソッドでは、session[:user_id]
と定義しているため、current_user
メソッドにある複雑な分岐処理を統合テストでチェックすることが非常に困難current_user
を直接テストすれば、この制約を突破することができる
- 永続的セッションのテストを追加
- fixture で
user
変数を定義 - 渡されたユーザーを
remember
メソッドで記憶 current_user
が渡されたユーザーと同じであることを確認
- fixture で
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
9.4 最後に
- 基本的な認証機構に必要な残りの作業は、ログイン状態やログイン済みユーザー ID に基づいて、ページへのアクセス権限を制限する実装だけ
所感
cookies によるログイン状態の永続化を学ぶことができ、cookie まわりの良い復習になった章だった。脆弱性を考慮した実装も行ったので、セキュリティ周りの簡単な学びにも繋がった。
ここ数章は Rails を学ぶというより、Rails を通じて Web アプリケーションの作り方を学ぶことに楽しみを見出すようになってきたように思う。これまでは体系的に Web アプリケーション作成について学ぶ機会がなかったので、非常にありがたい。