パスワードリセット・変更 仕様書
忘れたパスワードのリセットフロー・ログイン中のパスワード変更
ステータス: Draft / 作成日: 2026-05-27 依存: なし(
usersテーブル + Redis + メール送信基盤を使用)
1. 概要
2 つのフローを提供する。
どちらも完了後に対象ユーザーの既存セッションをすべて無効化する。
管理者が特定ユーザー宛にリセットメールを送る操作は サービス管理者仕様書 §7.1 で定義する。本仕様のトークン生成ロジックを共有する。
2. データモデル
DB への追加はなし。トークンは Redis のみに保持する(既存のメール認証と同じ方針)。
2.1 Redis キー設計
pw_reset:t:{token} → user_id(文字列) TTL: TOKEN_TTL_SECS
pw_reset:u:{user_id} → token(文字列) TTL: TOKEN_TTL_SECS
pw_reset:rl:{email} → "1"(存在するだけで意味あり) TTL: RATE_LIMIT_SECS
t:/u:ペアはメール認証(email_verify:)と同じ双方向マッピング構造- 同一ユーザーへの二重発行時は Lua スクリプトで旧トークンを原子的に無効化する
rl:キーで送信レートを制限する(メールアドレス単位)rl:キーに使うメールアドレスは必ず正規化する(小文字変換 + 前後空白トリム)
2.2 セッション無効化のための users テーブル追加
ALTER TABLE users ADD COLUMN sessions_revoked_at TIMESTAMPTZ;
// entities/users.rs に追加
pub sessions_revoked_at: Option<DateTimeUtc>,
パスワードリセット・変更完了時に now() を設定する。AuthUser エクストラクタはセッション発行時刻と比較し、セッションが古ければ 401 を返す。
// session に発行時刻をミリ秒精度で保存
session.set("user_id", user.id);
session.set("issued_at_ms", Utc::now().timestamp_millis());
// AuthUser エクストラクタで確認(ミリ秒精度で比較してタイミング競合を防ぐ)
let issued_at_ms = session.get::<i64>("issued_at_ms").unwrap_or(0);
if let Some(revoked_at) = user.sessions_revoked_at {
if issued_at_ms < revoked_at.timestamp_millis() {
return Err(AuthError::Unauthorized);
}
}
3. パスワードリセットフロー
1. POST /v1/auth/password-reset/request (公開)
Request: { "email": "user@example.com" }
→ メールアドレスからユーザーを検索
→ 存在しない場合も 200 を返す(メール存在の有無を漏らさない)
→ レートリミット確認(RATE_LIMIT_SECS 以内に同一メールへ送信済みなら 429)
→ 32 バイトのランダムトークンを生成(URL-safe base64)
→ Redis に保存(TTL: TOKEN_TTL_SECS = 30 分)
→ リセットメールをジョブキューに投入
→ 200 OK(本文: 固定メッセージ)
2. GET /v1/auth/password-reset/verify?token={token} (公開)
→ Redis でトークンの存在を確認(消費しない)
→ 有効: 200 OK
→ 無効・期限切れ: 404
3. POST /v1/auth/password-reset/complete (公開)
Request: { "token": "...", "new_password": "..." }
→ new_password をバリデーション(8 文字以上)← 先に検証してトークン消費を防ぐ
→ 不正: 400 Bad Request(トークンはまだ有効のまま)
→ Redis でトークンの存在確認(消費しない)
→ 無効・期限切れ: 400 Bad Request
→ Argon2id でハッシュを生成(消費前に計算を完了させる)
→ Redis からトークンを消費(GETDEL)→ user_id を取得
→ 消費失敗(並行リクエストで先に消費された): 400 Bad Request
→ users.password_hash を UPDATE
→ users.sessions_revoked_at を now() に UPDATE
→ 200 OK
4. パスワード変更フロー(ログイン中)
POST /v1/auth/password/change (セッション必須)
Request: { "current_password": "...", "new_password": "..." }
→ 現在のパスワードを verify_password で検証
→ 不一致: 400 Bad Request("current_password が正しくありません")
→ new_password をバリデーション(8 文字以上)
→ Argon2id でハッシュ化
→ users.password_hash を UPDATE
→ users.sessions_revoked_at を now() に UPDATE(他のセッションは次回リクエスト時に遅延無効化)
→ 現在のセッションは即時削除(session.destroy())
→ 200 OK(次のリクエストで再ログインを促す)
OAuth のみで登録したユーザー(
password_hash IS NULL)はこのエンドポイントを呼べない(400)。パスワードを新規設定したい場合はパスワードリセットフローを使う。
5. Redis トークン管理
メール認証(utils/email_verification.rs)と同じ Lua スクリプト構造を使用する。
実装は utils/password_reset.rs に切り出す。
/// トークン有効期限(秒)。30 分。
pub const TOKEN_TTL_SECS: u64 = 30 * 60;
/// リセットメール送信のレートリミット(秒)。同一メールへの再送を制限。
pub const RATE_LIMIT_SECS: u64 = 60;
const KEY_TOKEN: &str = "pw_reset:t:";
const KEY_USER: &str = "pw_reset:u:";
const KEY_RL: &str = "pw_reset:rl:";
/// トークンを Redis に原子的に保存(旧トークンは自動失効)。
pub async fn store_token(redis: &RedisConnection, user_id: Uuid, token: &str) -> Result<(), anyhow::Error>;
/// GETDEL でトークンを消費し user_id を返す。無効・期限切れは None。
pub async fn consume_token(redis: &RedisConnection, token: &str) -> Result<Option<Uuid>, anyhow::Error>;
/// レートリミット枠を取得(SET NX)。Ok(false) = 429 相当。
pub async fn try_acquire_rate_limit(redis: &RedisConnection, email: &str) -> Result<bool, anyhow::Error>;
/// トークンの存在確認のみ(消費しない)。
pub async fn token_exists(redis: &RedisConnection, token: &str) -> Result<bool, anyhow::Error>;
6. API
リセットリクエスト:
POST /v1/auth/password-reset/request
{ "email": "user@example.com" }
レスポンス(メールが存在するかどうかに関わらず同一):
200 OK
{ "message": "入力されたメールアドレスにリセットリンクを送信しました(登録済みの場合)" }
トークン確認:
GET /v1/auth/password-reset/verify?token=xxxxx
200 OK — 有効
404 — 無効・期限切れ
新パスワード設定:
POST /v1/auth/password-reset/complete
{
"token": "URL-safe-base64-token",
"new_password": "newpassword123"
}
200 OK
{ "message": "パスワードをリセットしました。再度ログインしてください。" }
ログイン中のパスワード変更:
POST /v1/auth/password/change
{
"current_password": "oldpassword",
"new_password": "newpassword123"
}
200 OK
{ "message": "パスワードを変更しました。再度ログインしてください。" }
7. リセットメール
既存の VerificationEmailJob と同じ Apalis ジョブキュー構造で PasswordResetEmailJob を実装する。
メール本文(テキスト部分):
件名: パスワードリセットのご案内
以下のリンクをクリックしてパスワードをリセットしてください。
このリンクは 30 分間有効です。
https://app.example.com/auth/reset-password?token={token}
このメールに心当たりのない場合は無視してください。
アカウントへの変更は行われていません。
リンク先フロントエンドページ: /auth/reset-password?token={token}
→ トークン確認(GET verify)→ 有効なら新パスワード入力フォームを表示
8. セキュリティ
9. フロントエンド(Phase B)
ログイン画面に「パスワードをお忘れですか?」リンクを追加
┌─────────────────────────────────────────────┐
│ ログイン │
├─────────────────────────────────────────────┤
│ メールアドレス [___________________] │
│ パスワード [___________________] │
│ パスワードをお忘れですか? │
│ [ログイン] │
└─────────────────────────────────────────────┘
パスワードリセットページ
/auth/forgot-password ← メールアドレス入力
/auth/reset-password ← トークン確認 + 新パスワード入力
セキュリティ設定画面(パスワード変更)
/settings/security
┌─────────────────────────────────────────────┐
│ パスワード変更 │
├─────────────────────────────────────────────┤
│ 現在のパスワード [___________________] │
│ 新しいパスワード [___________________] │
│ 確認 [___________________] │
│ [変更する] │
└─────────────────────────────────────────────┘