Files
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

160 lines
6.1 KiB
Python

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."}