まずはユーザーのログイン情報を永続的に記憶する方法を学び、
次にremember meチェックボックスを使って、ユーザーの任意でログイン情報を記憶する方法を学ぶ。
$ git checkout -b advanced-login
##Remember me機能
###記憶トークンと暗号化
セッション永続化のためにすること
- 記憶トークン(remember token)を作成
- cookiesメソッドによる永続的cookiesの作成
- 安全性の高い記憶ダイジェスト(remember digest)によるトークン認証
cookiesメソッドに保存する情報が永続化
-> セッションハイジャックなどの様々な攻撃を受ける可能性がある。
①管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す
②DBから記憶トークンを取り出す
③クロスサイトスクリプティング(XSS)を使う
④ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る
①は7章でSSl化したのでパケットスニッファから読み取られないようにした。
②は記憶トークンをそのままDBに保存するのではなく、代わりにハッシュ値をDBに保存するようにする。
③Railsによって自動的に対策が行われる。ビューのテンプレートで入力した内容を全て自動的にエスケープ(無効化)した。
④システム側で完全に防衛するのは不可能。だが、2次被害を最小限に留めることは可能。要はトークンが盗まれても大丈夫なようにする。
上で説明した設計やセキュリティ上の考慮事項を元に、次の方針で永続的セッションを作成する。
①記憶トークンにはランダムな文字列を生成して用いる
②ブラウザのcookiesにトークンを保存する時には、有効期限を設定する
③トークンはハッシュ値に変換してからデータベースに保存する
④ブラウザのcookiesに保存するユーザーIDは暗号化しておく
⑤永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。
上記の手順はユーザーがログインする時の手順と似ている。
ユーザーログインでは、メールアドレスをキーにユーザーを取り出し、送信されたパスワードがパスワードダイジェストと一致することを、authenticateメソッドで確認してログインしていた。
つまり、ここでの実装はhas_secure_passwordと似た側面を持つ。
まず、remember_digest(記憶ダイジェスト)をUserモデルに追加する。
users | 型 |
---|---|
id | integer |
name | string |
string | |
created_at | datetime |
updated_at | datetime |
password_digest | string |
remember_digest | string |
このデータモデルをアプリケーションに追加するため、マイグレーションを生成 |
$ rails generate migration add_remember_digest_to_users remember_digest:string
ファイル名をto_usersと書くことで、マイグレーションの変更対象がusersテーブルであることをRailsに指示
remember_digest:stringでrember_digest属性のデータ型をStringに指定したレコードをマイグレーションファイルで生成
class AddRememberDigestToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :remember_digest, :string
end
end
記憶ダイジェストはユーザーが直接読み出すことはないので、remember_digestカラムに
インデックスを追加する必要はない。
したがって、そのままマイグレーションに変更を反映させる。
$ rails db:migrate
ここで、記憶トークン作成にはRuby標準ライブラリのSecureRandomモジュールにある
urlsafe_base64メソッドを用いる。
$ rails c
>> SecureRandom.urlsafe_base64
=> "Xjqf9D02yrYA2_Mc9u6nlw"
同一のパスワードを持つユーザーが複数いても問題ないのと同様に、
同一の記憶トークンを持つユーザーが複数いても問題ない。
一方で、セッションハイジャックのリスクなどを考えると、トークンは一意である方がより安全。
base64文字列は、64種類の文字・長さ22の文字列なので、トークンが衝突することはまずありえない。
base64はURLを安全にエスケープするためにも用いられる。
そのため、base64を採用すれば、第10章でアカウントの有効化のリンクやパスワードリセットのリンクでも同じトークンジェネレータを使えるようになる。
つまり、URLのトークン化・パスワードのトークン化にbase64を採用する。
ユーザーを記憶するには、記憶トークンを作成して、そのトークンをダイジェストに変換したものをDBに保存。
fixtureをテストする時にdigestメソッドを作っていたので、新規トークンを作成するために
new_tokenメソッドを作成可能。
この新しいdigestメソッドではユーザーオブジェクトが不要な為、Userモデルのクラスにクラスメソッドとして作成できる。
# 渡された文字列のハッシュ値を返す
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
user.rememberメソッドを作成。
記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストをDBに保存する。
既にDBにあるremember_digest属性とは別に、remember_tokenをDBに保存せずに、user.remember_tokenメソッド(cookiesの保存場所)を使ってトークンにアクセスできるようにする必要がある。
そのために、6章で行った安全なパスワードの問題の時と同様の手法で解決する。
6章では、仮想のpassword属性
と、DB上にあるセキュアなpassword_digest属性の2つを使ったが、
仮想のpassword属性
はhas_secure_passwordメソッドで自動的に作成していた。今回はremember_tokenのコードを自分で書く必要がある。
これを実装するため、4章で触れたattr_accessorを使って「仮想の」属性を作成
class User < ApplicationRecord
# インスタンス変数の定義
attr_accessor :remember_token
# ランダムなトークンを返す
def User.new_token # Userクラスにnew_tokenを渡したクラスメソッドを作成
SecureRandom.urlsafe_base64 # SecureRandomモジュールにbase64でランダムな文字列を生成
end
# 記憶トークンをユーザーオブジェクトに代入し、DBのデータを更新する。
def remember
self.remember_token =
update_attribute(:remember_digest, ) # update_attributeメソッド使ってDBに記憶ダイジェストを更新せよ
end
このように、定義したインスタンス変数remeber_tokenをUserクラスオブジェクトの属性として扱うには、selfメソッド(オブジェクト自身)に対してインスタンス変数を渡す。
selfを付けないと新たに変数が定義されてしまうので気を付ける。
update_attributeメソッドには、バリデーションを素通りする特性がある。
これを利用し、パスワードなどを設定せず属性値を更新する。
以上の点を考慮して
①記憶トークンをBase64で作成し、
②ハッシュ関数Bcryptでハッシュ化し、
③ハッシュ値(記憶ダイジェスト)として更新できるようにする。
#渡された文字列をハッシュ化して、ハッシュ値として返す
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 # Userクラスにnew_tokenを渡したクラスメソッドを作成
SecureRandom.urlsafe_base64 # SecureRandomモジュールにbase64でランダムな文字列を生成
end
# 記憶トークンをUserオブジェクトのremember_token属性に代入し、DBに記憶ダイジェストとして保存
def remember
self.remember_token = User.new_token # 記憶トークンをremember_token属性に代入
update_attribute(:remember_digest, User.digest(remember_token)) # DBのremember_token属性値をBcryptに渡してハッシュ化して更新
end
###ログイン状態の保持
user.rememberメソッドが動作するようになったので、ユーザーの暗号化済みIDと記憶トークンを
ブラウザの永続cookiesに保存して、永続セッションを作成する準備ができた。
実際に行うにはcookiesメソッドを使う。
このメソッドはハッシュとして扱える。
個別のcookiesのハッシュ中身は
value(値) オプションのexpires(有効期限)
からできている。有効期限は省略可能
例えば、20年後に期限切れになる記憶トークンと、同じ値をcookieに保存することで、永続的なセッションを作成できる。
cookies[:remember_token] = { value: remember_token,
expires: 20.years.from_now.utc }
20年で期限切れになるcookies設定はよく使われていて、
Railsにもpermanentという専用メソッドが追加されたほど。
cookies.permanent[:remember_token] = remember_token
###cookies
4章で、Rubyは組み込みクラスを含むあらゆるクラスにメソッドを追加できることを学んだ。
例えば、Stringクラスにpalindrome?というメソッドを新規作成し、
そこに文字列を定義 -> 継承したクラスを作らずにStringクラスに直接埋め込むことが可能。
また、Railsのblank?メソッドはクラス継承の中でもBasicObjectの次のObjectクラスという上の部分に追加されているメソッドだということがわかった。
これにより、"".blank?や" ".blank?としてもtrueになる。
cookies.permanentメソッドでは、cookiesが20年後に期限切れになるように設定している。
(20.years.from_now)
$ rails console
>> 1.year.from_now
=> Wed, 21 Jun 2017 19:36:29 UTC +00:00
>> 10.weeks.ago
=> Tue, 12 Apr 2016 19:36:44 UTC +00:00
また、Railsではこのようなヘルパーも追加している。
>> 1.kilobyte
=> 1024
>> 5.megabytes
=> 5242880
ファイルのアップロードに5.megabytesなどの容量の制限を与えるのに便利。
ユーザーIDをcookiesに保存するには、sessionメソッドで使ったのと同じパターンを使う。
cookies[:user_id] = user.id
このままではIDが生のテキストとしてcookiesに保存されてしまい、アプリケーションのcookiesの形式が見え見え
-> 攻撃者がユーザーアカウントを奪われてしまうかもしれない。
-> これを避けるために、署名付きcookieを使う。
cookies.signed[:user_id] = user.id
これを使えば、cookieをブラウザに保存する前に安全に暗号化できる。
signedメソッドに渡されたcookieは、なりすましを防ぐデジタル署名と、暗号化をまとめて実行される。
さらに、ユーザーidと記憶トークンはペアで扱う必要があるので、cookieも永続化しなくてはならない。
そこで、signedとpermanentをメソッドチェーンで繋いでおく。
cookies.permanent.signed[:user_id] = user.id
cookiesは20年期限付き(permanent)で、署名付きで暗号化されたcookieが完成した。
<例> ページビューでcookiesからユーザーを取り出す場合
User.find_by(id: cookies.signed[:user_id])
このように使う。
id:ハッシュの中でcookies.signedとしているのは、user_idを暗号化して検索してる為。
保存する時にsignedメソッドを使ったので、idをサーチする時もsignedメソッドを使う。
permanentメソッドを使ってないのは、cookieの有効期限は保存する時しか使う必要がない為。
ユーザーidを探す流れ
①ユーザーがidとパスワードを入力
②ユーザーidはcookies.signedで暗号化し、暗号化したidと一致するユーザーidを探す
③パスワードはbase64でトークン化(記憶トークン)し、BCrypt(ハッシュ関数)でハッシュ化する
④入力したパスワードを元に作られたハッシュ値と、既にDBにある記憶ダイジェストが一致することを確認
⑤ユーザーの取り出しに成功
ユーザーが入力したidとパスワードを暗号化した値
DBにある暗号化された値
一致すればユーザーを取り出す。これで成り済ましを防止出来る。
次、攻撃者がidとパスワードのcookieを奪い取ったとしても、最後のBCryptによる、
記憶トークンと記憶ダイジェストの一致が不可能なため、ログインできないようにする。
「記憶トークンと記憶ダイジェストの一致」を設計するため、今からその実装をする。
この一致をbcryptで確認する為の方法は様々あり、
secure_passwordのソースコードを調べてみると、
BCrypt::Password.new(password_digest) == unencrypted_password
上記を参考にし、
BCrypt::Password.new(remember_digest) == remember_token
記憶トークンと記憶ダイジェストを比較し、同一であればtrueを返す。
しかし、本来であればbcryptのハッシュは復号化できない。
bcrypt gemのソースコードには、比較に使っている==演算子が再定義されている。
実際のコード
BCrypt::Password.new(remember_digest).is_password?(remember_token)
is_password?は論理値メソッドであり、==の代わりに比較として使える。
これを実際に実現するために、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: true
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_tokenはグローバル変数定義のattr_accessorで書かれたものとは違い、
新たにremember_token変数を定義(作成)している点に注意。 -
is_password?の引数はメソッド内のローカル変数を参照
-
remember_digestの属性は、データベースのカラムに対応しているため、
Active Recordによって簡単に取得したり保存したりできる。
ログインしたユーザーを記憶する処理の準備が整ったので、
rememberヘルパーメソッドを追加して、log_inと連携させる。
ログインしてユーザを保持
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
cookiesメソッドでユーザーIDと記憶トークンの永続cookiesを作成
current_userへルパーとして定義する
# ユーザーのセッションを永続的にする
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
新しくログインしたユーザーは正しく記憶され、実際にログインしてからブラウザを閉じ、
アプリケーションを再起動してからもう一度ブラウザでアプリケーションを開いてみると、
期待どおり動作していることを確認できる。
###ユーザーを忘れる
ユーザーがログアウトできるようにするため、ユーザーを記憶するためのメソッドと同様の方法で、
ユーザーを忘れるためのメソッドを定義。
user.forgetメソッドによって、user.rememberが取り消される。
具体的には、記憶ダイジェストをnilで更新する。
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
これで永続セッションを終了させる準備が整ったので、
forgetメソッドを呼び出し、
cookies ①user_id(ユーザーID)②remember_token(記憶トークン)を
削除する。
また、ログアウトするために現在のユーザー(@current_user)をnilにし、
sessionにdeleteメソッドを渡す。
# 永続的セッションを破棄する
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
###2つの目立たないバグ
①現在のログアウトメソッドだと、
ユーザーが二つのタブを用意 -> 片方でログアウト -> もう片方でログアウト
するとエラーとなる。
なぜなら、current_userをnilにした後、再びlog_outメソッドを実行すると、
引数のcurrent_userを受け取れないから。
この問題を回避するためには、ユーザーがログイン中の場合にのみログアウトさせる必要がある。
②ユーザーが複数のブラウザ(FirefoxやChromeなど)でログインしていたとき、
例えば、Firefoxでログアウトし、Chromeではログアウトせずにブラウザを終了させ、
再度Chromeで同じページを開くと発生する。
この理由として、まずFirefoxでログアウトすると、user.forgetメソッドによって
remember_digest(記憶ダイジェスト)がnilとなる。
この時点で、Firefoxでまだアプリが正常に動作しているはずなので、log_outメソッドによってユーザーidが削除される。
user_idが消えたことにより、current_userメソッドのユーザーidの条件式で、どちらもfalseとなる。
# 記憶トークン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
current_userメソッドの最終的な評価結果は、nilとなる。
一方、Chromeを閉じた時、session[:user_id]はnilとなる。
(ブラウザを閉じた時に全てのセッション変数の有効期限が切れるため)
cookiesはブラウザの中に残り続けているため、Chromeを再起動して
サンプルアプリケーションにアクセスすると、DBからそのユーザーを見つけることができてしまう。
# 記憶トークン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
結果として、次のif文の条件式が評価される
user && user.authenticated?(cookies[:remember_token])
このとき、userがnilであれば1番目の条件式で評価は終了する。
実際にはnilではないので2番目の条件式まで評価が進み、そのときにエラーが発生。
原因は、Firefoxでログアウトしたときにユーザーのremember_digestが
削除されているにもかかわらず、Chromeにアクセスしたときにauthenticatedメソッドの
BCrypt::Password.new(remember_digest).is_password?(remember_token)
を実行してしまうから。
ここで、remember_digestはnilなので、bcryptライブラリ内部でエラーが発生する。
テスト駆動開発は、この種の地味なバグ修正にはうってつけなので、
2つのエラーをキャッチするテストから書いていく
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
delete logout_pathを追加し、2回目のログアウトでcurrent_userがないためテストが失敗することを確認。
次にこのテストを成功させるため、destroyメソッドに、ログアウトする時はログインしている時という条件式を追加する。
def destroy
log_out if logged_in?
redirect_to root_url
end
end
①の問題はこれで解決。
②の問題は困難なので、Userモデルで直接テスト
記憶ダイジェストを持たないユーザーを用意し、authenticated?を呼び出す。
ユーザーにはsetupメソッドにある@userを使う。
test "authenticated? should return false for a user with nil digest" do # authenticatedメソッドで記憶ダイジェストを暗号化できるか検証
assert_not @user.authenticated?('') # @userのユーザーの記憶ダイジェストと引数で受け取った値が同一ならfalse、異なるならtrueを返す
end
@userには記憶ダイジェストが存在しないため、BCrypt::Password.new(nil)でエラー。
テストを成功させるためは、
記憶ダイジェストがnilの場合、自動でfalseを返すようにすればいい。
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
return false if remember_digest.nil?
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
記憶ダイジェストがnilの場合にはreturnキーワードで即座にメソッドを終了している。
これは処理を中途で終了する場合によく使われるテクニック。