feat(auth): self-service password reset (forgot/reset endpoints)

Add an email-based "forgot password" flow:
- PasswordResetToken model (stores only a SHA-256 hash, single-use, TTL)
  and migration 27 for the password_reset_tokens table.
- POST /api/auth/forgot-password — generic response (no account
  enumeration), 60s throttle, emails a one-time reset link via the
  existing EmailChannel as a background task.
- GET /api/auth/reset-password/validate — token validity check for the UI.
- POST /api/auth/reset-password — validates the token, enforces the
  password policy, sets the new password, clears lock state, invalidates
  outstanding tokens and terminates all sessions.
- Configurable link lifetime via PASSWORD_RESET_TOKEN_EXPIRE_MINUTES (30m).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 03:24:19 +05:00
parent 65e14585c5
commit 8b93161434
6 changed files with 329 additions and 6 deletions

View File

@@ -1374,6 +1374,33 @@ def migrate_user_language(conn):
logger.info("✓ users.language already exists")
def migrate_password_reset_tokens(conn):
"""Migration 27: Create password_reset_tokens table for self-service password reset."""
if table_exists(conn, 'password_reset_tokens'):
logger.info("✓ password_reset_tokens table already exists")
return
logger.info("Creating password_reset_tokens table...")
conn.execute(text("""
CREATE TABLE password_reset_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
ip_address VARCHAR(45),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""))
conn.execute(text("""
CREATE INDEX idx_password_reset_tokens_user ON password_reset_tokens(user_id)
"""))
conn.execute(text("""
CREATE INDEX idx_password_reset_tokens_hash ON password_reset_tokens(token_hash)
"""))
logger.info("✓ password_reset_tokens table created")
def run_all_migrations(engine, db_session=None):
"""
Run all migrations in order.
@@ -1596,6 +1623,14 @@ def run_all_migrations(engine, db_session=None):
logger.warning(f"User language migration: {e}")
conn.rollback()
# Migration 27: password_reset_tokens table
try:
migrate_password_reset_tokens(conn)
conn.commit()
except Exception as e:
logger.warning(f"Password reset tokens migration: {e}")
conn.rollback()
logger.info("=" * 50)
logger.info("✓ All migrations completed successfully")
logger.info("=" * 50)