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,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,
|
||||
}
|
||||
Reference in New Issue
Block a user