Rails-tutorial自分用まとめ(第9章 発展的なログイン機構 主に演習)

その8から続く

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
トークンとしてRuby標準ライブラリのSecureRandomモジュールに
ある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_tokenremember_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_tokenUser.digestを使って呼び出せるようになったので、おそらく最も明確なクラスメソッドの定義方法であると言えるでしょう。
より「Ruby的に正しい」クラスメソッドの定義方法が2通りあります。
ややわかりにくいリスト 9.4の実装でも、非常に混乱しやすいリスト 9.5の実装でも、いずれも正しく動くことを確認してみてください。
ヒント: selfは、通常の文脈ではUser「モデル」、つまりユーザーオブジェクトのインスタンスを指しますが、リスト 9.4やリスト 9.5の文脈では、selfはUser「クラス」を指すことにご注意ください。わかりにくさの原因の一部はこの点にあります。

9.4selfを使ってdigestnew_tokenメソッドを定義する green
app/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
リスト 9.5: class << selfを使ってdigestnew_tokenメソッドを定義する green
app/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を更新する red
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

演習

1:ブラウザのcookieを調べ、ログイン後のブラウザではremember_tokenと暗号化されたuser_idがあることを確認してみましょう。

cookieを調べて確認する

2:コンソールを開き、authenticated?メソッドがうまく動くかどうか確かめてみましょう。

コンソールで動作確認する

9.1.3 ユーザーを忘れる

forgetメソッドをUserモデルに追加する red
app/models/user.rb
class User < ApplicationRecord
 
  #中略
  # ユーザーのログイン情報を破棄する ↓追加
  def forget
    update_attribute(:remember_digest, nil)
  end
end
永続セッションからログアウトする green
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_usernilとなってしまうため、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
ユーザーログアウトのテスト red
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になります。

red

次にこのテストを成功させます。具体的にはlogged_in?がtrueの場合に限ってlog_outを呼び出すように変更します。

リスト 9.16: ログイン中の場合のみログアウトする green
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?のテスト red
test/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になります。

red
rails test
記憶ダイジェストがnilの場合にfalseを返すようにすれば良さそうです。
authenticated?を更新して、ダイジェストが存在しない場合に対応 green
app/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
green
rails test

演習

1:リスト 9.16で修正した行をコメントアウトし、2つのログイン済みのタブによるバグを実際に確かめてみましょう。まず片方のタブでログアウトし、その後、もう1つのタブで再度ログアウトを試してみてください。

動作確認する

2:リスト 9.19で修正した行をコメントアウトし、2つのログイン済みのブラウザによるバグを実際に確かめてみましょう。まず片方のブラウザでログアウトし、もう一方のブラウザを再起動してサンプルアプリケーションにアクセスしてみてください。

動作確認する

上のコードでコメントアウトした部分を元に戻し、テストスイートが red から greenになることを確認しましょう。

動作確認する

9.2 [Remember me] チェックボックス

 [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>
[remember me] チェックボックスのCSS
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;
}
[remember me] チェックボックスの送信結果を処理する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
[remember me] チェックボックスのテスト green
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

演習

  1. test/integration/users_login_test.rbの統合テストでは、仮想のremember_token属性にアクセスできないと説明しましたが、
    コントローラで定義したインスタンス変数にテストの内部からアクセスするには、テスト内部でassignsメソッドを使います。このメソッドにはインスタンス変数に対応するシンボルを渡します。例えばcreateアクションで@userというインスタンス変数が定義されていれば、テスト内部ではassigns(:user)と書くことでインスタンス変数にアクセスできます。このアイデアに従ってリスト 9.27とリスト 9.28の不足分を埋め (ヒントとして?FILL_INを目印に置いてあります)、[remember me] チェックボックスのテストを改良してみてください。
リスト 9.27: 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
リスト 9.28: [remember me] テストを改良するためのテンプレート green
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] をテストする

テストされていないブランチで例外を発生する green
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] をテストする

  1. fixtureでuser変数を定義する
  2. 渡されたユーザーをrememberメソッドで記憶する
  3. current_userが、渡されたユーザーと同じであることを確認します

上の手順ではrememberメソッドではsession[:user_id]が設定されないので、これで問題となっている複雑な分岐処理もテストできるようになります。作成したコードをリスト 9.31に示します。

リスト 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
red
rails test test/helpers/sessions_helper_test.rb
例外発生部分を削除する green
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つ目のテストで失敗することを確かめてみましょう (このテストが正しい対象をテストしていることを確認してみましょう)。

コメントアウトしてテストする。

9.4.1 本章のまとめ

1:Railsでは、あるページから別のページに移動するときに状態を保持することが
できる。ページの状態を長期間保持したいときは、cookiesメソッドを使って
永続的なセッションにする

2:記憶トークンと記憶ダイジェストをユーザーごとに関連付けて、永続的セッション
が実現できる

3:cookiesメソッドを使うと、ユーザーのブラウザにcookiesなどを保存できる

4:ログイン状態は、セッションもしくはクッキーの状態に基づいて決定される

5:セッションとクッキーをそれぞれ削除すると、ユーザーのログアウトが実現できる

6:三項演算子を使用すると、単純なif-then文をコンパクトに記述できる

その10に続きます

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です