Rails 8の認証ジェネレータでパスワードリセットを実装する|15分で切れるトークンとメール設定のハマりどころ

Rails 8の認証ジェネレータによるパスワードリセットの流れ。リセット申請→メール送信→トークン付きURLを踏む(デフォルト15分で失効)→新パスワード設定の4ステップ。
  • このエントリーをはてなブックマークに追加

スポンサーリンク

Rails 8 の bin/rails generate authentication で認証を作ったあと、「パスワードを忘れた人向けのリセット機能、これ全部自分で書くんだっけ?」と身構えていませんか?

結論から言うと、パスワードリセットの一式はジェネレータが最初から生成してくれています。メール送信・トークン付きURL・新パスワード設定フォームまで揃っていて、追加の gem は要りません。

ただし、そのまま本番に出すと「リンクがすぐ期限切れになる」「そもそもメールが届かない・URLが作れずエラー」といった定番のつまずきが待っています。この記事では、生成されたコードを読み解きながら、仕組みとハマりどころを実務目線で押さえていきます。認証ジェネレータ自体の全体像は Rails 8の認証ジェネレータとDeviseはどっちを使う? でまとめているので、そちらとあわせてどうぞ。

まず完成形:パスワードリセットの流れ

コードに入る前に、ユーザーから見た一連の流れを掴んでおきましょう。パスワードリセットは、ざっくり次の4ステップで動きます。

Rails 8の認証ジェネレータによるパスワードリセットの流れ。リセット申請→メール送信→トークン付きURLを踏む(デフォルト15分で失効)→新パスワード設定の4ステップ。
リセット申請からパスワード再設定までの4ステップ。カギは3番目のトークン有効期限

ポイントは、3番目の「トークン付きURL」が有効期限つきだということです。この期限が、あとで出てくる最大のハマりどころになります。まずはこの流れを頭に入れたまま、Rails が何を用意してくれているかを見ていきます。

認証ジェネレータが生成するもの(パスワードリセット一式)

bin/rails generate authentication を実行すると、ログインまわりに加えてパスワードリセット用のファイルが一式生成されます。リセットに関係するのは次のあたりです。

生成物役割
app/controllers/passwords_controller.rbリセット申請(create)と再設定(update)の処理
app/mailers/passwords_mailer.rbリセット用メールを送るメーラー
app/views/passwords/new, editメールアドレス入力フォーム/新パスワード入力フォーム
app/views/passwords_mailer/resetリセットメールの本文(リンクを含む)

意外に思うかもしれませんが、生成される User モデルはとてもシンプルです。パスワードリセットのための特別なカラムやメソッドは、モデルには書かれていません。

class User < ApplicationRecord
  has_secure_password
  has_many :sessions, dependent: :destroy

  normalizes :email_address, with: ->(e) { e.strip.downcase }
end

リセットの肝は、この has_secure_password の中に隠れています。次の章でそこを開けてみましょう。

仕組みの核心:リセットトークンはDBに保存されない

「パスワードリセットのトークンを保存するカラムが無いのに、どうやって検証するの?」——ここがRailsらしくてよくできているところです。Rails 7.1 以降の has_secure_password は、内部で次のようなトークン定義を自動的に持っています。

# has_secure_password が内部で定義している(イメージ)
generates_token_for :password_reset, expires_in: 15.minutes do
  password_salt&.last(10)
end

この generates_token_for が作るトークンは、署名付きの文字列で、DBには一切保存されません。ユーザーIDと有効期限を署名で包んだ値なので、受け取ったトークンを検証するだけで「誰のリセットか」「期限内か」が分かります。リセット用のカラムやマイグレーションが要らないのはこのためです。

さらに巧妙なのが、ブロックの中で password_salt(パスワードハッシュのソルト)の一部を混ぜている点です。パスワードを変更するとソルトも変わるため、一度リセットに成功すると、同じトークンは自動的に使えなくなります。「1回限りのリンク」が、わざわざフラグ管理をしなくても実現できているわけです。

Rails 8のリセットトークンの4つの性質。署名付き・DBに保存しない・15分で失効・パスワード変更で1回限り失効。
DBに保存せず、署名と期限とソルトだけで「安全な使い捨てリンク」を実現している

生成されたコードを読む(create / update)

では PasswordsController の中身を見ます。長くないので、全体像を掴んでしまいましょう。

class PasswordsController < ApplicationController
  allow_unauthenticated_access
  before_action :set_user_by_token, only: %i[ edit update ]

  def new
  end

  def create
    if user = User.find_by(email_address: params[:email_address])
      PasswordsMailer.reset(user).deliver_later
    end

    redirect_to new_session_path, notice: "Password reset instructions sent (if user with that email address exists)."
  end

  def edit
  end

  def update
    if @user.update(params.permit(:password, :password_confirmation))
      redirect_to new_session_path, notice: "Your password has been reset."
    else
      redirect_to edit_password_path(params[:token]), alert: "Passwords did not match."
    end
  end

  private
    def set_user_by_token
      @user = User.find_by_password_reset_token!(params[:token])
    rescue ActiveSupport::MessageVerifier::InvalidSignature
      redirect_to new_password_path, alert: "Password reset link is invalid or has expired."
    end
end

読みどころは3つです。順番に見ていきます。

① create(リセット申請):メールアドレスでユーザーを探し、見つかればメールを送ります。注目したいのは、ユーザーが見つかっても見つからなくても、同じメッセージで同じ画面にリダイレクトしていることです。これは「そのメールアドレスは登録されているか?」を外部から探れないようにする、ユーザー列挙対策です。

② set_user_by_token(トークン検証):find_by_password_reset_token! が、先ほどの署名付きトークンを検証します。期限切れでも改ざんでも、まとめて InvalidSignature 例外になるので、「無効または期限切れ」の1メッセージでまとめて弾けます。

③ update(再設定):@user.update でパスワードを更新するだけ。has_secure_password が password_confirmation の一致チェックまでやってくれるので、不一致なら false が返り、編集画面に戻します。更新に成功した瞬間、前章のソルトの仕組みでリンクが失効します。

ちなみに最近の Rails では、create に rate_limit to: 10, within: 3.minutes, only: :create のような連打対策も生成されます。リセットメールの乱発を防ぐためのものです。

ハマりどころ①:トークンが15分で切れる

ここからが本題です。デフォルトのトークン有効期限は 15分。開発中はすぐにリンクを踏むので気づきませんが、本番では話が変わります。

SMTPの経路が混んでいたり、受信側で迷惑メール判定の精査が入ったりすると、メールが届くまで数分〜十数分かかることがあります。ユーザーがメールに気づいてリンクを踏む頃には、もう期限切れ——「リンクが無効です」の無限ループ、という問い合わせが地味に増えます。

期限を延ばすのは簡単で、has_secure_password に reset_token: オプションを渡すだけです。モデルを1行変えます。

class User < ApplicationRecord
  # 有効期限を30分に延ばす
  has_secure_password reset_token: { expires_in: 30.minutes }
  has_many :sessions, dependent: :destroy

  normalizes :email_address, with: ->(e) { e.strip.downcase }
end

延ばしすぎは禁物です。リセットリンクは「パスワードを変更できる券」そのものなので、長くするほど漏れたときのリスク時間が伸びます。30分〜1時間程度に収めるのが現実的な落としどころです。メール本文には distance_of_time_in_words(0, @user.password_reset_token_expires_in) で「このリンクは◯分で期限切れになります」と出せるので、延ばした値をユーザーにも伝えておくと親切です。

ハマりどころ②:本番でメールのリンクが作れない・届かない

もう1つの定番が、メールまわりの設定漏れです。リセットメールのテンプレートは、リンクを edit_password_url(@user.password_reset_token) のように _url ヘルパーで組み立てています。

_path ではなく _url なので、ホスト名(https://example.com)が分からないとURLを生成できず、例外になります。これを教えるのが default_url_options です。本番環境に次を設定します。

# config/environments/production.rb
config.action_mailer.default_url_options = { host: "example.com", protocol: "https" }

# 実際に送るための配送設定(例:SMTP)
config.action_mailer.delivery_method = :smtp
config.action_mailer.smtp_settings = {
  address:   "smtp.example.com",
  port:      587,
  user_name: Rails.application.credentials.dig(:smtp, :user_name),
  password:  Rails.application.credentials.dig(:smtp, :password),
  authentication: :plain
}

開発環境でも default_url_options は必要です。あわせて、実際にメールサーバーを立てなくても中身を確認できるようにしておくと楽です。

# config/environments/development.rb
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }

送信メールの中身は http://localhost:3000/rails/mailers のメーラープレビューで確認できます。リンクのホストやトークンが正しく埋まっているか、ここで一度目視しておくと本番での事故が減ります。SMTPの認証情報を credentials で管理する方法は別記事にする予定なので、環境変数とどちらで持つか迷ったら認証ジェネレータの記事の考え方も参考にしてください。

セキュリティで押さえておきたい3点

最後に、パスワードリセットで事故らないための勘どころを整理します。生成コードは、実はこの多くを最初から面倒見てくれています。

  • ユーザー列挙を防ぐ:create はメールの存在有無にかかわらず同じ応答を返します。ここに「そのメールは登録されていません」を足したくなりますが、足さないのが正解です。
  • トークンは1回限り・期限つき:署名付きトークン+ソルト混ぜ込みで、再設定が済めば自動失効します。独自にフラグを足す必要はありません。
  • トークンがURLに乗る点に注意:リセットリンクにはトークンが含まれます。そのページに外部ドメインの画像やスクリプトを置くと、Referer ヘッダー経由でトークンが漏れる可能性があります。リセット画面は余計な外部リソースを読み込ませないのが安全です。

まとめ

Rails 8 のパスワードリセットは、「自分で全部書く」ものではなく「生成されたものを理解して、2か所だけ整える」作業でした。最後におさらいです。

  • リセット一式はジェネレータが生成済み。トークンは has_secure_password 由来でDBに保存されない署名付き
  • デフォルト期限は15分。遅いメールで切れるなら has_secure_password reset_token: { expires_in: 30.minutes } で調整
  • 本番は default_url_options のホスト設定が必須。設定漏れだとURL生成で落ちる
  • ユーザー列挙対策・1回限りトークンは標準で効いている。余計な「親切」を足さない

土台ができたら、「そもそもパスワードを持たせない」という発展もあります。興味があれば マジックリンク(パスワードレス)ログインに改造する方法 もどうぞ。

あわせて読みたい

スポンサーリンク

  • このエントリーをはてなブックマークに追加