feat(08-06): create backend/api/auth/ package — Task 1
- Create api/auth/shared.py: limiter, all Pydantic request models, _set_refresh_cookie, _user_dict - Create api/auth/tokens.py: register, login, refresh, logout, logout-all, me, quota, preferences handlers - Create api/auth/totp.py: totp/setup, totp/enable, totp DELETE handlers (CR-02, CR-03 preserved) - Create api/auth/password.py: change-password, password-reset, password-reset/confirm handlers (CR-01 preserved) - Create api/auth/__init__.py: router aggregator with prefix=/api/auth; re-exports limiter - Rename auth.py monolith to auth_OLD_REMOVE_IN_TASK_2.py (deleted in Task 2 after tests pass) - All 15 routes confirmed; limiter re-export verified; skip_token_hash calls preserved verbatim
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
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}
|
||||
Reference in New Issue
Block a user