"""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. Login attempts are rate-limited to 5 per minute per IP address to prevent brute-force attacks. Requests exceeding the limit return ``429 Too Many Requests`` with a ``Retry-After`` header. """ from __future__ import annotations import asyncio import structlog from fastapi import APIRouter, HTTPException, Request, Response, status from app.dependencies import ( AuthDep, LoginRateLimiterDep, SessionCacheDep, SessionServiceContextDep, SettingsDep, ) 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"]) # Trusted proxy IPs that can set X-Forwarded-For header. # By default, none are trusted. In production behind nginx, add the nginx container IP. _TRUSTED_PROXIES: list[str] = [] @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: Up to 5 login attempts per minute per client IP. Requests exceeding this limit 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). rate_limiter: The login rate limiter (per IP). Returns: :class:`~app.models.auth.LoginResponse` containing the token. Raises: HTTPException: 401 if the password is incorrect. HTTPException: 429 if the rate limit is exceeded. """ client_ip = get_client_ip(request, trusted_proxies=_TRUSTED_PROXIES) if not rate_limiter.is_allowed(client_ip): log.warning("login_rate_limit_exceeded", client_ip=client_ip) raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="Too many login attempts. Please try again later.", headers={"Retry-After": "60"}, ) 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: # Add delay on wrong password to slow down brute-force attacks. # The bcrypt checkpw already takes ~100ms at cost factor 12, # but an extra 10 seconds makes automation much less feasible. await asyncio.sleep(10.0) log.warning("login_failed", client_ip=client_ip, error=str(exc)) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail=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, 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