""" Auth API — TOTP endpoints. Handles: GET /totp/setup — provision TOTP secret for current user POST /totp/enable — enable TOTP (verify code, generate backup codes) DELETE /totp — disable TOTP (clear secret, revoke other sessions) """ from __future__ import annotations import hashlib import time from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from sqlalchemy import delete from sqlalchemy.ext.asyncio import AsyncSession from config import settings from db.models import BackupCode, 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, TotpEnableRequest router = APIRouter() # ── GET /totp/setup ─────────────────────────────────────────────────────────── @router.get("/totp/setup") async def totp_setup( session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Provision a TOTP secret for the current user. If TOTP is already enabled, returns 400. Returns { provisioning_uri, secret } — the provisioning_uri is suitable for QR code generation. The secret is the base32-encoded TOTP secret. """ if current_user.totp_enabled: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="TOTP already enabled", ) secret, provisioning_uri = await auth_service.provision_totp(session, current_user.id) return {"provisioning_uri": provisioning_uri, "secret": secret} # ── POST /totp/enable ───────────────────────────────────────────────────────── @router.post("/totp/enable") @limiter.limit("10/minute") async def enable_totp( request: Request, body: TotpEnableRequest, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Enable TOTP for the current user. Rate-limited to 10 attempts/minute per IP (SEC-02 / T-02-25). Verifies the submitted 6-digit code (with Redis replay prevention, AUTH-08). On success: marks TOTP enabled, generates and returns 10 one-time backup codes. The backup codes are ONLY returned here — they are stored as Argon2 hashes in the DB and never returned again (T-02-19). """ redis_client = request.app.state.redis ok = await auth_service.verify_totp(session, current_user.id, body.code, redis_client) if not ok: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Incorrect or expired code", ) # Mark TOTP as enabled user = await session.get(User, current_user.id) user.totp_enabled = True await session.flush() # Generate and store 10 backup codes; return plaintext to user (one-time, T-02-19) plain_codes = auth_service.generate_backup_codes(10) await auth_service.store_backup_codes(session, current_user.id, plain_codes) # Revoke other sessions; keep current one alive via skip_token_hash (CR-02) 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: TOTP enrolled event _ip = get_client_ip(request) await write_audit_log( session, event_type="auth.totp_enrolled", user_id=current_user.id, actor_id=current_user.id, resource_id=None, ip_address=_ip, metadata_={"sessions_revoked": revoked}, ) # Revoke any pre-enroll access tokens still within their TTL window (T-7.2-01) await redis_client.set( f"user_nbf:{current_user.id}", int(time.time()), ex=settings.access_token_expire_minutes * 60, ) await session.commit() return {"backup_codes": plain_codes, "sessions_revoked": revoked} # ── DELETE /totp ────────────────────────────────────────────────────────────── @router.delete("/totp") async def disable_totp( request: Request, session: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): """Disable TOTP for the current user. Clears totp_secret, sets totp_enabled=False, and deletes all backup codes. """ _ip = get_client_ip(request) user = await session.get(User, current_user.id) user.totp_enabled = False user.totp_secret = None # Delete all backup codes for this user (including unused ones) await session.execute(delete(BackupCode).where(BackupCode.user_id == current_user.id)) # Revoke other sessions; keep current one alive via skip_token_hash (CR-03) 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: TOTP revoked event await write_audit_log( session, event_type="auth.totp_revoked", user_id=current_user.id, actor_id=current_user.id, resource_id=None, ip_address=_ip, metadata_={"sessions_revoked": revoked}, ) # Revoke any pre-revoke 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": "TOTP disabled", "sessions_revoked": revoked}