from __future__ import annotations import logging from datetime import UTC, datetime from fastapi import APIRouter, Depends, Request, Response, status from fastapi.responses import RedirectResponse from sqlalchemy.orm import Session from app.core.config import get_settings from app.core.errors import AppError from app.db.session import get_db from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope from app.services.auth_service import AuthPrincipal, AuthService from app.services.authentik_oidc_service import AuthentikOidcService from app.services.demo_workflow_service import DemoWorkflowService router = APIRouter(prefix="/auth", tags=["auth"]) COOKIE_NAME = "geointel_session" OIDC_FLOW_COOKIE_NAME = "geointel_oidc_flow" logger = logging.getLogger("geointel.auth") def _session_from_principal( principal: AuthPrincipal, *, guest_access_enabled: bool, authentik_enabled: bool, ) -> AuthSession: return AuthSession( authentication_required=True, authenticated=True, username=principal.username, expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC), role=principal.role, guest_access_enabled=guest_access_enabled, authentik_enabled=authentik_enabled, guest_project_id=principal.project_id, ) def _session_payload(request: Request) -> AuthSession: settings = get_settings() guest_access_enabled = settings.auth_enabled and settings.guest_access_enabled authentik_enabled = AuthentikOidcService(settings).enabled if not settings.auth_enabled: return AuthSession( authentication_required=False, authenticated=True, guest_access_enabled=False, authentik_enabled=False, ) principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings) if principal is None: return AuthSession( authentication_required=True, authenticated=False, guest_access_enabled=guest_access_enabled, authentik_enabled=authentik_enabled, ) return _session_from_principal( principal, guest_access_enabled=guest_access_enabled, authentik_enabled=authentik_enabled, ) def _set_session_cookie( *, request: Request, response: Response, token: str, max_age: int, ) -> None: forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower() response.set_cookie( key=COOKIE_NAME, value=token, max_age=max_age, httponly=True, secure=forwarded_proto == "https" or request.url.scheme == "https", samesite="strict", path="/", ) @router.get("/session", response_model=AuthSessionEnvelope) def session(request: Request) -> AuthSessionEnvelope: return AuthSessionEnvelope(data=_session_payload(request)) @router.post("/login", response_model=AuthSessionEnvelope) def login(payload: AuthLoginRequest, request: Request, response: Response) -> AuthSessionEnvelope: settings = get_settings() if not settings.auth_enabled: raise AppError( code="AUTHENTICATION_DISABLED", message="Operator authentication is not enabled on this runtime", status_code=status.HTTP_409_CONFLICT, ) client_host = request.client.host if request.client else "unknown" throttle_key = f"{client_host}:{payload.username.casefold()}" retry_after = AuthService.retry_after_seconds(throttle_key) if retry_after: raise AppError( code="LOGIN_RATE_LIMITED", message="Te veel mislukte aanmeldpogingen. Probeer later opnieuw.", details={"retry_after_seconds": retry_after}, status_code=status.HTTP_429_TOO_MANY_REQUESTS, ) if not AuthService.credentials_match(payload.username, payload.password, settings): AuthService.record_failure(throttle_key) raise AppError( code="INVALID_CREDENTIALS", message="Gebruikersnaam of wachtwoord is onjuist.", status_code=status.HTTP_401_UNAUTHORIZED, ) AuthService.clear_failures(throttle_key) token = AuthService.create_session_token(payload.username, settings) principal = AuthService.verify_session_token(token, settings) if principal is None: # pragma: no cover - defensive invariant raise AppError( code="SESSION_CREATION_FAILED", message="De beveiligde sessie kon niet worden aangemaakt.", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) _set_session_cookie( request=request, response=response, token=token, max_age=settings.auth_session_ttl_seconds, ) return AuthSessionEnvelope( data=_session_from_principal( principal, guest_access_enabled=settings.guest_access_enabled, authentik_enabled=AuthentikOidcService(settings).enabled, ) ) @router.get("/authentik/start") def authentik_start(request: Request) -> RedirectResponse: settings = get_settings() service = AuthentikOidcService(settings) try: location, flow = service.start() except Exception as exc: logger.warning("Authentik authorization start failed: %s", type(exc).__name__) raise AppError( code="AUTHENTIK_UNAVAILABLE", message="Authentik is momenteel niet beschikbaar.", status_code=status.HTTP_503_SERVICE_UNAVAILABLE, ) from exc response = RedirectResponse(location, status_code=status.HTTP_302_FOUND) response.set_cookie( OIDC_FLOW_COOKIE_NAME, flow, max_age=600, httponly=True, secure=True, samesite="lax", path=f"{settings.api_prefix}/auth/authentik", ) return response @router.get("/authentik/callback") def authentik_callback( request: Request, code: str = "", state: str = "", ) -> RedirectResponse: settings = get_settings() service = AuthentikOidcService(settings) base_url = settings.public_base_url.rstrip("/") try: service.finish( code=code, state=state, flow_cookie=request.cookies.get(OIDC_FLOW_COOKIE_NAME, ""), ) token = AuthService.create_session_token( settings.auth_username or "operator", settings, ) except Exception as exc: logger.warning("Authentik callback rejected: %s", type(exc).__name__) response = RedirectResponse( f"{base_url}/?authentik=error", status_code=status.HTTP_302_FOUND, ) else: response = RedirectResponse( f"{base_url}/", status_code=status.HTTP_302_FOUND, ) _set_session_cookie( request=request, response=response, token=token, max_age=settings.auth_session_ttl_seconds, ) response.delete_cookie( OIDC_FLOW_COOKIE_NAME, path=f"{settings.api_prefix}/auth/authentik", secure=True, httponly=True, samesite="lax", ) return response @router.post("/guest", response_model=AuthSessionEnvelope) def guest_login( request: Request, response: Response, db: Session = Depends(get_db), ) -> AuthSessionEnvelope: settings = get_settings() if not settings.auth_enabled or not settings.guest_access_enabled: raise AppError( code="GUEST_ACCESS_DISABLED", message="Gasttoegang is niet ingeschakeld op deze GeoIntel-installatie.", status_code=status.HTTP_403_FORBIDDEN, ) demo = DemoWorkflowService.seed(db) token = AuthService.create_session_token( settings.guest_display_name, settings, role="guest", project_id=demo.project_id, ttl_seconds=settings.guest_session_ttl_seconds, ) principal = AuthService.verify_session_token(token, settings) if principal is None: # pragma: no cover - defensive invariant raise AppError( code="SESSION_CREATION_FAILED", message="De tijdelijke gastensessie kon niet worden aangemaakt.", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, ) _set_session_cookie( request=request, response=response, token=token, max_age=settings.guest_session_ttl_seconds, ) return AuthSessionEnvelope( data=_session_from_principal( principal, guest_access_enabled=True, authentik_enabled=AuthentikOidcService(settings).enabled, ) ) @router.post("/logout", response_model=AuthSessionEnvelope) def logout(response: Response) -> AuthSessionEnvelope: settings = get_settings() response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict") return AuthSessionEnvelope( data=AuthSession( authentication_required=settings.auth_enabled, authenticated=not settings.auth_enabled, guest_access_enabled=settings.auth_enabled and settings.guest_access_enabled, authentik_enabled=AuthentikOidcService(settings).enabled, ) )