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:
curo1305
2026-06-10 18:42:27 +02:00
parent a895b1812f
commit 5117e2542a
6 changed files with 1743 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
"""
Auth API — shared helpers, Pydantic request models, and the Limiter instance.
This module is imported by every auth sub-module (tokens.py, totp.py, password.py).
The Limiter instance is re-exported from api/auth/__init__.py so that:
from api.auth import limiter
continues to work in main.py and the 5 test files without modification.
"""
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)
# Re-exported from api/auth/__init__.py via: from api.auth.shared import limiter
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,
}