GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
327 lines
11 KiB
Python
327 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from ipaddress import ip_address, ip_network
|
|
|
|
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")
|
|
_TRUSTED_PROXY_NETWORKS = (
|
|
ip_network("127.0.0.0/8"),
|
|
ip_network("::1/128"),
|
|
ip_network("172.16.0.0/12"),
|
|
)
|
|
|
|
|
|
def _peer_is_trusted_proxy(request: Request) -> bool:
|
|
if request.client is None:
|
|
return False
|
|
try:
|
|
peer_address = ip_address(request.client.host)
|
|
except ValueError:
|
|
return False
|
|
return any(peer_address in network for network in _TRUSTED_PROXY_NETWORKS)
|
|
|
|
|
|
def _request_is_https(request: Request) -> bool:
|
|
if request.url.scheme == "https":
|
|
return True
|
|
if not _peer_is_trusted_proxy(request):
|
|
return False
|
|
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
|
|
return forwarded_proto == "https"
|
|
|
|
|
|
def _client_host(request: Request) -> str:
|
|
peer = request.client.host if request.client else "unknown"
|
|
if not _peer_is_trusted_proxy(request):
|
|
return peer
|
|
forwarded = request.headers.get("x-real-ip", "").strip()
|
|
if not forwarded:
|
|
return peer
|
|
try:
|
|
return str(ip_address(forwarded))
|
|
except ValueError:
|
|
return peer
|
|
|
|
|
|
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:
|
|
response.set_cookie(
|
|
key=COOKIE_NAME,
|
|
value=token,
|
|
max_age=max_age,
|
|
httponly=True,
|
|
secure=_request_is_https(request),
|
|
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,
|
|
)
|
|
if settings.auth_require_https and not _request_is_https(request):
|
|
raise AppError(
|
|
code="AUTH_HTTPS_REQUIRED",
|
|
message="Operator authentication requires HTTPS on this runtime",
|
|
status_code=status.HTTP_426_UPGRADE_REQUIRED,
|
|
)
|
|
client_host = _client_host(request)
|
|
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,
|
|
)
|
|
|
|
client_host = _client_host(request)
|
|
retry_after = AuthService.consume_guest_request(
|
|
f"guest-login:{client_host}",
|
|
max_requests=settings.guest_login_requests_per_minute,
|
|
)
|
|
if retry_after:
|
|
raise AppError(
|
|
code="GUEST_LOGIN_RATE_LIMITED",
|
|
message="Too many guest sessions were requested. Try again later.",
|
|
details={"retry_after_seconds": retry_after},
|
|
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
|
)
|
|
|
|
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,
|
|
)
|
|
)
|