😀

【Rails初心者向け】ログイン状態保持の仕組みとbcryptの謎を解明する

に公開

【Rails初心者向け】ログイン状態保持の仕組みとbcryptの謎を解明する

はじめに

Railsチュートリアルの「ログイン状態を保持する」部分で、cookieの暗号化や記憶トークンの仕組みに混乱していませんか?特に、bcryptの==演算子の動作は初見では理解しにくいものです。

この記事では、なぜ2つのcookieが必要なのか、bcryptの比較処理がなぜあんな書き方になっているのかを、初心者でも分かるように解説します。

全体の流れを把握しよう

まず、何を実現しようとしているかを整理しましょう。

目標:ログイン状態を保持する機能

  • ユーザーがブラウザを閉じても、次回訪問時に自動的にログインされている
  • でも、セキュリティはしっかり確保したい

これを実現するために、cookieという仕組みを使います。

なぜ2つのcookieが必要なのか?

Cookie1: 暗号化されたユーザーID

cookies.encrypted[:user_id] = user.id
  • 役割: 「誰のアカウントか」を特定する
  • 特徴: Railsが自動的に暗号化・復号化してくれる
  • 復号: cookies.encrypted[:user_id] で元のuser.idが取得できる

Cookie2: 記憶トークン

cookies[:remember_token] = remember_token
  • 役割: 「本当にその人なのか」を確認する
  • 特徴: ランダムに生成された文字列(パスワードのような役割)
  • 保存方法: データベースにはハッシュ化した状態で保存

なぜ1つのcookieじゃダメなの?

「暗号化されたユーザーIDだけあれば十分じゃない?」と思うかもしれません。しかし、これには重要なセキュリティ上の問題があります。

悪い例:ユーザーIDだけの場合

攻撃者が暗号化されたユーザーIDのcookieを盗む

そのcookieをそのまま使ってログインできてしまう!

被害者がログアウトしても攻撃者はログインし続けられる

良い例:2つのcookieを組み合わせる場合

攻撃者が両方のcookieを盗んでも...

本物のユーザーがログアウトすると記憶トークンがデータベースから削除される

攻撃者は次回ログインできなくなる

つまり、**記憶トークンによって「ログアウトしたら攻撃者も締め出せる」**という安全装置が働くわけです。

時系列で見る攻撃者締め出しの流れ

実際にどのような流れで攻撃者が締め出されるのか、時系列で詳しく見てみましょう。

ステップ1:正常なログイン状態

# ユーザーAがログイン
user_a = User.find(1)

# 記憶トークンを生成・保存
remember_token = SecureRandom.urlsafe_base64  # "abc123xyz"
user_a.remember_digest = BCrypt::Password.create(remember_token)
user_a.save

# cookieに保存
cookies.encrypted[:user_id] = user_a.id          # 暗号化された "1"
cookies[:remember_token] = remember_token        # "abc123xyz"

# データベースの状態
# users テーブル
# id: 1, remember_digest: "$2a$12$hashedversion_of_abc123xyz"

ステップ2:攻撃者がcookieを盗む

# 攻撃者が何らかの方法でユーザーAのcookieを盗む
stolen_cookies = {
  user_id: "暗号化された1",
  remember_token: "abc123xyz"
}

# この時点では攻撃者もログインできてしまう
# なぜなら:
# 1. stolen_user_id → User.find(1) でユーザーA取得
# 2. BCrypt::Password.new("$2a$12$hashedversion_of_abc123xyz") == "abc123xyz" → true

ステップ3:本物のユーザーAがログアウト

# ユーザーAがログアウトボタンを押す
def log_out
  forget(current_user)           # ← 重要:記憶情報を削除
  cookies.delete(:user_id)       # cookieも削除
  cookies.delete(:remember_token)
  session.delete(:user_id)
  @current_user = nil
end

def forget(user)
  user.update_attribute(:remember_digest, nil)  # ← ここで削除!
end

# ログアウト後のデータベース状態
# users テーブル
# id: 1, remember_digest: nil  ← 削除された!

ステップ4:攻撃者が次回アクセス(締め出し発生)

# 攻撃者はまだ盗んだcookieを持っている
attacker_cookies = {
  user_id: "暗号化された1",      # まだ有効
  remember_token: "abc123xyz"   # まだ持っている
}

# しかし、認証処理で...
def current_user
  if (user_id = cookies.encrypted[:user_id])  # user_id = 1 取得成功
    user = User.find(user_id)                 # ユーザーA取得成功
    
    if user&.authenticated?(cookies[:remember_token])
      @current_user = user
    else
      # ここで認証失敗!
    end
  end
end

def authenticated?(remember_token)
  return false if remember_digest.nil?  # ← ここで false が返る!
  # remember_digestがnilなので、この行は実行されない
  BCrypt::Password.new(remember_digest) == remember_token
end

ステップ5:攻撃者のアクセス結果

# 認証結果
user.authenticated?("abc123xyz") # → false

# 結果として
@current_user = nil  # ログインできない
redirect_to login_path  # ログインページにリダイレクト

重要なポイント

この仕組みで重要なのは以下の点です:

1. cookieは盗まれたまま

攻撃者のブラウザには盗んだcookieがまだ残っています。しかし、それだけでは役に立ちません。

2. データベースの情報が鍵

認証の成功/失敗は**データベースに保存されたremember_digest**で決まります。これが削除されれば、どんなcookieを持っていても認証は失敗します。

3. ユーザーが自分でリセットできる

もしcookieが盗まれたことに気づいたら、ログアウトするだけで攻撃者を締め出せるのが大きなメリットです。

つまり、記憶トークンの仕組みによって「データベース側で認証情報をコントロールできる」ことが、攻撃者締め出しの鍵となっているのです。

ここが一番混乱しやすい部分です。

謎のコード

BCrypt::Password.new(remember_digest) == remember_token

このコードを見ると、「ハッシュ化されたものと生のトークンを直接比較している?」と思いますよね。普通に考えると不可能なはずです。

実は==演算子が再定義されている

bcrypt gemでは、BCrypt::Passwordクラスの==演算子が特別に再定義されています。

# 見た目のコード
BCrypt::Password.new(remember_digest) == remember_token

# 実際に実行されるコード
BCrypt::Password.new(remember_digest).is_password?(remember_token)

is_password?メソッドの動作

is_password?メソッドは以下のような処理を行います:

  1. 引数のremember_tokenを同じソルト(塩)を使ってハッシュ化
  2. そのハッシュと保存されているremember_digestを比較
  3. 一致すればtrue、不一致ならfalseを返す

具体例で理解を深める

実際の流れを具体例で見てみましょう。

ログイン時(記憶トークンの生成・保存)

# 1. ランダムなトークンを生成
remember_token = SecureRandom.urlsafe_base64

# 2. トークンをハッシュ化してデータベースに保存
user.remember_digest = BCrypt::Password.create(remember_token)
user.save

# 3. 生のトークンをcookieに保存
cookies[:remember_token] = remember_token

次回訪問時(認証の確認)

# 1. cookieから生のトークンを取得
token_from_cookie = cookies[:remember_token]

# 2. データベースからハッシュ化されたダイジェストを取得
stored_digest = user.remember_digest

# 3. bcryptの特殊な==で比較
if BCrypt::Password.new(stored_digest) == token_from_cookie
  # 認証成功:自動ログイン
else
  # 認証失敗:ログインページにリダイレクト
end

セキュリティのポイント

この仕組みの優れている点をまとめます:

1. cookieが盗まれても比較的安全

  • 暗号化されたユーザーIDだけでは不十分
  • 記憶トークンも一緒に盗まれる必要がある

2. ログアウトで攻撃者を締め出し

# ログアウト時
def forget(user)
  user.update_attribute(:remember_digest, nil)
end

記憶ダイジェストをデータベースから削除することで、たとえcookieが盗まれていても次回からはアクセスできなくなります。

3. ハッシュ化によるデータベースの保護

データベースには生のトークンではなく、ハッシュ化されたダイジェストのみが保存されるため、データベースが漏洩してもトークンそのものは分からない。

まとめ

Railsのログイン状態保持機能は、以下の要素を組み合わせて安全性を確保しています:

  • 暗号化されたユーザーID で「誰か」を特定
  • 記憶トークン で「本人確認」を実施
  • bcryptの特殊な==演算子 で安全なハッシュ比較
  • ログアウト時の削除 で攻撃者の締め出し

最初は複雑に感じるかもしれませんが、それぞれの仕組みには明確な理由があります。セキュリティとユーザビリティを両立させるための、よく考えられた設計なのです。

参考

Discussion