#第9章
第8章は基本的なログイン機構だったが、第9章ではブラウザを再起動してもログインできる「remember me」機能を追加する。
これには、永続cookieを使って機能を実装する。
さらにユーザの任意でログイン情報を記録できるようにする。
##Remember me 機能
ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができるようになるもの。
また、remember meのチェックボックスをログインフォームに追加する。
いつものようにトピックブランチを作成し、作業開始。
$ git checkout -b advanced-login
###記憶トークンと暗号化
ややこしい話になってくるので、まとめる。
大事なのは、特にこの2つ
・記憶トークンの生成
・記憶ダイジェストによるトークン認証
記憶トークンを活用して、記憶ダイジェストを認証させる。
Q:トークンとパスワードの違って?
A:トークンはコンピューだが作成・管理する情報。パスワードはユーザーが作成・管理する情報
session
メソッドで保存した情報は、安全性が高い一方で、cookies
メソッドに保存する情報は危険性がある。
それは、セッションハイジャックという攻撃を受ける可能性がある点だ。
記憶トークンを盗み、ユーザーになりすましてログインするというもの。
cookiesを盗む方法は4種類
(1)管理の甘いネットワークを通過するネットワークパケットからパケットスニッファという特殊なソフトウェアで直接cookieを取り出す1 。
(2)データベースから記憶トークンを取り出す。
(3)クロスサイトスクリプティング(XSS)を使う。
(4)ユーザーがログインしているパソコンやスマホを直接操作してアクセスを奪い取る。
①は、SSL化でOK
②は、記憶トークンをハッシュ化すればOK
③は、Railsで自動的に対策してくれるのでOK
④は、個々で対策が必要だけど、ログアウト時にトークンを変更するようにしてセキュリティ上重要な情報を表示する時はデジタル署名を使えばOK
永続的セッションの作成ポイント
①記憶トークンにはランダムな文字列を生成して用いる。
②ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
③トークンはハッシュ値に変換してからデータベースに保存する。
④ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
⑤永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。
usersテーブルにremember_digest
属性を追加する。
$ rails generate migration add_remember_digest_to_users remember_digest:string
to_users
とすることで、マイグレーション対象データベースがusers
テーブルであることをRailsに指示している。
今回、remember_digest
属性はstring
としている。
マイグレーションが自動的に作られた。
class AddRememberDigestToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :remember_digest, :string
end
end
記憶ダイジェストはユーザーが直接読みださないので、remember_digest
カラムにインデックスは追加しない。
あとは、$ rails db:migrate
してOK
記憶トークン作成時に、長くてランダムな文字列を作りたい。
こんな時には、Ruby標準ライブラリのSecureRandom
モジュールにあるurlsafe_base64
を使うと良い。
このメソッドは64種類の文字からなる長さ22のランダムな文字列を返す。
$ rails console
>> SecureRandom.urlsafe_base64
=> "brl_446-8bqHv87AQzUj_Q"
同じパスワードを持つユーザーが複数いても問題ないのと同様、同じ記憶トークンを持つユーザーが複数いても問題ないが、トークンは一意であるほうがより安全。
ユーザーの記憶のステップとして、記憶トークンを作成、そのトークンをダイジェストに変換後、データベースに保存という流れ。
第8章でdigest
メソッドを既に作成したので、新しいトークンを作成するためのnew_token
メソッドを作成する。
この新しいdiges
メソッドでもユーザーオブジェクトが不要なため、Userモデルのクラスメソッドとする。
# ランダムなトークンを返す
def User.new_token
SecureRandom.urlsafe_base64
end
次に、user.remember
メソッドを作成するが、これは記憶トークンをユーザーと関連付けて、トークンに対応する記憶ダイジェストをデータベースに保存するというもの。
まだ、Userモデルには、remember_token
属性はないため、user.remember_token
メソッドを使い、トークンにアクセスするがトークンをデータベースに保存しない形で実装する。
今回はremember_token
のコードを実装するにあたり、attr_accessor
を使って「仮想の」属性を作成する。
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
remember
メソッド1行目の左辺にself
というキーワードを使わないと、remember_token
という名前のローカル変数が作成されるので、self
を付けている。
self
キーワードを与えることで、この代入によってユーザーのremember_token
属性が設定される。
2行のupdate_attribute
メソッドを使って、記憶ダイジェストを更新しいている。update_attribute
メソッドはバリデーションを素通りする。今回はパスワードにアクセスできないので、素通りさせなければならない。
以上から
①User.new_token
で記憶トークンを作成する。
②User.digest
を適用した結果で記憶ダイジェストを更新する。
なので、remember_token
にはbase64の結果が入り、remember_digest
にはbase64をハッシュ化した値が入る。
演習
User.digest
とかUser.new_token
とあるが、これのUserの部分はselfに置き換えられる。なので、self.digest
やself.new_token
となり、この場合のselfはUserクラスを指す。
selfは通常の文脈においては、Userモデルのユーザーオブジェクトのインスタンスを指すが、これはUserクラスを指すのでややこしい。。。。。。
(この辺りちょっとややこしいわ)
class << self
からend
で囲んであげれば、def digest(string)
とdef new_token
という風に書ける。
###ログイン状態の保持
ここから、ユーザーの暗号化済IDと記憶トークンをブラウザの永続cookiesに保存することとする。
これをするために、cookies
メソッドを使う。
これは、value
(値)とexpires
(有効期限)からできており、有効期限は省略可能。下のコードでは有効期限を20年後に設定
cookies[:remember_token] = { value: remember_token,
expires: 20.years.from_now.utc }
今では、Railsにpermanent
という専用メソッドが追加された。
このコード使えば、すっきりとシンプルになる。
cookies.permanent[:remember_token] = remember_token
続いて、ユーザーIDをcookiesに保存するには、session
メソッドと同様に
cookies[:user_id] = user.id
とすればOK
しかしながら、これではユーザーIDが生テキストとしてcookiesに保存され、非常に危険。
そのため、署名付きcookieを使おう。
cookies.signed[:user_id] = user.id
これは、cookieをブラウザに保存する前に安全に暗号化するためのもの。
そして、ユーザーIDと記憶トークンはペアで扱うため、cookieも永続化するが、signed
とpermanentはメソッドチェーンで繋げられる。
cookies.permanent.signed[:user_id] = user.id`
cookiesを設定すれば、以後のビューで下のようにcookiesからユーザーを取り出せる。
User.find_by(id: cookies.signed[:user_id])
こうすることで、自動的にユーザーIDのcookiesの暗号が解除されて、元に戻る。
現在の設計では、攻撃者がユーザーIDと記憶トークンを盗み出来たとしても、本物のユーザーがログアウトすると、ログインできないようになっている。
次に、bcryptを使いcookies[:remember_token]
がremember_digest
と一致するかを確認する。
渡されたトークンがユーザーの記憶ダイジェストと一致するかだが、この一致確認をbcryptで行うが、方法は様々だ。
secure_passwordのソースコード
上記から
BCrypt::Password.new(password_digest) == unencrypted_password
これを参考にしたものを
BCrypt::Password.new(remember_digest) == remember_token
に置き換える。
このコードは、bcryptで暗号化されたパスワードとトークンを**==で直接比較しているようにみえる。
bcryptは複合化できないので、直接比較はしていない。
実は、比較に使っている==**演算子は再定義されていて、実際の比較をコードにするとこんな感じ
BCrypt::Password.new(remember_digest).is_password?(remember_token)
==の代わりに、is_password?
という論理値メソッドを使用している。
記憶トークンと記憶ダイジェストを比較するauthenticated?
メソッドをUserモデルに追加する。
# 渡されたトークンがダイジェストと一致したらtrueを返す
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
end
authenticated?
メソッドのローカル変数として定義してあるremember_token
は、attr_accessor :remember_token
で定義されたアクセサとは異なる点に注意。
コントローラーに、ログインしたユーザーを記憶する処理の準備が整ったので、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
上で書いたremember
メソッド読み出すため、下のコードで定義する。
# ユーザーのセッションを永続的にする
def remember(user)
user.remember
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
ここで第8章で定義したcurrent_user
メソッドは一時セッションしか対応していないので、session[:user_id]
があればここからユーザーを取り出し、なければ対応する永続セッションにログインするといった形にする。
すると下のようなコードになる。
# 記憶トークン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
・session[:user_id]
をuser_id
に格納して、コードの重複を減らしている。
これで永続セッションによって、ブラウザを閉じて開きなおしてもログインし続ける。
しかし、まだブラウザのcookiesを削除する手段が未実装のため、ログアウトできない。
###ユーザーを忘れる
ユーザを忘れるためのメソッドを定義する。
user.forget
メソッドで、user.remember
を取り消せるようにする。
方法としては、記憶ダイジェストをnil
で更新する。
# ユーザーのログイン情報を破棄する
def forget
update_attribute(:remember_digest, nil)
end
cookiesに保存されたユーザーIDと記憶トークンを削除するため、forget
ヘルパーメソッドを定義する。
そして、log_out
ヘルパーメソッドからforget
ヘルパーメソッドを読み出せばOK
# 永続的セッションを破棄する
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つの目立たないバグ
実はバグが、2つある。
①同じサイトを複数のタブorウィンドウで開いているとき、一つのタブでログアウトし、もう一つのタブで再度ログアウトしようとするとエラーになる。
これは、ログアウトすることでcurrent_user
がnil
となるため、forget(current_user)
が失敗してしまうから。
②複数のブラウザでログインしてたとき、例えばFirefoxでログアウトし、Chromeではログアウトせずにブラウザを終了し、再度Chromeで同じページを開くと、この問題が発生する。
Firefoxからログアウトし、user.forget
メソッドによってremember_digest
がnil
になる。一時セッションと永続クッキーがのif文がfalseとる。
なので、current_user
メソッドの評価結果はnil
になる。
一方のChormeでは、session[:user_id]
はnil
になるが、cookies
はブラウザに残り、ブラウザを再起動したときにデータベースからユーザーを見つけることができる。
結果的に
user && user.authenticated?(cookies[:remember_token])
二番目の条件式でエラーになる。Firefoxでログアウトした時に、remember_digest
を削除したのに、Chrome側ではBCrypt::Password.new(remember_digest).is_password?(remember_token)
を実行しようとするから。
解決策としては、remember_digest
が存在しない時にfalse
を返す処理をauthenticated?
に追加してあげる。
まずは、テストを書きエラーをキャッチするところから始める。
assert_redirected_to root_url
# 2番目のウィンドウでログアウトをクリックするユーザーをシミュレートする
delete logout_path
follow_redirect!
assert_redirected_to root_url
の後にdelete logout_path
を追記する。
これでテストが失敗するので、これをパスさせるには下記のようにログイン中だったら、ログアウトするようにすれば良い。
def destroy
log_out if logged_in?
redirect_to root_url
end
これで一つ目の問題はOK
二番目の問題は、統合テストで2つのブラウザをシミュレートするのが難しい。
なので、記憶ダイジェストを持たないユーザーを用意し、authenticated?
メソッドを呼び出せば良い。そして、記憶トークンの値はなんでもOK
test "authenticated? should return false for a user with nil digest" do
assert_not @user.authenticated?('')
end
このテストを成功にするため、記憶ダイジェストが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
で即座にメソッドを終了させる。
これでテストは成功する。
##[Remember me]チェックボックス
Remember meチェックボックスにチェックがあったらログインを保持するというもの。
初めに、ログインフォームにチェックボックスを追加する。
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
チェックボックスをラベルの内側に配置しているのは、ラベルに指定されている範囲内であればどこを押しても、チェックボックスを押したときと同様の動作にするため。
次にCSSを整える。
CSSクラスのcheckbox
とinline
は、Bootstrapにおいてチェックボックスとテキストとして同じ行に配置する。
.checkbox {
margin-top: -10px;
margin-bottom: 10px;
span {
margin-left: 20px;
font-weight: normal;
}
}
#session_remember_me {
width: auto;
margin-left: 0;
}
チェックボックスがオンなら1で、オフなら0となる。
params[:session][:remember_me]
とすることで、1か0の値が取れる。
そして、paramsのハッシュの値によって、if文で分岐させれば良いので
if params[:session][:remember_me] == '1'
remember(user)
else
forget(user)
end
とできるが、「三項演算子」を使うことで1行にまとめられる。
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
なのでセッションコントローラのremember user
を上のに置き換えればOK
三項演算子の書き方
条件分 ? trueの時 : falseの時
##[Remember me]のテスト
[Remember me]の機能が動作したので、テストを書いていく。
###[Remember me]ボックスをテストする
railsチュートリアルの著者によると、最初の実装をしたのようにしたとのこと
params[:session][:remember_me] ? remember(user) : forget(user)
しかし、0も1もRubyではtrue
になるので、上のコードは常にtrueになる。
このようなミスを防ぐためにも、テストを入れようってお話。
今までは、テスト内でユーザーがログインできるようにするために、post
メソッドと有効なsession
ハッシュを使ってログインしていたが、毎回やるのは面倒なので、log_in_as
というヘルパーメソッドを作成し、テスト用のログインを行う。これで無駄に繰り返していたpostとsessionの処理を排除できる。
今回は既存のlog_in
メソッドと名前の混乱を防ぐため、log_in_as
というメソッド名にしてる。
まずは、ログイン済ユーザーをsession
に代入する。
def log_in_as(user)
session[:user_id] = user.id
end
これをtest_helper
ファイルのActiveSupport::TestCase
クラス内で定義する。
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
続いて、統合テストでも同様のヘルパーを実装するが、session
を直接取り扱えないので、代わりにSessionsリソースに対してpost
を送信する。
メソッド名は、単体テストと同様にlog_in_as
とする。
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
ActiveSupport::TestCase
とActionDispatch::IntegrationTest
の両方でlog_in_as
メソッドを定義しているのは、単体テストか統合テストか意識せず、log_in_as
メソッドを呼び出せるようにしたため。
最後に、[Remember me]チェックボックスがオン、オフになっているかのテストを作成する。
オンの時
log_in_as(@user, remember_me: '1')
オフの時
log_in_as(@user, remember_me: '0')
実は、1
はremember me
のデフォルト値なので、省略できるが明示的に表記すると分かりやすくなる。
[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
# cookieを保存してログイン
log_in_as(@user, remember_me: '1')
delete logout_path
# cookieを削除してログイン
log_in_as(@user, remember_me: '0')
assert_empty cookies[:remember_token]
end
ここまででテストを実施すると、成功する。
###[Remember me]をテストする
current_user
メソッドがきちんと動作しているか現時点で不明なのと、それに対するテストが書かれていない。
# 記憶トークン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
なので、途中でraise
を仕込んでテストして通ってしまったらテストがされてないことになる。
そのため、session_helper
に対するテストを入れる。
$ touch test/helpers/sessions_helper_test.rb
でテストファイルを作成する。
手順
①fixtureでuser
変数を定義
②渡されたユーザーをremember
メソッドで記憶
③current_user
が、渡されたユーザーと同じであるか確認
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
二つ目のテストでは、ユーザーの記憶ダイジェストが記憶トークンと正しく対応していない場合に、現在のユーザーがnilになるかをチェックしている。
このおかげで、if文のif user && user.authenticated?(cookies[:remember_token])
の中にあるauthenticated?
をテストしている。
assert_equal
の書き方に関して
assert_equal current_user, @user
と書いてもOKのように思えるが、assert_equal
の引数は
assert_equal <期待する値>, <実際の値>
としなければならない。
従って、assert_equal @user, current_user
この順序で書けばOK。
演習
Failure:
SessionsHelperTest#test_current_user_returns_nil_when_remember_digest_is_wrong [/home/ubuntu/environment/sample_app/test/helpers/sessions_helper_test.rb:17]:
Expected #<User id: 762146111, name: "Michael Example", email: "michael@example.com", created_at: "2021-05-30 06:19:06", updated_at: "2021-05-30 06:19:09", password_digest: [FILTERED], remember_digest: "$2a$04$eIUEhCF4kZ1P25y7fpg9Rukzw0dJhCrTwiK3FJjQkJy..."> to be nil.
current_user
がnil
であることを期待されているが、authenticated?
メソッドの箇所を削除したため、userオブジェクトが返ってきてしまいテストが失敗している。
#最後に
あとは、いつも通りGithubとHerokuにpushして終わり~。
因みにトラフィックの多い本番サイトでは、メンテナンスモードをオンしておくと良い。
メンテナンスモードオン→プッシュ→マイグレート→メンテナンスオフの流れ
$ heroku maintenance:on
$ git push heroku
$ heroku run rails db:migrate
$ heroku maintenance:off
結構複雑になってきたということを実感できる章だと思う。