1882edfff6
- backend/api/auth.py: register, login (TOTP+backup), refresh, logout, me, change-password; per-account Redis rate limit; HIBP check - backend/main.py: Origin validation middleware, CSP headers middleware, CORS locked to settings.cors_origins, Redis lifespan (app.state.redis), admin bootstrap, auth router included, slowapi SlowAPIMiddleware - backend/services/email.py: already created in Plan 01 (verified exists) - Python 3.9 compat: fixed match statement in ai/__init__.py, str|None union syntax in openai_provider.py, api/documents.py, api/topics.py, api/settings.py, services/classifier.py All 17 tests in test_auth_api.py pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
431 lines
15 KiB
Python
431 lines
15 KiB
Python
"""
|
|
Auth API endpoints for DocuVault.
|
|
|
|
Implements:
|
|
POST /api/auth/register — new user registration with HIBP check
|
|
POST /api/auth/login — login with optional TOTP/backup-code second factor
|
|
POST /api/auth/refresh — rotate refresh token (httpOnly cookie in/out)
|
|
POST /api/auth/logout — revoke current refresh token, clear cookie
|
|
GET /api/auth/me — return current user profile
|
|
POST /api/auth/change-password — update password (requires current password)
|
|
|
|
Security invariants:
|
|
- Per-account rate limit: 10 login attempts per email per 15 minutes (SEC-02)
|
|
- HTTP 429 returned before any DB lookup when the counter is exceeded
|
|
- httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint)
|
|
- HIBP breach check on register and change-password (SEC-03)
|
|
- TOTP takes precedence over backup_code when both fields are provided
|
|
- password_must_change=True: returns requires_password_change without tokens
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
import uuid
|
|
from typing import Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
from pydantic import BaseModel, EmailStr
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from config import settings
|
|
from db.models import BackupCode, Quota, RefreshToken, User
|
|
from deps.auth import get_current_user
|
|
from deps.db import get_db
|
|
from services import auth as auth_service
|
|
from slowapi import Limiter
|
|
from slowapi.util import get_remote_address
|
|
|
|
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
|
|
|
# IP-level rate limiter (SEC-02 — 10 req/min on register/login/refresh)
|
|
limiter = Limiter(key_func=get_remote_address)
|
|
|
|
# ── Password strength validation ─────────────────────────────────────────────
|
|
_PASSWORD_DETAIL = (
|
|
"Password must be at least 12 characters and include uppercase, "
|
|
"lowercase, a number, and a special character."
|
|
)
|
|
|
|
|
|
def _validate_password_strength(password: str) -> bool:
|
|
"""Return True if password passes all strength rules (AUTH-01).
|
|
|
|
Rules: min 12 chars, has uppercase, has lowercase, has digit, has special char.
|
|
"""
|
|
if len(password) < 12:
|
|
return False
|
|
if not re.search(r"[A-Z]", password):
|
|
return False
|
|
if not re.search(r"[a-z]", password):
|
|
return False
|
|
if not re.search(r"[0-9]", password):
|
|
return False
|
|
if not re.search(r"[^A-Za-z0-9]", password):
|
|
return False
|
|
return True
|
|
|
|
|
|
# ── 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
|
|
|
|
|
|
class ChangePasswordRequest(BaseModel):
|
|
current_password: str
|
|
new_password: str
|
|
|
|
|
|
# ── Helper: set httpOnly refresh cookie ──────────────────────────────────────
|
|
|
|
def _set_refresh_cookie(response: Response, raw_token: str) -> None:
|
|
"""Set the httpOnly Secure SameSite=Strict refresh cookie (CLAUDE.md constraint)."""
|
|
response.set_cookie(
|
|
key="refresh_token",
|
|
value=raw_token,
|
|
httponly=True,
|
|
secure=True,
|
|
samesite="strict",
|
|
path="/api/auth/refresh",
|
|
max_age=settings.refresh_token_expire_days * 86400,
|
|
)
|
|
|
|
|
|
def _user_dict(user: User) -> 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,
|
|
}
|
|
|
|
|
|
# ── POST /api/auth/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
|
|
if not _validate_password_strength(body.password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=_PASSWORD_DETAIL,
|
|
)
|
|
|
|
# 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,
|
|
)
|
|
session.add(new_user)
|
|
session.add(quota)
|
|
await session.commit()
|
|
await session.refresh(new_user)
|
|
|
|
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 /api/auth/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()
|
|
|
|
# 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):
|
|
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",
|
|
)
|
|
|
|
# Issue tokens
|
|
access_token = auth_service.create_access_token(str(user.id), user.role)
|
|
raw_refresh = await auth_service.create_refresh_token(session, user.id)
|
|
_set_refresh_cookie(response, raw_refresh)
|
|
|
|
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 /api/auth/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)
|
|
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 /api/auth/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
|
|
|
|
raw_token = request.cookies.get("refresh_token")
|
|
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:
|
|
row.revoked = True
|
|
await session.commit()
|
|
|
|
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
|
return {"message": "Logged out"}
|
|
|
|
|
|
# ── POST /api/auth/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."""
|
|
count = await auth_service.revoke_all_refresh_tokens(session, current_user.id)
|
|
response.delete_cookie("refresh_token", path="/api/auth/refresh")
|
|
return {"message": f"Signed out of {count} session(s)"}
|
|
|
|
|
|
# ── GET /api/auth/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)
|
|
|
|
|
|
# ── POST /api/auth/change-password ───────────────────────────────────────────
|
|
|
|
@router.post("/change-password")
|
|
async def change_password(
|
|
body: ChangePasswordRequest,
|
|
session: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Update the current user's password.
|
|
|
|
Checks:
|
|
1. current_password matches stored hash
|
|
2. new_password has not appeared in HIBP (SEC-03)
|
|
3. new_password meets strength requirements (AUTH-01)
|
|
"""
|
|
# Verify current password
|
|
if not auth_service.verify_password(body.current_password, current_user.password_hash):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
detail="Current password is incorrect",
|
|
)
|
|
|
|
# HIBP breach check on new password (SEC-03)
|
|
if await auth_service.check_hibp(body.new_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail="This password has appeared in a data breach. Choose a different password.",
|
|
)
|
|
|
|
# Password strength check
|
|
if not _validate_password_strength(body.new_password):
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=_PASSWORD_DETAIL,
|
|
)
|
|
|
|
# Update password
|
|
user = await session.get(User, current_user.id)
|
|
user.password_hash = auth_service.hash_password(body.new_password)
|
|
await session.commit()
|
|
|
|
return {"message": "Password updated"}
|