- 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)
94 lines
2.6 KiB
Python
94 lines
2.6 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Literal, Optional
|
|
|
|
from fastapi import Response
|
|
from pydantic import BaseModel, EmailStr
|
|
from slowapi import Limiter
|
|
|
|
from config import settings
|
|
from deps.utils import get_client_ip
|
|
|
|
# IP-level rate limiter (SEC-02 — 10 req/min on register/login/refresh)
|
|
limiter = Limiter(key_func=get_client_ip)
|
|
|
|
|
|
# ── Request models ────────────────────────────────────────────────────────────
|
|
|
|
class RegisterRequest(BaseModel):
|
|
handle: str
|
|
email: EmailStr
|
|
password: str
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
email: EmailStr
|
|
password: str
|
|
totp_code: Optional[str] = None
|
|
backup_code: Optional[str] = None
|
|
remember_me: bool = False
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
class TotpEnableRequest(BaseModel):
|
|
code: str
|
|
|
|
|
|
class PasswordResetRequest(BaseModel):
|
|
email: EmailStr
|
|
|
|
|
|
class PasswordResetConfirmRequest(BaseModel):
|
|
token: str
|
|
new_password: str
|
|
|
|
|
|
class PreferencesUpdate(BaseModel):
|
|
"""Request body for PATCH /api/auth/me/preferences.
|
|
|
|
Validates pdf_open_mode strictly via Literal (T-04-05-05 — no mass assignment).
|
|
"""
|
|
pdf_open_mode: Literal["in_app", "new_tab"]
|
|
|
|
|
|
# ── Helper: set httpOnly refresh cookie ──────────────────────────────────────
|
|
|
|
def _set_refresh_cookie(
|
|
response: Response, raw_token: str, remember_me: bool = False
|
|
) -> None:
|
|
"""Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint).
|
|
|
|
remember_me=False (default): Max-Age = refresh_token_expire_hours * 3600 (16h, D-11, RM-03)
|
|
remember_me=True: Max-Age = refresh_token_expire_days * 86400 (30d, D-11, RM-03)
|
|
"""
|
|
max_age = (
|
|
settings.refresh_token_expire_days * 86400
|
|
if remember_me
|
|
else settings.refresh_token_expire_hours * 3600
|
|
)
|
|
response.set_cookie(
|
|
key="refresh_token",
|
|
value=raw_token,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="strict",
|
|
path="/api/auth/refresh",
|
|
max_age=max_age,
|
|
)
|
|
|
|
|
|
def _user_dict(user: object) -> dict:
|
|
"""Return serialisable user metadata (no password_hash, no credentials_enc)."""
|
|
return {
|
|
"id": str(user.id),
|
|
"handle": user.handle,
|
|
"email": user.email,
|
|
"role": user.role,
|
|
"totp_enabled": user.totp_enabled,
|
|
"created_at": user.created_at.isoformat() if user.created_at else None,
|
|
}
|