Files
kite/backend/api/auth/tokens.py
T
curo1305 6d1c02f703 refactor(09-05): CODE-09 purge — Phase 8 backend sub-packages (WHAT comments removed)
- api/admin/users.py: removed module docstring + 7 WHAT function docstrings
- api/admin/quotas.py: removed module docstring + 2 WHAT function docstrings
- api/admin/ai.py: removed module docstring + 3 WHAT inline comments; security invariant docstrings preserved
- api/admin/shared.py: unchanged (all comments are WHY — constraint notes)
- api/documents/upload.py: removed 4 WHAT inline comments; T-03-05/T-03-06 WHY notes preserved
- api/documents/content.py: removed WHAT function docstring + WHAT inline comment
- api/documents/crud.py: removed 7 WHAT inline comments; D-16 + security constraint docstrings preserved
- api/auth/shared.py: removed module docstring + 1 WHAT inline comment
- api/auth/tokens.py: removed module docstring + 8 WHAT function docstrings/comments; family-revocation + SEC-02 WHY notes preserved
- api/auth/totp.py: removed module docstring + 2 WHAT function docstrings + 2 WHAT inline comments
- api/auth/password.py: removed module docstring + 2 WHAT function docstrings + 3 WHAT inline comments
- All NO-prefix anchor comments and security-invariant WHY comments preserved
- pytest: 413 passed, 1 failed (pre-existing ModuleNotFoundError unrelated to purge)
2026-06-12 16:59:46 +02:00

384 lines
14 KiB
Python

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),
):
try:
auth_service.validate_password_strength(body.password)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc))
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.",
)
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",
)
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),
):
# Per-account rate limiting (SEC-02): Redis counter keyed by email, checked before any DB lookup
redis_client = request.app.state.redis
rate_key = f"login_attempts:{body.email}"
count = await redis_client.incr(rate_key)
if count == 1:
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.",
)
result = await session.execute(select(User).where(User.email == str(body.email)))
user: Optional[User] = result.scalar_one_or_none()
_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",
)
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)}
if user.totp_enabled:
if body.totp_code is None and body.backup_code is None:
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,
)
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),
):
# Token reuse (revoked token presented) revokes entire family — family revocation security contract
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
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_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)):
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),
):
_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 _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),
):
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),
):
# 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),
):
# 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}