- api/admin/users.py: removed module docstring + 7 WHAT function docstrings - api/admin/quotas.py: removed module docstring + 2 WHAT function docstrings - api/admin/ai.py: removed module docstring + 3 WHAT inline comments; security invariant docstrings preserved - api/admin/shared.py: unchanged (all comments are WHY — constraint notes) - api/documents/upload.py: removed 4 WHAT inline comments; T-03-05/T-03-06 WHY notes preserved - api/documents/content.py: removed WHAT function docstring + WHAT inline comment - api/documents/crud.py: removed 7 WHAT inline comments; D-16 + security constraint docstrings preserved - api/auth/shared.py: removed module docstring + 1 WHAT inline comment - api/auth/tokens.py: removed module docstring + 8 WHAT function docstrings/comments; family-revocation + SEC-02 WHY notes preserved - api/auth/totp.py: removed module docstring + 2 WHAT function docstrings + 2 WHAT inline comments - api/auth/password.py: removed module docstring + 2 WHAT function docstrings + 3 WHAT inline comments - All NO-prefix anchor comments and security-invariant WHY comments preserved - pytest: 413 passed, 1 failed (pre-existing ModuleNotFoundError unrelated to purge)
130 lines
4.8 KiB
Python
130 lines
4.8 KiB
Python
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),
|
|
):
|
|
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),
|
|
):
|
|
# Backup codes are returned here ONLY — stored as Argon2 hashes, 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",
|
|
)
|
|
|
|
user = await session.get(User, current_user.id)
|
|
user.totp_enabled = True
|
|
await session.flush()
|
|
|
|
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),
|
|
):
|
|
_ip = get_client_ip(request)
|
|
user = await session.get(User, current_user.id)
|
|
user.totp_enabled = False
|
|
user.totp_secret = None
|
|
|
|
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}
|