LoginSignup
0
0

More than 3 years have passed since last update.

第9章 発展的なログイン機構

Posted at

はじめに

永続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がデータベース内のハッシュ値と一致することを確認する。

db/migrate/[timestamp]_add_remember_digest_to_users.rb
class AddRememberDigestToUsers < ActiveRecord::Migration[6.0]
  def change
    add_column :users, :remember_digest, :string
  end
end

記憶トークン用のカラムを用意。string(文字列)のremember_digest属性を追加する。

app/models/user.rb
# ランダムなトークンを返す
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を使って「仮想の」属性を作成する。

app/models/user.rb
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設定
app/helpers/sessions_helper.rb
# ユーザーのセッションを永続的にする
  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: 暗号化、復号化するときのメソッド

app/models/user.rb
def authenticated?(remember_token)
  BCrypt::Password.new(remember_digest).is_password?(remember_token)
    # ハッシュ値remember_digestと平文remember_tokenがあっているかBCryptがチェック
end
app/helpers/sessions_helper.rb
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で更新する)

app/models/user.rb
# ユーザーのログイン情報を破棄する
def forget
  self.update_attribute(:remember_digest, nil)
    # nilで更新する。削除する。
end
app/helpers/sessions_helper.rb
# 永続的セッションを破棄する
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
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

演習 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

app/controllers/sessions_controller.rb
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/integration/users_login_test.rb
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チュートリアルを最後まで進めたら戻ってこようと思います。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0