"""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 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/auth", tags=["auth"]) @router.post( "/login", response_model=LoginResponse, summary="Authenticate with the master password", ) async def login( body: LoginRequest, response: Response, request: Request, session_ctx: SessionServiceContextDep, settings: SettingsDep, rate_limiter: LoginRateLimiterDep, ) -> 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. 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). 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 = 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 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", summary="Validate the current session", ) async def validate_session( _: AuthDep, ) -> dict[str, bool]: """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: A simple JSON object confirming the session is valid. """ return {"valid": True} @router.post( "/logout", response_model=LogoutResponse, summary="Revoke the current session", ) 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