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

@@ -1,12 +1,17 @@
import hashlib
import secrets
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form
from fastapi import APIRouter, Depends, HTTPException, status, Request, Form, BackgroundTasks
from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from loguru import logger
from ..db import get_db
from ..models import User
from ..schemas import UserCreate, UserResponse, LoginRequest, Token
from ..models import User, PasswordResetToken
from ..schemas import (
UserCreate, UserResponse, LoginRequest, Token,
ForgotPasswordRequest, ResetPasswordRequest,
)
from ..auth import (
get_password_hash,
verify_password,
@@ -480,5 +485,247 @@ def change_password(
)
logger.info(f"User {current_user.username} changed password")
return {"message": "Password changed successfully"}
# ==================== Forgot / Reset Password (self-service) ====================
# Generic response returned by /forgot-password regardless of whether the email
# exists — prevents account enumeration.
_FORGOT_PASSWORD_GENERIC = {
"message": "If an account with that email exists, a password reset link has been sent."
}
def _hash_reset_token(raw_token: str) -> str:
"""Hash a raw reset token for storage/lookup (only the hash is persisted)."""
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
async def _send_reset_email(to_email: str, display_name: str, lang: str, link: str, expire_minutes: int) -> None:
"""Send the password-reset email via the configured SMTP channel.
Runs as a FastAPI background task so the HTTP response time does not leak
whether the address exists.
"""
try:
from ..services.notification_channels import get_email_channel, get_panel_name
panel = get_panel_name()
name = display_name or to_email
if (lang or "").startswith("ru"):
subject = f"{panel}: восстановление пароля"
body = (
f"Здравствуйте, {name}!\n\n"
f"Поступил запрос на сброс пароля для вашего аккаунта в {panel}.\n"
f"Чтобы задать новый пароль, перейдите по ссылке (действует {expire_minutes} мин.):\n{link}\n\n"
f"Если вы не запрашивали сброс пароля, просто проигнорируйте это письмо — "
f"ваш текущий пароль останется без изменений."
)
btn = "Сбросить пароль"
note = f"Ссылка действительна {expire_minutes} минут. Если вы не запрашивали сброс — проигнорируйте письмо."
else:
subject = f"{panel}: password reset"
body = (
f"Hello, {name}!\n\n"
f"We received a request to reset the password for your {panel} account.\n"
f"To set a new password, open this link (valid for {expire_minutes} min):\n{link}\n\n"
f"If you did not request a password reset, you can safely ignore this email — "
f"your current password will remain unchanged."
)
btn = "Reset password"
note = f"This link is valid for {expire_minutes} minutes. If you didn't request a reset, ignore this email."
html_body = f"""
<div style="font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;color:#1a1a1a">
<h2 style="font-size:20px;margin:0 0 16px">{panel}</h2>
<p style="font-size:15px;line-height:1.5;margin:0 0 20px">{body.splitlines()[0]}</p>
<p style="margin:0 0 24px">
<a href="{link}" style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;
padding:10px 20px;border-radius:8px;font-size:15px;font-weight:600">{btn}</a>
</p>
<p style="font-size:13px;color:#666;line-height:1.5;margin:0 0 8px">{note}</p>
<p style="font-size:12px;color:#999;word-break:break-all;margin:0">{link}</p>
</div>
"""
channel = get_email_channel()
ok = await channel.send(to_email, subject, body, html_body)
if ok:
logger.info(f"Password reset email sent to {to_email}")
else:
logger.warning(f"Password reset email NOT sent to {to_email} (SMTP not configured or send failed)")
except Exception as e:
logger.error(f"Failed to send password reset email to {to_email}: {e}")
@router.post("/api/auth/forgot-password")
def forgot_password(
payload: ForgotPasswordRequest,
request: Request,
background: BackgroundTasks,
db: Session = Depends(get_db),
):
"""
Start the self-service password reset flow.
Always returns a generic message (no account enumeration). When the email
belongs to an active user, a one-time reset link is emailed via SMTP.
"""
client_ip = get_client_ip(request)
email = payload.email.strip()
user = db.query(User).filter(User.email.ilike(email)).first()
if not user or not user.is_active:
LoggingService.log_auth(
db=db,
action="password_reset_requested",
username=email,
ip_address=client_ip,
success=False,
error_message="Unknown or inactive email",
)
return _FORGOT_PASSWORD_GENERIC
# Throttle: ignore repeat requests within 60s to avoid email spam
recent = (
db.query(PasswordResetToken)
.filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.created_at >= utcnow() - timedelta(seconds=60),
)
.first()
)
if recent:
return _FORGOT_PASSWORD_GENERIC
# Invalidate any previously issued (unused) tokens for this user
db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.used_at.is_(None),
).update({PasswordResetToken.used_at: utcnow()}, synchronize_session=False)
raw_token = secrets.token_urlsafe(32)
expire_minutes = settings.PASSWORD_RESET_TOKEN_EXPIRE_MINUTES
reset = PasswordResetToken(
user_id=user.id,
token_hash=_hash_reset_token(raw_token),
expires_at=utcnow() + timedelta(minutes=expire_minutes),
ip_address=client_ip,
)
db.add(reset)
db.commit()
# Build the reset link from the request origin (SPA and API share an origin)
origin = request.headers.get("origin") or str(request.base_url).rstrip("/")
link = f"{origin}/reset-password?token={raw_token}"
background.add_task(
_send_reset_email,
user.email,
user.full_name or user.username,
user.language or "ru",
link,
expire_minutes,
)
LoggingService.log_auth(
db=db,
action="password_reset_requested",
username=user.username,
ip_address=client_ip,
success=True,
user_id=user.id,
)
logger.info(f"Password reset requested for {user.username} from {client_ip}")
return _FORGOT_PASSWORD_GENERIC
@router.get("/api/auth/reset-password/validate")
def validate_reset_token(token: str, db: Session = Depends(get_db)):
"""Check whether a reset token is still valid (for the reset form to gate UI)."""
reset = (
db.query(PasswordResetToken)
.filter(PasswordResetToken.token_hash == _hash_reset_token(token))
.first()
)
return {"valid": bool(reset and reset.is_valid())}
@router.post("/api/auth/reset-password")
def reset_password(
payload: ResetPasswordRequest,
request: Request,
db: Session = Depends(get_db),
):
"""Complete the reset flow: validate the one-time token and set a new password."""
client_ip = get_client_ip(request)
reset = (
db.query(PasswordResetToken)
.filter(PasswordResetToken.token_hash == _hash_reset_token(payload.token))
.first()
)
if not reset or not reset.is_valid():
LoggingService.log_auth(
db=db,
action="password_reset_failed",
username="unknown",
ip_address=client_ip,
success=False,
error_message="Invalid or expired reset token",
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Reset link is invalid or has expired",
)
user = db.query(User).filter(User.id == reset.user_id).first()
if not user or not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Reset link is invalid or has expired",
)
# Validate the new password against the panel's password policy
lang = user.language or "en"
is_valid, errors = SecurityService.validate_password(db, payload.new_password, lang)
if not is_valid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="; ".join(errors),
)
# Apply the new password and clear any lock state
user.hashed_password = get_password_hash(payload.new_password)
user.last_password_change = utcnow()
user.require_password_change = False
user.failed_login_attempts = 0
user.locked_until = None
# Consume this token and invalidate any other outstanding ones
reset.used_at = utcnow()
db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.used_at.is_(None),
).update({PasswordResetToken.used_at: utcnow()}, synchronize_session=False)
db.commit()
# Force re-login everywhere
SecurityService.terminate_all_user_sessions(db, user.id)
LoggingService.log_auth(
db=db,
action="password_reset",
username=user.username,
ip_address=client_ip,
success=True,
user_id=user.id,
)
logger.info(f"Password reset completed for {user.username} from {client_ip}")
return {"message": "Password has been reset successfully"}

View File

@@ -38,6 +38,8 @@ class Settings(BaseSettings):
SECRET_KEY: str = Field(default="your-secret-key-here", env="SECRET_KEY")
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=480, env="ACCESS_TOKEN_EXPIRE_MINUTES") # 8 hours
# Lifetime of self-service password-reset links (forgot-password flow)
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = Field(default=30, env="PASSWORD_RESET_TOKEN_EXPIRE_MINUTES")
# Logging settings
LOG_LEVEL: str = Field(default="INFO", env="LOG_LEVEL")

View File

@@ -1,5 +1,5 @@
from .base import *
from .auth import Role, user_servers, User, ActiveSession, LoginAttempt, BlockedIP, UserSSHKey
from .auth import Role, user_servers, User, ActiveSession, LoginAttempt, BlockedIP, UserSSHKey, PasswordResetToken
from .proxmox import OSTemplateGroup, OSTemplate, ProxmoxServer, VMInstance, VMSnapshotArchive
from .ipam import IPAMNetwork, IPAMPool, IPAMAllocation, IPAMHistory
from .tasks import TaskQueue, ProxmoxTask, DeployTask
@@ -10,7 +10,7 @@ from .catalog import ImageMirror
__all__ = [
'Base', 'utcnow',
'Role', 'user_servers', 'User', 'ActiveSession', 'LoginAttempt', 'BlockedIP', 'UserSSHKey',
'Role', 'user_servers', 'User', 'ActiveSession', 'LoginAttempt', 'BlockedIP', 'UserSSHKey', 'PasswordResetToken',
'OSTemplateGroup', 'OSTemplate', 'ProxmoxServer', 'VMInstance', 'VMSnapshotArchive',
'IPAMNetwork', 'IPAMPool', 'IPAMAllocation', 'IPAMHistory',
'TaskQueue', 'ProxmoxTask', 'DeployTask',

View File

@@ -116,6 +116,36 @@ class User(Base):
def __repr__(self):
return f"<User(id={self.id}, username='{self.username}', is_admin={self.is_admin})>"
class PasswordResetToken(Base):
"""One-time tokens for the self-service 'forgot password' flow.
Only a SHA-256 hash of the token is stored; the raw token lives solely in
the emailed link. A token is single-use (``used_at``) and time-limited
(``expires_at``).
"""
__tablename__ = "password_reset_tokens"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
token_hash = Column(String(64), unique=True, nullable=False, index=True)
expires_at = Column(DateTime(timezone=True), nullable=False)
used_at = Column(DateTime(timezone=True), nullable=True)
ip_address = Column(String(45), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
user = relationship("User")
def is_valid(self) -> bool:
"""Token is unused and not expired."""
if self.used_at is not None:
return False
if self.expires_at is None or make_tz_aware(self.expires_at) <= utcnow():
return False
return True
def __repr__(self):
return f"<PasswordResetToken(id={self.id}, user_id={self.user_id}, used={self.used_at is not None})>"
class ActiveSession(Base):
"""Active user sessions for single-session enforcement"""
__tablename__ = "active_sessions"

View File

@@ -53,6 +53,15 @@ class LoginRequest(BaseModel):
password: str = Field(..., description="Password")
class ForgotPasswordRequest(BaseModel):
email: EmailStr = Field(..., description="Account email to send the reset link to")
class ResetPasswordRequest(BaseModel):
token: str = Field(..., min_length=8, max_length=128, description="One-time reset token from the emailed link")
new_password: str = Field(..., description="New password")
# ==================== Proxmox Server Schemas ====================
class ProxmoxServerBase(BaseModel):

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)