from __future__ import annotations import hashlib import time import uuid from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Request, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from config import settings from db.models import User from deps.auth import get_current_user from deps.db import get_db from deps.utils import get_client_ip from services import auth as auth_service from services.audit import write_audit_log from api.auth.shared import ( limiter, ChangePasswordRequest, PasswordResetRequest, PasswordResetConfirmRequest, ) router = APIRouter() # ── POST /change-password ───────────────────────────────────────────────────── @router.post("/change-password") async def change_password( request: Request, body: ChangePasswordRequest, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): if not auth_service.verify_password(body.current_password, current_user.password_hash): raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Current password is incorrect", ) if await auth_service.check_hibp(body.new_password): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="This password has appeared in a data breach. Choose a different password.", ) try: auth_service.validate_password_strength(body.new_password) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) _ip = get_client_ip(request) user = await session.get(User, current_user.id) user.password_hash = auth_service.hash_password(body.new_password) # Revoke other sessions; keep current one alive via skip_token_hash (CR-01) raw_cookie = request.cookies.get("refresh_token") skip_hash = hashlib.sha256(raw_cookie.encode()).hexdigest() if raw_cookie else None revoked = await auth_service.revoke_all_refresh_tokens(session, current_user.id, skip_token_hash=skip_hash) # D-13: password changed event (flush within same transaction before commit) await write_audit_log( session, event_type="auth.password_changed", user_id=current_user.id, actor_id=current_user.id, resource_id=None, ip_address=_ip, metadata_={"sessions_revoked": revoked}, ) # Revoke any pre-change access tokens still within their TTL window (T-7.2-01) await request.app.state.redis.set( f"user_nbf:{current_user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60, ) await session.commit() return {"message": "Password updated", "sessions_revoked": revoked} # ── POST /password-reset ────────────────────────────────────────────────────── @router.post("/password-reset", status_code=status.HTTP_202_ACCEPTED) @limiter.limit("5/hour") async def password_reset_request( request: Request, body: PasswordResetRequest, session: AsyncSession = Depends(get_db), ): # Always returns 202 regardless of whether email exists — anti-enumeration (T-02-22) from sqlalchemy import select as _select # noqa: PLC0415 (already imported above) result = await session.execute(_select(User).where(User.email == str(body.email))) user: Optional[User] = result.scalar_one_or_none() if user is not None: token = auth_service.create_password_reset_token(str(user.id)) reset_link = f"{settings.frontend_url}/password-reset/confirm?token={token}" # Deferred import to avoid circular import; Celery task is fire-and-forget from tasks.email_tasks import send_reset_email # noqa: PLC0415 send_reset_email.delay(user.email, reset_link) # Always return 202 (anti-enumeration — never reveal whether email exists) return { "message": ( "If an account exists for that email, you will receive a reset link shortly." ) } # ── POST /password-reset/confirm ────────────────────────────────────────────── @router.post("/password-reset/confirm") async def password_reset_confirm( request: Request, body: PasswordResetConfirmRequest, session: AsyncSession = Depends(get_db), ): # Does NOT issue new tokens — user must re-authenticate through /login (AUTH-05, T-02-21) try: user_id_str = auth_service.decode_password_reset_token(body.token) except ValueError: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired reset link", ) try: auth_service.validate_password_strength(body.new_password) except ValueError as exc: raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) if await auth_service.check_hibp(body.new_password): raise HTTPException( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="This password has appeared in a data breach. Choose a different password.", ) user = await session.get(User, uuid.UUID(user_id_str)) if user is None or not user.is_active: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired reset link", ) user.password_hash = auth_service.hash_password(body.new_password) await auth_service.revoke_all_refresh_tokens(session, user.id) # Revoke any pre-reset access tokens still within their TTL window (T-7.2-01) await request.app.state.redis.set( f"user_nbf:{user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60, ) await session.commit() # Do NOT issue tokens (AUTH-05 — user must pass TOTP gate on next login) return {"message": "Password updated. Please sign in."}