はじめに
永続cookie(permanent cookies)を使って[remember me]を実装する。
9.1 Remember me 機能
ユーザーのログイン状態をブラウザを閉じた後でも有効にする[remember me]機能を実装していく。
9.1.1 記憶トークンと暗号化
Cookiesの場合
ブラウザ(cookie)に暗号化したパスワードとDBに入っているハッシュ化しているパスワードが一致するかRailsで認証する。
下記の方針で永続的セッションを作成する。
1. 記憶トークンを保存する場所を用意する。
2. 記憶トークンにはランダムな文字列を生成して用いる。
3. ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
4. トークンはハッシュ値に変換してからデータベースに保存する。
5. ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
6. 永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。
class AddRememberDigestToUsers < ActiveRecord::Migration[6.0]
def change
add_column :users, :remember_digest, :string
end
end
記憶トークン用のカラムを用意。string(文字列)のremember_digest属性を追加する。
# ランダムなトークンを返す
def User.new_token
SecureRandom.urlsafe_base64
end
A–Z、a–z、0–9、"-"、"_"のいずれかの文字(64種類)からなる長さ22のランダムな文字列を返すクラスメソッドUser.new_token
を作成する。
マイグレーションは実行済みなので、Userモデルには既にremember_digest
属性が追加されているが、remember_token
属性はまだ追加されていない。attr_accessor
を使って「仮想の」属性を作成する。
class User < ApplicationRecord
attr_accessor :remember_token
.
.
.
# 永続セッションのためにユーザーをデータベースに記憶する
def remember
self.remember_token = User.new_token
# 自分自身のremember_tokenに新しいtokenを代入する。保存されない
update_attribute(:remember_digest, User.digest(remember_token))
# :remember_digestにremember_tokenをハッシュ化したものを保存する。
# 頭のselfが省略されている
end
end
user.remember_token
メソッドを使ってトークンにアクセスできるようにし、かつ、トークンをDBに保存せずに 実装する。
演習 1
コンソールを開き、データベースにある最初のユーザーを変数userに代入してください。その後、そのuserオブジェクトからrememberメソッドがうまく動くかどうか確認してみましょう。また、remember_tokenとremember_digestの違いも確認してみてください。
>> user = User.first
(1.9ms) SELECT sqlite_version(*)
User Load (1.3ms) SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? [["LIMIT", 1]]
=> #<User id: 1, name: "Rails Tutorial", email: "example@railstutorial.org", created_at: "2021-02-13 18:10:29", updated_at: "2021-02-13 18:10:29", password_digest: [FILTERED], remember_digest: nil>
>> user.remember
(0.1ms) begin transaction
User Update (8.2ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", "2021-02-23 08:22:41.315576"], ["remember_digest", "$2a$12$y6wygDQ4HwYF6smxv8E1Y.K6Nz.3tNhCHpJVEDKHOBFs9MBu/NuLe"], ["id", 1]]
(14.1ms) commit transaction
=> true
>> user.remember_token
=> "JTiObGhfDhCqI-mYx7jGrw"
>> user.remember_digest
=> "$2a$12$O6z7y1SMzDbXqIBa5OoYrOXv4nKB132fjA4WQ7MQxubweRSg/5nYa"
演習 2
リスト 9.3では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。しかし実は、より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。1つはややわかりにくく、もう1つは非常に混乱するでしょう。テストスイートを実行して、ややわかりにくいリスト 9.4の実装でも、非常に混乱しやすいリスト 9.5の実装でも、いずれも正しく動くことを確認してみてください。ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。
確認だけなので省略
9.1.2 ログイン状態の保持
cookies
メソッドを使い、永続セッションを作成する。
value
(値)とオプションのexpires
(有効期限)が必要。有効期限は省略できる。
cookies[:remember_token] = { value: remember_token,
expires: 20.years.from_now.utc }
cookies.permanent[:remember_token] = remember_token
# Railsの20年で期限切れになるcookies設定
# ユーザーのセッションを永続的にする
def remember(user)
# 必ず引数を設定する
user.remember
# DBに書き込む
cookies.permanent.signed[:user_id] = user.id
# cookieに暗号化したuser.idを代入する(20年で期限切れになる)
cookies.permanent[:remember_token] = user.remember_token
# cookieにuser.remember_tokenを代入する(20年で期限切れになる)
end
signed: 暗号化、復号化するときのメソッド
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token)
# ハッシュ値remember_digestと平文remember_tokenがあっているかBCryptがチェック
end
def current_user
if (user_id = session[:user_id])
# session[:user_id]をuser_idに代入してnilかどうか確認
@current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.signed[:user_id])
# cookies.signed[:user_id]をuser_idに代入してnilかどうか確認
user = User.find_by(id: user_id)
#
if user && user.authenticated?(cookies[:remember_token])
# nilかどうか確認(左側)して、引数にcookies内の:remember_tokenを引数にuser.authenticatedする
log_in user
@current_user = user
end
end
end
session[:user_id]
もcookies.signed[:user_id]
もnilの場合はnilを返す。
演習 1
ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。
確認のみなので省略
演習 2
コンソールを開き、リスト 9.6のauthenticated?メソッドがうまく動くかどうか確かめてみましょう。
確認のみなので省略
9.1.3 ユーザーを忘れる
ユーザーがログアウトできるようにする。
user.forget
メソッドでuser.remember
が取り消される(nil
で更新する)
# ユーザーのログイン情報を破棄する
def forget
self.update_attribute(:remember_digest, nil)
# nilで更新する。削除する。
end
# 永続的セッションを破棄する
def forget(user)
user.forget
# remember_digestを削除
cookies.delete(:user_id)
# user_idを削除
cookies.delete(:remember_token)
# remember_tokenを削除
end
# 現在のユーザーをログアウトする
def log_out
forget(current_user)
# cookie情報を削除
session.delete(:user_id)
@current_user = nil
end
演習 1
ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。
確認のみなので省略
9.1.4 2つの目立たないバグ
ユーザーがタブを複数開いているとき、複数のブラウザでログインしているときにそれぞれバグが発生する。
前者はユーザーがログイン中の場合にのみログアウトさせる必要があり、後者はremember_digest
が存在しないときはfalseを返す処理をauthenticated?
に追加する必要がある。
# 記憶トークンcookieに対応するユーザーを返す
def current_user
if (user_id = session[:user_id])
# log_outメソッドによってfalseになる
@current_user ||= User.find_by(id: user_id)
elsif (user_id = cookies.signed[:user_id])
# log_outメソッドによってfalseになる
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
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
演習 1
リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。
確認のみなので省略
演習 2
リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。
確認のみなので省略
演習 3
上のコードでコメントアウトした部分を元に戻し、テストスイートが red から green になることを確認しましょう。
確認のみなので省略
9.2 [Remember me]チェックボック
params[:session][:remember_me] == '1' ? remember(user) : forget(user)
もし、params[:session][:remember_me]が「1」だったら(チェックボックスにチェックが入っていたら)、ログイン情報を記憶するためにrememberメソッドを呼び出す。「1」でなかったら記憶しないのでforgetメソッドを呼び出す。
三項演算子
if boolean?
(true)var = foo
else
(false)var = bar
end
三項演算子を使うと下記のようになる。
var = boolean? ? (true) : (false)
演習 1
ブラウザでcookies情報を調べ、[remember me]をチェックしたときに意図した結果になっているかどうかを確認してみましょう。
確認のみなので省略。
演習 2
コンソールを開き、三項演算子を使った実例を考えてみてください(コラム 9.2)。
> def first(type)
> type = "fire" ? "ヒトカゲ" : "フシギダネ"
> end
=> :type
> first("fire")
=> "ヒトカゲ"
# ゼニガメごめんね
9.3 [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')
# cookieを保存してログイン
# remember me チェックする
delete logout_path
# ログアウトする
log_in_as(@user, remember_me: '0')
# cookieを削除してログイン
# remember me チェックしない
assert_empty cookies[:remember_token]
# cookiesには情報が入ってないはず
end
演習 1
リスト 9.25の統合テストでは、仮想のremember_token属性にアクセスできないと説明しましたが、実は、assignsという特殊なテストメソッドを使うとアクセスできるようになります。コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使います。このメソッドにはインスタンス変数に対応するシンボルを渡します。例えばcreateアクションで@userというインスタンス変数が定義されていれば、テスト内部ではassigns(:user)と書くことでインスタンス変数にアクセスできます。本チュートリアルのアプリケーションの場合、Sessionsコントローラのcreateアクションでは、userを(インスタンス変数ではない)通常のローカル変数として定義しましたが、これをインスタンス変数に変えてしまえば、cookiesにユーザーの記憶トークンが正しく含まれているかどうかをテストできるようになります。このアイデアに従ってリスト 9.27とリスト 9.28の不足分を埋め(ヒントとして?やFILL_INを目印に置いてあります)、[remember me]チェックボックスのテストを改良してみてください。17
def create
@user = User.find_by(email: params[:session][:email].downcase)
# if user && user.authenticate(params[:session][:password])
if @user && @user.authenticate(params[:session][:password])
log_in @user
params[:session][:remember_me] == '1' ? remember(@user) : forget(@user)
redirect_to @user
else
# alert-danger => 赤色のフラッシュ
flash.now[:danger] = 'Invalid email/password combination'
render 'new'
# GET /users/1 => show template
# render 'new'(0回目)
end
end
test "login with remembering" do
log_in_as(@user, remember_me: '1')
assert_equal cookies[:remember_token], assigns(:user).remember_token
end
9.3.2 [Remember me]をテストする
raiseメソッド: 例外(わざとバグ)を発生させるメソッド。きちんとテストがされているか確認する。
require 'test_helper'
class SessionsHelperTest < ActionView::TestCase
def setup
@user = users(:michael)
# michaelを@userに代入
remember(@user)
# @userの情報をrememberに入れる
end
test "current_user returns right user when session is nil" do
assert_equal @user, current_user
# @userとcurrent_user(ログインしている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))
# @userのremember_digestを新しいものに書き換える
assert_nil current_user
# 新しいものに書き換えたのでcurrent_userはnilを返しているはず
end
end
演習 3
リスト 9.33にあるauthenticated?の式を削除すると、リスト 9.31の2つ目のテストで失敗することを確かめてみましょう(このテストが正しい対象をテストしていることを確認してみましょう)。
確認のみなので省略。
さいごに
第9章からほぼ理解できないまま飛ばす箇所が出てきました。
Railsチュートリアルを最後まで進めたら戻ってこようと思います。