Files
kite/backend/api/auth/totp.py
T
curo1305 6d1c02f703 refactor(09-05): CODE-09 purge — Phase 8 backend sub-packages (WHAT comments removed)
- 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)
2026-06-12 16:59:46 +02:00

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}