"""Authentication API routes.""" import asyncio from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel from slowapi import Limiter from slowapi.util import get_remote_address from sqlmodel import func, select from sqlmodel.ext.asyncio.session import AsyncSession import bcrypt from ..database.engine import get_session from ..database.models import User from .dependencies import get_current_user from .jwt import create_access_token, create_refresh_token, decode_token router = APIRouter(prefix="/api/auth", tags=["auth"]) # Default rate limit applied by SlowAPIMiddleware to every route that does NOT # specify its own @limiter.limit(...) — protects against blanket abuse. limiter = Limiter(key_func=get_remote_address, default_limits=["600/minute"]) class SetupRequest(BaseModel): username: str password: str class LoginRequest(BaseModel): username: str password: str class TokenResponse(BaseModel): access_token: str refresh_token: str token_type: str = "bearer" class UserResponse(BaseModel): id: int username: str role: str class RefreshRequest(BaseModel): refresh_token: str async def _hash_password(password: str) -> str: """bcrypt.hashpw is CPU-bound (~200-500ms); never run it on the event loop. Caller is responsible for length-validating ``password`` against the 72-byte bcrypt cap before calling — bcrypt silently truncates beyond that, which is a correctness footgun, not a security one. """ def _work() -> str: return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() return await asyncio.to_thread(_work) # bcrypt's algorithm cap — the underlying primitive truncates input # beyond this so two distinct passwords sharing a 72-byte prefix would # verify identically. We reject up-front with a clear 422 message. _BCRYPT_MAX_PASSWORD_BYTES = 72 def _check_bcrypt_length(password: str) -> None: if len(password.encode("utf-8")) > _BCRYPT_MAX_PASSWORD_BYTES: raise HTTPException( status_code=422, detail=( f"Password too long; bcrypt limit is " f"{_BCRYPT_MAX_PASSWORD_BYTES} bytes (longer passwords would " "be silently truncated)" ), ) async def _verify_password(password: str, hashed: str) -> bool: def _work() -> bool: try: return bcrypt.checkpw(password.encode(), hashed.encode()) except ValueError: # Malformed hash in DB — treat as mismatch, never raise to caller. return False return await asyncio.to_thread(_work) @router.post("/setup", response_model=TokenResponse) @limiter.limit("3/minute") async def setup(request: Request, body: SetupRequest, session: AsyncSession = Depends(get_session)): if len(body.password) < 8: raise HTTPException(status_code=400, detail="Password must be at least 8 characters") _check_bcrypt_length(body.password) # Compute hash BEFORE opening the transaction so we don't hold a writer lock # during the CPU-bound bcrypt work. hashed = await _hash_password(body.password) # Serialize setup via an INSERT-inside-transaction-with-count-guard. # SQLite's writer lock plus the count check inside the transaction closes # the TOCTOU window between two concurrent POSTs. We ignore id=0 — that's # the internal "__system__" placeholder used for ownership of default # templates, never a real admin. async with session.begin(): result = await session.exec( select(func.count()).select_from(User).where(User.id != 0) ) count = result.one() if count > 0: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Setup already completed.", ) user = User(username=body.username, hashed_password=hashed, role="admin") session.add(user) await session.refresh(user) # Auto-create the bridge_self provider for the new admin so internal- # failure notifications work out of the box. Best-effort — a seeding # failure should not abort setup. try: from ..database.seeds import ensure_bridge_self_provider_for_user await ensure_bridge_self_provider_for_user(session, user.id) await session.commit() except Exception: # noqa: BLE001 await session.rollback() return TokenResponse( access_token=create_access_token(user.id, user.role, user.token_version), refresh_token=create_refresh_token(user.id, user.token_version), ) @router.post("/login", response_model=TokenResponse) @limiter.limit("5/minute") async def login(request: Request, body: LoginRequest, session: AsyncSession = Depends(get_session)): result = await session.exec(select(User).where(User.username == body.username)) user = result.first() # Always run a bcrypt verification to keep the response time constant, # preventing username-enumeration via timing side channel. password_ok = await _verify_password( body.password, user.hashed_password if user else "$2b$12$" + "a" * 53, ) if not user or not password_ok: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid username or password") return TokenResponse( access_token=create_access_token(user.id, user.role, user.token_version), refresh_token=create_refresh_token(user.id, user.token_version), ) @router.post("/refresh", response_model=TokenResponse) @limiter.limit("10/minute") async def refresh(request: Request, body: RefreshRequest, session: AsyncSession = Depends(get_session)): import jwt as pyjwt try: payload = decode_token(body.refresh_token) if payload.get("type") != "refresh": raise HTTPException(status_code=401, detail="Invalid token type") user_id = int(payload["sub"]) token_version = int(payload.get("ver", 1)) except (pyjwt.PyJWTError, KeyError, ValueError) as exc: raise HTTPException(status_code=401, detail="Invalid refresh token") from exc user = await session.get(User, user_id) if not user: raise HTTPException(status_code=401, detail="User not found") if token_version != user.token_version: raise HTTPException(status_code=401, detail="Refresh token revoked") return TokenResponse( access_token=create_access_token(user.id, user.role, user.token_version), refresh_token=create_refresh_token(user.id, user.token_version), ) @router.get("/me", response_model=UserResponse) async def me(user: User = Depends(get_current_user)): return UserResponse(id=user.id, username=user.username, role=user.role) class PasswordChangeRequest(BaseModel): current_password: str new_password: str @router.put("/password") @limiter.limit("10/minute") async def change_password( request: Request, body: PasswordChangeRequest, user: User = Depends(get_current_user), session: AsyncSession = Depends(get_session), ): if not await _verify_password(body.current_password, user.hashed_password): raise HTTPException(status_code=400, detail="Current password is incorrect") if len(body.new_password) < 8: raise HTTPException(status_code=400, detail="New password must be at least 8 characters") _check_bcrypt_length(body.new_password) user.hashed_password = await _hash_password(body.new_password) user.token_version = (user.token_version or 1) + 1 session.add(user) await session.commit() return {"success": True} @router.get("/needs-setup") @limiter.limit("30/minute") async def needs_setup(request: Request, session: AsyncSession = Depends(get_session)): # Exclude the internal __system__ placeholder (id=0) from the count so # a fresh install still reports needs_setup=True. result = await session.exec( select(func.count()).select_from(User).where(User.id != 0) ) count = result.one() return {"needs_setup": count == 0}