静的型付け言語原理主義者によるRailsチュートリアル(第9章)

image

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

  • 永続クッキーを使用してブラウザを再起動した後でもすぐにログインできる機能を実装する

9.1 Remember me 機能

  • ユーザーのログイン状態をブラウザを閉じた後でも有効にする remember me 機能を実装していく
    • ユーザーが明示的にログアウトを実行しない限り、ログイン状態を維持することができる

9.1.1 記憶トークンと暗号化

  • セッションの永続化の第一歩として記憶トークンを生成し、cookies メソッドによる永続的 cookies の生成や、安全性の高い記憶ダイジェストによるトークン認証にこの記憶トークンを活用する
  • session メソッドで保存した情報は自動的に安全性が保たれるが、cookies メソッドに保存する情報はそうではない
    • cookies を永続化するとセッションハイジャックを受ける可能性がある
    • cookies を盗み出す有名な方法とその対処法
      • パケットスニッファで直接 cookies を取り出す
        • SSL でネットワークデータを暗号化
      • DBから記憶トークンを取り出す
        • 記憶トークンのハッシュ値を保存
      • XSS
        • ビューのテンプレートで入力した内容は Rails によって自動的に対策される
      • 直接端末を操作
        • ログアウト時にトークンを変更し、デジタル署名を用いた情報表示で二次被害を最小限に留める
  • 上記を考慮して永続的セッションを作成する
    • 記憶トークンはランダム文字列で生成
    • トークンは有効期限を設けて cookies に保存
    • トークンはハッシュ値に変換して DB に保存
    • cookies に保存するユーザー ID は暗号化する
    • 永続ユーザー ID を含む cookies を受け取ったら、その ID で DB を検索し、記憶トークンの cookies が DB 内のハッシュ値を一致するかを確認する
  • 最初に remember_digest 属性を User モデルに追加する
rails generate migration add_remember_digest_to_users remember_digest:string
  • 相変わらず簡単にマイグレーションが作成される
class AddRememberDigestToUsers < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :remember_digest, :string
  end
end
  • 記憶ダイジェストはユーザーが直接読み出すことはないので、remember_digest カラムにインデックスを追加する必要はない
    • 上記マイグレーションをそのまま使用する
  • 記憶トークンとして何を使うか
    • Ruby 標準ライブラリの SecureRandom モジュールにある urlsafe_base64 メソッドがこの用途にぴったり
  • 同一のパスワードを持つユーザーが複数いても問題ないように、同一の記憶トークンを持つユーザーが複数しても問題はない
    • とはいえトークンは一意である方が安全
  • ユーザーを記憶するには記憶トークンを作成し、そのトークンをダイジェストに変換したものを DB に保存する
  • User モデルのクラスメソッドとして新しいトークン作成用の new_token メソッドを追加する
class User < ApplicationRecord
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }

  # 渡された文字列のハッシュ値を返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end
end
  • 記憶トークンをユーザーと関連付け、トークンに対応する記憶ダイジェストを DB に保存するために user.remember メソッドを作成する
  • マイグレーションは実行済みなので、User モデルには remember_digest 属性が追加されているが、remember_token 属性はまだ追加されていない
    • user.remember_token メソッドを使ってトークンにアクセスできるようにし、かつトークンを DB に保存せずに実装する必要がある
      • attr_accessor を使って仮想の属性を作成する
  • 最初に User.new_token で記憶トークンを作成し、続いて User.digest を適用した結果で記憶ダイジェストを更新する
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

9.1.2 ログイン状態の保持

  • ユーザーの暗号化済み ID と記憶トークンをブラウザの永続 cookies に保存して永続セッションを作成する準備ができた
    • 実際に行うには cookies メソッドを使う
    • session のときと同様にハッシュとして扱える
      • 個別の cookie は valueexpires からできている
    • permanent メソッドで20年で期限切れになる cookie 設定が可能
  • ユーザー ID を cookies にそのまま保存するとセキュリティ上危険なため、cookie をブラウザに保存する前に安全に暗号化する署名付き cookie を使う
  • ユーザー ID と記憶トークンはペアで扱う必要があるため、cookie も永続化する
cookies.permanent.signed[:user_id] = user.id
  • cookies を設定すると、以後のページのビューで cookies からユーザーを取り出せるようになる
User.find_by(id: cookies.signed[:user_id])
  • cookies.signed[:user_id] では自動的にユーザー ID の cookies の暗号が解除され、元に戻る
  • 続いて bcrypt を使って cookies[:remember_token]remember_digest と一致することを確認する
  • 仮に攻撃者が暗号化された ID を奪った場合、記憶トークンがなければそのまま攻撃者がログイン可能になってしまう
    • 現在の設計の場合、本物のユーザーがログアウトすると攻撃者はログインできない設計になっている
  • 最後に渡されたトークンがユーザーの記憶ダイジェストと一致することを確認する
    • User モデルに authenticated? メソッドを作る
class User < ApplicationRecord
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }

  # 渡された文字列のハッシュ値を返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end
end
  • ログインしてユーザーを保存するため、remember ヘルパーメソッドを追加して log_in と連携させてみる
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
      remember user
      redirect_to user
    else
      flash.now[:danger] = 'Invalid email/password combination'
      render 'new'
    end
  end

  def destroy
    log_out
    redirect_to root_url
  end
end
  • log_in のときと同様に、実際の Sessions ヘルパーの動作は remember メソッド定義の user.remember を呼び出すまで遅延され、そこで記憶トークンを生成してトークンのダイジェストを DB に保存する
  • 続いて cookies メソッドでユーザー ID と記憶トークンの永続 cookies を作成する
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end

  # ユーザーのセッションを永続的にする
  def remember(user)
    user.remember
    cookies.permanent.signed[:user_id] = user.id
    cookies.permanent[:remember_token] = user.remember_token
  end

  # 現在ログインしているユーザーを返す (いる場合)
  def current_user
    if session[:user_id]
      @current_user ||= User.find_by(id: session[:user_id])
    end
  end

  # ユーザーがログインしていればtrue、その他ならfalseを返す
  def logged_in?
    !current_user.nil?
  end

  # 現在のユーザーをログアウトする
  def log_out
    session.delete(:user_id)
    @current_user = nil
  end
end
  • 上記コードでは一時セッション用に作成した current_user が正常に動作しない
    • 永続セッションの場合は session[:user_id] が存在すれば一時セッションからユーザーを取り出し、それ以外の場合は cookies[:user_id] からユーザーを取り出して、対応する永続セッションにログインする必要がある
module SessionsHelper

 # 渡されたユーザーでログインする
 def log_in(user)
   session[:user_id] = user.id
 end

 # ユーザーのセッションを永続的にする
 def remember(user)
   user.remember
   cookies.permanent.signed[:user_id] = user.id
   cookies.permanent[:remember_token] = user.remember_token
 end

 # 記憶トークン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

 # ユーザーがログインしていればtrue、その他ならfalseを返す
 def logged_in?
   !current_user.nil?
 end

 # 現在のユーザーをログアウトする
 def log_out
   session.delete(:user_id)
   @current_user = nil
 end
end
  • 残りの問題はブラウザの cookies を削除してユーザーをログアウトさせること

9.1.3 ユーザーを忘れる

  • ユーザーがログアウトできるようにするために、user.forget メソッドを定義する
    • user.remember が取り消される
    • 記憶ダイジェストを nil で更新する
class User < ApplicationRecord
  attr_accessor :remember_token
  before_save { self.email = email.downcase }
  validates :name,  presence: true, length: { maximum: 50 }
  VALID_EMAIL_REGEX = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i
  validates :email, presence: true, length: { maximum: 255 },
                    format: { with: VALID_EMAIL_REGEX },
                    uniqueness: { case_sensitive: false }
  has_secure_password
  validates :password, presence: true, length: { minimum: 6 }

  # 渡された文字列のハッシュ値を返す
  def User.digest(string)
    cost = ActiveModel::SecurePassword.min_cost ? BCrypt::Engine::MIN_COST :
                                                  BCrypt::Engine.cost
    BCrypt::Password.create(string, cost: cost)
  end

  # ランダムなトークンを返す
  def User.new_token
    SecureRandom.urlsafe_base64
  end

  # 永続セッションのためにユーザーをデータベースに記憶する
  def remember
    self.remember_token = User.new_token
    update_attribute(:remember_digest, User.digest(remember_token))
  end

  # 渡されたトークンがダイジェストと一致したらtrueを返す
  def authenticated?(remember_token)
    BCrypt::Password.new(remember_digest).is_password?(remember_token)
  end

  # ユーザーのログイン情報を破棄する
  def forget
    update_attribute(:remember_digest, nil)
  end
end
  • 永続セッションを終了するには、forget ヘルパーメソッドを追加して log_out ヘルパーメソッドから呼び出す
module SessionsHelper

  # 渡されたユーザーでログインする
  def log_in(user)
    session[:user_id] = user.id
  end
  .
  .
  .
  # 永続的セッションを破棄する
  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
  • これでログアウトできるようになり、テストも通るようになった

9.1.4 2つの目立たないバグ

  • 小さなバグが2つ残っている
    • ひとつは複数タブで同じサイトを開いているときに、片方でログアウトした後、もう片方でログアウトしようとするとエラーになること
      • ユーザーがログイン中にのみログアウトさせる必要がある
    • もうひとつはユーザーが複数のブラウザでログインしている場合に起こる
      • 片方でログアウトし、もう片方でログアウトせずにブラウザを終了して再度起動したときに問題が発生する
        • 終了した方のブラウザの中に cookies が残り続けているため、DB からユーザーを見つけることができてしまう
  • 1つ目の問題への対応
    • logged_in? が true の場合に限って log_out を呼び出すように修正する
class SessionsController < ApplicationController
  .
  .
  .
  def destroy
    log_out if logged_in?
    redirect_to root_url
  end
end
  • 2つ目の問題への対応
    • 統合テストで2種類のブラウザをシュミレートするのは困難
    • その代わりに User モデルで直接テストする
    • 記憶ダイジェストを持たないユーザーを用意し、authenticated? を呼び出す
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
  • 上記テストが成功するように修正する
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

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

  • [remember me] チェックボックスをログインフォームに追加してでログインを保持する
    • チェックボックスはヘルパーメソッドで作成可能
  • チェックボックスがオンのときにユーザーを記憶し、オフのときには記憶しないようにする
    • 必要な準備は終わっているので実装は1行で完了する
      • 本当に???
  • [remember me] チェックボックスの送信結果を処理する
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.3 [Remember me] のテスト

9.3.1 [Remember me] ボックスをテストする

  • テスト内でユーザーがログインするためのヘルパーメソッド log_in_as を定義
    • session を直接操作して :user_id キーに user.id の値を代入する
  • 統合テストでも同様のヘルパーメソッドを実装する
    • 統合テストでは session を直接取り扱うことができないので、代わりに Sessions リソースに対して post を送信することで代用する
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
  • チェックボックスの動作を確認するため、チェックボックスがオンになっている場合とオフになっている場合のテストを作成する
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

9.3.2 [Remember me] をテストする

  • current_user 内のある複雑な分岐処理についてはこれまでまったくテストが行われていない
    • テスト漏れが疑われる箇所に例外処理を入れて確認する
      • Ruby にも例外という概念があったのか
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
  • 上記コードでテストが通ってしまうので、コードは正常ではない
  • log_in_as ヘルパーメソッドでは、session[:user_id] と定義しているため、current_user メソッドにある複雑な分岐処理を統合テストでチェックすることが非常に困難
    • current_user を直接テストすれば、この制約を突破することができる
  • 永続的セッションのテストを追加
    • 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

9.4 最後に

  • 基本的な認証機構に必要な残りの作業は、ログイン状態やログイン済みユーザー ID に基づいて、ページへのアクセス権限を制限する実装だけ

所感

cookies によるログイン状態の永続化を学ぶことができ、cookie まわりの良い復習になった章だった。脆弱性を考慮した実装も行ったので、セキュリティ周りの簡単な学びにも繋がった。

ここ数章は Rails を学ぶというより、Rails を通じて Web アプリケーションの作り方を学ぶことに楽しみを見出すようになってきたように思う。これまでは体系的に Web アプリケーション作成について学ぶ機会がなかったので、非常にありがたい。


4833 Words

2020-02-22 11:15 +0000