9.1 Remember me 機能
ユーザーのログイン状態をブラウザを閉じた後でも有効にする [remember me] 機能永続クッキー (permanent cookies) を使ってこの機能を実現していきます
9.1.1 記憶トークンと暗号化
1:記憶トークンにはランダムな文字列を生成して用いる。
2:ブラウザのcookiesにトークンを保存するときには、有効期限を設定する。
3:トークンはハッシュ値に変換してからデータベースに保存する。
4:ブラウザのcookiesに保存するユーザーIDは暗号化しておく。
5:永続ユーザーIDを含むcookiesを受け取ったら、そのIDでデータベースを検索し、
記憶トークンのcookiesがデータベース内のハッシュ値と一致することを確認する。
最初に、必要となるremember_digest属性をUserモデルに追加
rails generate migration add_remember_digest_to_users remember_digest:string
rails db:migrate
あるurlsafe_base64メソッドを使う
app/models/user.rb
class User < ApplicationRecord
# ランダムなトークンを返す。追加
def User.new_token
SecureRandom.urlsafe_base64
end
end
実装計画としては、user.rememberメソッドを作成することで、
このメソッドは記憶トークンをユーザーと関連付け、トークンに対応する記憶
ダイジェストをデータベースに保存します。
Userモデルには既にremember_digest属性が追加されていますが
remember_token属性はまだ追加されていません。
このためuser.remember_tokenメソッドを使ってトークンにアクセスできるようにし、トークンをデータベースに保存せずに実装する必要があリます。
仮想のpassword属性はhas_secure_passwordメソッドで自動的に作成されましたが、今回はremember_tokenのコードを自分で書く必要があります。
attr_accessorを使って「仮想の」属性を作成します。
class User < ApplicationRecord attr_accessor :remember_token . . . def remember self.remember_token = ...update_attribute(:remember_digest, ...) end end
selfキーワードを与えると、この代入によってユーザーのremember_token属性が期待どおりに設定されます。
rememberメソッドの2行目では、update_attributeメソッドを使って記憶ダイジェストを更新しています。
ユーザーを記憶するには、記憶トークンを作成して、そのトークンをダイジェストに変換したものをデータベースに保存します。
新しいトークンを作成するためのnew_tokenメソッドを作成
remember
メソッドをUserモデルに追加する green
app/models/user.rb
class User < ApplicationRecord
attr_accessor :remember_token 中略 ↑追加↓
# 永続セッションのためにユーザーをデータベースに記憶する
def remember
self.remember_token = User.new_tokenupdate_attribute(:remember_digest, User.digest(remember_token)) end
end
演習
1:コンソールを開き、データベースにある最初のユーザーを変数user
に代入してください。その後、そのuser
オブジェクトからremember
メソッドがうまく動くかどうか確認してみましょう。また、remember_token
とremember_digest
の違いも確認してみてください。
>> user = User.first User Load (0.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: "2017-02-09 07:39:54", updated_at: "2017-02-09 07:39:54", password_digest: "$2a$10$NCGVBX5axnKjyHVDxfUo5eco71WXZlmOX/9/gfBb2Gm...", remember_digest: nil> >> user.remember (0.1ms) begin transaction SQL (0.6ms) UPDATE "users" SET "updated_at" = ?, "remember_digest" = ? WHERE "users"."id" = ? [["updated_at", 2017-02-12 05:54:01 UTC], ["remember_digest", "$2a$10$DZBeoa8QGU5fxq9.stitr.SIH7uSUlLliaunThO2yWeZevK59.fge"], ["id", 1]] (10.2ms) commit transaction => true >> user.remember_token => "9jkAE-aydjad4QFuCj7TCQ" >> user.remember_digest => "$2a$10$DZBeoa8QGU5fxq9.stitr.SIH7uSUlLliaunThO2yWeZevK59.fge"
2:app/models/user.rb
では、明示的にUserクラスを呼び出すことで、新しいトークンやダイジェスト用のクラスメソッドを定義しました。実際、User.new_tokenやUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。
より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。
ややわかりにくいリスト 9.4の実装でも、非常に混乱しやすいリスト 9.5の実装でも、いずれも正しく動くことを確認してみてください。
ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。
9.4self
を使ってdigest
とnew_token
メソッドを定義する greenapp/models/user.rb
class User < ApplicationRecord
.
.
.
# 渡された文字列のハッシュ値を返す
def self.digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end
# ランダムなトークンを返す
def self.new_token
SecureRandom.urlsafe_base64
end
.
.
.
end
class << self
を使ってdigest
とnew_token
メソッドを定義する greenapp/models/user.rb
class User < ApplicationRecord
.
.
.
class << self
# 渡された文字列のハッシュ値を返す
def digest(string)
cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
BCrypt::Engine.cost
BCrypt::Password.create(string, cost: cost)
end
# ランダムなトークンを返す
def new_token
SecureRandom.urlsafe_base64
end
end
・
・
・
テストして動作確認するだけ
9.1.2 ログイン状態の保持
authenticated?
をUserモデルに追加するapp/models/user.rb
class User < ApplicationRecord
#中略
# 渡されたトークンがダイジェストと一致したらtrueを返す 追加
def authenticated?(remember_token)
BCrypt::Password.new(remember_digest).is_password?(remember_token) end
end
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
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
end
app/helpers/sessions_helper.rb
module SessionsHelper
# ユーザーのセッションを永続的にする ↓追加
def remember(user)
user.remember
cookies.permanent.signed[:user_id] = user.id
cookies.permanent[:remember_token] = user.remember_token
end
end
current_user
を更新する redapp/helpers/sessions_helper.rb
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])
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token])
log_in user
@current_user = user
end
end
end
end
演習
1:ブラウザのcookieを調べ、ログイン後のブラウザではremember_token
と暗号化されたuser_id
があることを確認してみましょう。
cookieを調べて確認する
2:コンソールを開き、authenticated?
メソッドがうまく動くかどうか確かめてみましょう。
コンソールで動作確認する
9.1.3 ユーザーを忘れる
forget
メソッドをUserモデルに追加する redapp/models/user.rb
class User < ApplicationRecord
#中略
# ユーザーのログイン情報を破棄する ↓追加
def forget
update_attribute(:remember_digest, nil)
end
end
app/helpers/sessions_helper.rb
module SessionsHelper
#中略
# 永続的セッションを破棄する ↓追加
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
演習
1:ログアウトした後に、ブラウザの対応するcookiesが削除されていることを確認してみましょう。
cookieを調べて確認する
9.1.4 2つの目立たないバグ
1つはユーザーが1つのタブでログアウトし、もう1つのタブで再度ログアウトしようとするとエラーになってしまいます。
もう1つのタブで “Log out” リンクをクリックすると、current_user
がnil
となってしまうため、log_out
メソッド内のforget(current_user)
が失敗してしまうからです
2番目の地味な問題は、ユーザーが複数のブラウザ (FirefoxやChromeなど) でログインしていたときに起こります。
# 記憶トークン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
test/integration/users_login_test.rb
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
.
.
.
test "login with valid information followed by logout" do
get login_path
post login_path, params: { session: { email: @user.email,
password: 'password' } }
assert is_logged_in?
assert_redirected_to @user
follow_redirect!
assert_template 'users/show'
assert_select "a[href=?]", login_path, count: 0
assert_select "a[href=?]", logout_path
assert_select "a[href=?]", user_path(@user)
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
end
current_user
がないために2回目のdelete logout_path
の呼び出しでエラーが発生し、テストスイートは redになります。
次にこのテストを成功させます。具体的にはlogged_in?
がtrueの場合に限ってlog_out
を呼び出すように変更します。
app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
.
.
.
def destroy
log_out if logged_in?
redirect_to root_url
end
end
記憶ダイジェストを持たないユーザーを用意し (setup
メソッドで定義した@user
インスタンス変数ではtrueになります)、続いてauthenticated?
を呼び出します。
authenticated?
のテスト redtest/models/user_test.rb
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
上のコードではBCrypt::Password.new(nil)
でエラーが発生するため、テストスイートは redになります。
rails test
nil
の場合にfalse
を返すようにすれば良さそうです。authenticated?
を更新して、ダイジェストが存在しない場合に対応 greenapp/models/user.rb
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
rails test
演習
1:リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。
動作確認する
2:リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。
動作確認する
上のコードでコメントアウトした部分を元に戻し、テストスイートが red から greenになることを確認しましょう。
動作確認する
9.2 [Remember me] チェックボックス
app/views/sessions/new.html.erb
<% provide(:title, "Log in") %>
<h1>Log in</h1>
<div class="row">
<div class="col-md-6 col-md-offset-3">
<%= form_for(:session, url: login_path) do |f| %>
<%= f.label :email %>
<%= f.email_field :email, class: 'form-control' %>
<%= f.label :password %>
<%= f.password_field :password, class: 'form-control' %>
<%= f.label :remember_me, class: "checkbox inline" do %>
<%= f.check_box :remember_me %>
<span>Remember me on this computer</span>
<% end %>
<%= f.submit "Log in", class: "btn btn-primary" %>
<% end %>
<p>New user? <%= link_to "Sign up now!", signup_path %></p>
</div>
</div>
app/assets/stylesheets/custom.scss
.
.
.
/* forms */
.
.
.
.checkbox {
margin-top: -10px;
margin-bottom: 10px;
span {
margin-left: 20px;
font-weight: normal;
}
}
#session_remember_me {
width: auto;
margin-left: 0;
}
app/controllers/sessions_controller.rb
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
演習
1:ブラウザでcookies情報を調べ、[remember me] をチェックしたときに意図した結果になっているかどうかを確認してみましょう。
動作確認する
2:コンソールを開き、三項演算子を使った実例を考えてみてください。
params[:session][:remember_me] == ‘1’ ? remember(user) : forget(user)
9.3.1 [Remember me] ボックスをテストする
log_in_as
ヘルパーを追加するtest/test_helper.rb
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
test/integration/users_login_test.rb
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
このテストは greenになっているはずでしょう。
rails test
演習
test/integration/users_login_test.rb
の統合テストでは、仮想のremember_token
属性にアクセスできないと説明しましたが、
コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassigns
メソッドを使います。このメソッドにはインスタンス変数に対応するシンボルを渡します。例えばcreate
アクションで@user
というインスタンス変数が定義されていれば、テスト内部ではassigns(:user)
と書くことでインスタンス変数にアクセスできます。このアイデアに従ってリスト 9.27とリスト 9.28の不足分を埋め (ヒントとして?
やFILL_IN
を目印に置いてあります)、[remember me] チェックボックスのテストを改良してみてください。
create
アクション内でインスタンス変数を使うためのテンプレートapp/controllers/sessions_controller.rb
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
test/integration/users_login_test.rb
require 'test_helper'
class UsersLoginTest < ActionDispatch::IntegrationTest
#略
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] をテストする
app/helpers/sessions_helper.rb
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
rails test
9.3.2 [Remember me] をテストする
- fixtureで
user
変数を定義する - 渡されたユーザーを
remember
メソッドで記憶する current_user
が、渡されたユーザーと同じであることを確認します
上の手順ではremember
メソッドではsession[:user_id]
が設定されないので、これで問題となっている複雑な分岐処理もテストできるようになります。作成したコードをリスト 9.31に示します。
test/helpers/sessions_helper_test.rb
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
rails test test/helpers/sessions_helper_test.rb
app/helpers/sessions_helper.rb
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])
user = User.find_by(id: user_id)
if user && user.authenticated?(cookies[:remember_token])
log_in user
@current_user = user
end
end
end
.
.
.
end
これでテストスイートは greenになるはずです。
rails test
演習
1:app/helpers/sessions_helper.rb
にあるauthenticated?
の式を削除すると、2つ目のテストで失敗することを確かめてみましょう (このテストが正しい対象をテストしていることを確認してみましょう)。
コメントアウトしてテストする。