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
+449
View File
@@ -0,0 +1,449 @@
"""
Auth API — token-issuing endpoints.
Handles:
POST /register — new user registration with HIBP check
POST /login — login with optional TOTP/backup-code second factor
POST /refresh — rotate refresh token (httpOnly cookie in/out)
POST /logout — revoke current refresh token, clear cookie
POST /logout-all — revoke all refresh tokens for current user
GET /me — return current user profile
GET /me/quota — return current user quota
GET /me/preferences — return current user preferences
PATCH /me/preferences — update current user preferences
"""
from __future__ import annotations
import hashlib
import time
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import BackupCode, Quota, RefreshToken, 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,
RegisterRequest,
LoginRequest,
PreferencesUpdate,
_set_refresh_cookie,
_user_dict,
)
router = APIRouter()
# ── POST /register ────────────────────────────────────────────────────────────
@router.post("/register", status_code=status.HTTP_201_CREATED)
@limiter.limit("10/minute")
async def register(
request: Request,
body: RegisterRequest,
session: AsyncSession = Depends(get_db),
):
"""Register a new user account.
- Validates password strength (min 12 chars, upper, lower, digit, special)
- Checks HIBP k-anonymity API for breached passwords
- Hashes password with Argon2
- Inserts User + Quota rows in a single transaction
"""
# Password strength check
try:
auth_service.validate_password_strength(body.password)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
# HIBP breach check
if await auth_service.check_hibp(body.password):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="This password has appeared in a data breach. Choose a different password.",
)
# Duplicate email/handle check
result = await session.execute(
select(User).where(
(User.email == str(body.email)) | (User.handle == body.handle)
)
)
if result.scalar_one_or_none() is not None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email or handle already in use",
)
# Create user and quota
user_id = uuid.uuid4()
new_user = User(
id=user_id,
handle=body.handle,
email=str(body.email),
password_hash=auth_service.hash_password(body.password),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(
user_id=user_id,
limit_bytes=104857600, # 100 MB default (STORE-01)
used_bytes=0,
)
try:
session.add(new_user)
await session.flush() # persist User before Quota FK
session.add(quota)
await session.commit()
await session.refresh(new_user)
except IntegrityError:
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Email or handle already in use",
)
return {
"id": str(new_user.id),
"handle": new_user.handle,
"email": new_user.email,
"role": new_user.role,
"totp_enabled": new_user.totp_enabled,
"created_at": new_user.created_at.isoformat() if new_user.created_at else None,
}
# ── POST /login ───────────────────────────────────────────────────────────────
@router.post("/login")
@limiter.limit("10/minute")
async def login(
request: Request,
body: LoginRequest,
response: Response,
session: AsyncSession = Depends(get_db),
):
"""Authenticate a user and issue tokens.
Per-account rate limiting (SEC-02): checks Redis counter keyed by email
BEFORE any DB lookup to prevent enumeration timing attacks.
Three login flows:
1. No TOTP enabled: password → tokens
2. TOTP enabled, no code provided: requires_totp = True (challenge)
3. TOTP enabled, totp_code provided: verify TOTP → tokens
4. TOTP enabled, backup_code provided (no totp_code): verify backup → tokens
"""
# Per-account rate limiting (SEC-02)
redis_client = request.app.state.redis
rate_key = f"login_attempts:{body.email}"
count = await redis_client.incr(rate_key)
if count == 1:
# Set TTL only on first increment (15-minute window)
await redis_client.expire(rate_key, 900)
if count > 10:
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail="Too many login attempts. Try again in 15 minutes.",
)
# Look up user by email
result = await session.execute(select(User).where(User.email == str(body.email)))
user: Optional[User] = result.scalar_one_or_none()
# IP extraction for audit log (used in both success and failure paths)
_ip = get_client_ip(request)
# Verify password (anti-enumeration: same error regardless of whether user exists)
if user is None or not auth_service.verify_password(body.password, user.password_hash):
await write_audit_log(
session,
event_type="auth.login_failed",
user_id=user.id if user else None,
actor_id=user.id if user else None,
resource_id=None,
ip_address=_ip,
metadata_={"attempted_email_hash": hashlib.sha256(str(body.email).encode()).hexdigest()[:16]},
)
await session.commit()
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
)
# Active check
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Account deactivated",
)
# Password must change: return challenge without issuing tokens (T-02-16)
if user.password_must_change:
return {"requires_password_change": True, "user_id": str(user.id)}
# TOTP second-factor dispatch
if user.totp_enabled:
if body.totp_code is None and body.backup_code is None:
# Challenge: prompt for second factor
return {"requires_totp": True}
if body.totp_code is not None:
# TOTP path takes precedence (even if backup_code also provided)
ok = await auth_service.verify_totp(session, user.id, body.totp_code, redis_client)
if not ok:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect code",
)
else:
# Backup code path (body.backup_code is not None and body.totp_code is None)
ok = await auth_service.verify_backup_code(session, user.id, body.backup_code)
if not ok:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or already used code",
)
# D-13: backup code used event
await write_audit_log(
session,
event_type="auth.backup_code_used",
user_id=user.id,
actor_id=user.id,
resource_id=None,
ip_address=_ip,
)
# Issue tokens
access_token = auth_service.create_access_token(
str(user.id),
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
raw_refresh = await auth_service.create_refresh_token(session, user.id, remember_me=body.remember_me)
_set_refresh_cookie(response, raw_refresh, remember_me=body.remember_me)
# D-13: login success event
await write_audit_log(
session,
event_type="auth.login",
user_id=user.id,
actor_id=user.id,
resource_id=None,
ip_address=_ip,
metadata_={"totp_used": user.totp_enabled and body.totp_code is not None},
)
await session.commit()
return {
"access_token": access_token,
"user": {
"id": str(user.id),
"handle": user.handle,
"email": user.email,
"role": user.role,
"totp_enabled": user.totp_enabled,
},
}
# ── POST /refresh ─────────────────────────────────────────────────────────────
@router.post("/refresh")
@limiter.limit("10/minute")
async def refresh_token(
request: Request,
response: Response,
session: AsyncSession = Depends(get_db),
):
"""Rotate the refresh token.
Reads the refresh_token httpOnly cookie; on success issues a new access
token and rotates the refresh cookie.
On token reuse (revoked token presented), revokes entire family and raises 401.
"""
raw_token = request.cookies.get("refresh_token")
if not raw_token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="No refresh token",
)
try:
new_raw, user_id_str = await auth_service.rotate_refresh_token(session, raw_token)
except ValueError as exc:
if "token_family_revoked" in str(exc):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session revoked",
) from exc
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or expired refresh token",
) from exc
# Look up user for response body
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_401_UNAUTHORIZED,
detail="User not found or deactivated",
)
# Set new refresh cookie
_set_refresh_cookie(response, new_raw)
access_token = auth_service.create_access_token(
user_id_str,
user.role,
user_agent=request.headers.get("User-Agent", ""),
accept_lang=request.headers.get("Accept-Language", ""),
)
return {
"access_token": access_token,
"user": {
"id": str(user.id),
"handle": user.handle,
"email": user.email,
"role": user.role,
"totp_enabled": user.totp_enabled,
},
}
# ── POST /logout ──────────────────────────────────────────────────────────────
@router.post("/logout")
async def logout(request: Request, response: Response, session: AsyncSession = Depends(get_db)):
"""Revoke current refresh token and clear the cookie."""
import hashlib as _hashlib
_ip = get_client_ip(request)
raw_token = request.cookies.get("refresh_token")
_logout_user_id = None
if raw_token:
token_hash = _hashlib.sha256(raw_token.encode()).hexdigest()
result = await session.execute(
select(RefreshToken).where(RefreshToken.token_hash == token_hash)
)
row: Optional[RefreshToken] = result.scalar_one_or_none()
if row is not None:
_logout_user_id = row.user_id
row.revoked = True
# D-13: logout event (written before commit, within same transaction)
await write_audit_log(
session,
event_type="auth.logout",
user_id=_logout_user_id,
actor_id=_logout_user_id,
resource_id=None,
ip_address=_ip,
)
await session.commit()
response.delete_cookie("refresh_token", path="/api/auth/refresh")
return {"message": "Logged out"}
# ── POST /logout-all ──────────────────────────────────────────────────────────
@router.post("/logout-all")
async def logout_all(
request: Request,
response: Response,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Sign out of all devices: revoke all refresh tokens for current user."""
_ip = get_client_ip(request)
count = await auth_service.revoke_all_refresh_tokens(session, current_user.id)
# D-13: sign-out-all event
await write_audit_log(
session,
event_type="auth.sign_out_all",
user_id=current_user.id,
actor_id=current_user.id,
resource_id=None,
ip_address=_ip,
metadata_={"sessions_revoked": count},
)
await session.commit()
response.delete_cookie("refresh_token", path="/api/auth/refresh")
return {"message": f"Signed out of {count} session(s)"}
# ── GET /me ───────────────────────────────────────────────────────────────────
@router.get("/me")
async def get_me(current_user: User = Depends(get_current_user)):
"""Return the current user's profile (requires valid Bearer token)."""
return _user_dict(current_user)
# ── GET /me/quota ─────────────────────────────────────────────────────────────
@router.get("/me/quota")
async def get_my_quota(
current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
):
"""Return the current user's quota usage (STORE-04).
Returns {"used_bytes": int, "limit_bytes": int} for the sidebar quota bar.
Quota row is created at registration (100 MB default — STORE-01).
"""
q = await session.get(Quota, current_user.id)
if q is None:
raise HTTPException(status_code=404, detail="Quota not found")
return {"used_bytes": q.used_bytes, "limit_bytes": q.limit_bytes}
# ── GET /me/preferences ───────────────────────────────────────────────────────
@router.get("/me/preferences")
async def get_my_preferences(
current_user: User = Depends(get_current_user),
):
"""Return the current user's PDF open mode preference (D-10).
Both regular users and admins can read their own preferences.
Falls back to 'in_app' if the column is absent (migration not yet run).
"""
try:
pdf_open_mode = current_user.pdf_open_mode
except AttributeError:
pdf_open_mode = "in_app"
return {"pdf_open_mode": pdf_open_mode}
# ── PATCH /me/preferences ─────────────────────────────────────────────────────
@router.patch("/me/preferences")
async def update_my_preferences(
body: PreferencesUpdate,
session: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Update the current user's PDF open mode preference (D-10).
Both regular users and admins can update their own preferences.
Pydantic Literal["in_app", "new_tab"] enforces strict allowlist (T-04-05-05).
"""
user = await session.get(User, current_user.id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
user.pdf_open_mode = body.pdf_open_mode
session.add(user)
await session.commit()
return {"pdf_open_mode": user.pdf_open_mode}