- 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
103 lines
3.0 KiB
Python
103 lines
3.0 KiB
Python
"""
|
|
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,
|
|
}
|