"""Authentication router. ``POST /api/auth/login`` — verify master password and issue a session. ``POST /api/auth/logout`` — revoke the current session. The session token is set as an ``HttpOnly`` ``SameSite=Lax`` cookie for browser-based SPAs. The cookie is automatically included in all requests and is inaccessible to JavaScript, protecting it from XSS attacks and malicious scripts. For programmatic API clients (non-browser), use ``POST /api/auth/token`` which returns a token in the response body for use in the ``Authorization`` header. This endpoint does not set a cookie. Rate limiting uses exponential backoff: each wrong password attempt incurs a progressive delay (0.5s, 1s, 2s, 4s, 5s max) per IP address. Requests blocked by this delay return ``429 Too Many Requests`` with a ``Retry-After`` header. """ from __future__ import annotations import structlog from fastapi import APIRouter, Request, Response from app.dependencies import ( AuthDep, LoginRateLimiterDep, SessionCacheDep, SessionServiceContextDep, SettingsDep, ) from app.exceptions import AuthenticationError, RateLimitError from app.models.auth import LoginRequest, LoginResponse, LogoutResponse, SessionValidResponse from app.services import auth_service from app.utils.client_ip import get_client_ip from app.utils.constants import SESSION_COOKIE_NAME log: structlog.stdlib.BoundLogger = structlog.get_logger() router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) @router.post( "/login", response_model=LoginResponse, summary="Authenticate with the master password", responses={ 200: {"description": "Login successful", "model": LoginResponse}, 401: {"description": "Invalid password"}, 422: {"description": "Validation error — invalid request body"}, 429: {"description": "Too many login attempts, retry after delay"}, 503: {"description": "Setup not complete"}, }, ) async def login( body: LoginRequest, response: Response, request: Request, session_ctx: SessionServiceContextDep, settings: SettingsDep, rate_limiter: LoginRateLimiterDep, session_cache: SessionCacheDep, ) -> LoginResponse: """Verify the master password and return a session token. On success the token is also set as an ``HttpOnly`` ``SameSite=Lax`` cookie so the browser SPA benefits from automatic credential handling. Rate limiting: Exponential backoff on failed attempts. Each wrong password incurs an increasing delay (0.5s, 1s, 2s, 4s, 5s max per IP address). Requests during the penalty period return ``429 Too Many Requests`` with a ``Retry-After`` header. Cache invalidation: On successful login, any existing cached sessions for the same user are invalidated so that stale tokens (e.g., from a stolen device) cannot be reused beyond the cache TTL window. Args: body: Login request validated by Pydantic. response: FastAPI response object used to set the cookie. request: The incoming HTTP request (used to extract client IP). session_ctx: Session service context containing db and repository. settings: Application settings (used for session duration and trusted proxies). rate_limiter: The login rate limiter (per IP). session_cache: Session cache for invalidating old sessions on login. Returns: :class:`~app.models.auth.LoginResponse` containing the token. Raises: AuthenticationError: if the password is incorrect. RateLimitError: if the rate limit is exceeded. """ client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies) # Check if this IP is currently blocked by exponential backoff if not rate_limiter.is_allowed(client_ip): log.warning("login_rate_limit_exceeded", client_ip=client_ip) raise RateLimitError("Too many login attempts. Please try again later.", retry_after_seconds=60.0) try: signed_token, expires_at, session = await auth_service.login( session_ctx.db, password=body.password, session_duration_minutes=settings.session_duration_minutes, session_secret=settings.session_secret, session_repo=session_ctx.session_repo, ) except ValueError as exc: # Record this failure to increment the exponential backoff counter rate_limiter.record_failure(client_ip) log.warning("login_failed", client_ip=client_ip, error=str(exc)) raise AuthenticationError(str(exc)) from exc # Invalidate any cached sessions for the same user to prevent reuse of # stale tokens (e.g., from a stolen device) beyond the cache TTL window. session_cache.invalidate_by_user(session.id) response.set_cookie( key=SESSION_COOKIE_NAME, value=signed_token, httponly=settings.session_cookie_httponly, samesite=settings.session_cookie_samesite, secure=settings.session_cookie_secure, max_age=settings.session_duration_minutes * 60, ) log.info("login_success", client_ip=client_ip) return LoginResponse(expires_at=expires_at) @router.get( "/session", response_model=SessionValidResponse, summary="Validate the current session", responses={ 200: {"description": "Session valid", "model": SessionValidResponse}, 401: {"description": "Session missing, expired, or invalid"}, }, ) async def validate_session( _: AuthDep, ) -> SessionValidResponse: """Validate the current session. This endpoint requires a valid session and returns 200 if the session is valid and still active. If the session is invalid, expired, or missing, FastAPI's ``require_auth`` dependency returns 401 automatically. The frontend calls this on mount to bootstrap its authentication state from the backend rather than relying solely on cached ``sessionStorage``. Args: _: The injected session object (unused, but its presence triggers validation). Returns: :class:`~app.models.auth.SessionValidResponse` confirming the session state. """ return SessionValidResponse(valid=True) @router.post( "/logout", response_model=LogoutResponse, summary="Revoke the current session", responses={ 200: {"description": "Logout successful", "model": LogoutResponse}, 401: {"description": "Session missing or invalid (silently successful)"}, }, ) async def logout( request: Request, response: Response, session_ctx: SessionServiceContextDep, settings: SettingsDep, session_cache: SessionCacheDep, ) -> LogoutResponse: """Invalidate the active session. The session token is read from the ``bangui_session`` cookie or the ``Authorization: Bearer`` header. If no token is present the request is silently treated as a successful logout (idempotent). Args: request: FastAPI request (used to extract the token). response: FastAPI response (used to clear the cookie). session_ctx: Session service context containing db and repository. settings: Application settings (used to unwrap signed tokens). session_cache: Session cache for invalidation. Returns: :class:`~app.models.auth.LogoutResponse`. """ token = _extract_token(request) if token: raw_token = await auth_service.logout( session_ctx.db, token, settings.session_secret, settings.session_secret_previous, session_repo=session_ctx.session_repo, ) if raw_token: session_cache.invalidate(raw_token) session_cache.invalidate(token) response.delete_cookie(key=SESSION_COOKIE_NAME) return LogoutResponse() # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _extract_token(request: Request) -> str | None: """Extract the session token from cookie or Authorization header. Args: request: The incoming FastAPI request. Returns: The token string, or ``None`` if absent. """ token: str | None = request.cookies.get(SESSION_COOKIE_NAME) if token: return token auth_header: str = request.headers.get("Authorization", "") if auth_header.startswith("Bearer "): return auth_header[len("Bearer "):] return None