feat(auth): harden Authentik and guest capability boundaries
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
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
|
||||
@@ -10,14 +12,22 @@ 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) -> AuthSession:
|
||||
def _session_from_principal(
|
||||
principal: AuthPrincipal,
|
||||
*,
|
||||
guest_access_enabled: bool,
|
||||
authentik_enabled: bool,
|
||||
) -> AuthSession:
|
||||
return AuthSession(
|
||||
authentication_required=True,
|
||||
authenticated=True,
|
||||
@@ -25,6 +35,7 @@ def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: b
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -32,11 +43,13 @@ def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: b
|
||||
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:
|
||||
@@ -44,10 +57,12 @@ def _session_payload(request: Request) -> 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -120,10 +135,83 @@ def login(payload: AuthLoginRequest, request: Request, response: Response) -> Au
|
||||
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,
|
||||
@@ -163,6 +251,7 @@ def guest_login(
|
||||
data=_session_from_principal(
|
||||
principal,
|
||||
guest_access_enabled=True,
|
||||
authentik_enabled=AuthentikOidcService(settings).enabled,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -176,5 +265,6 @@ def logout(response: Response) -> AuthSessionEnvelope:
|
||||
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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from pydantic import AliasChoices, Field, field_validator, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
@@ -22,6 +24,14 @@ class Settings(BaseSettings):
|
||||
auth_username: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_USERNAME")
|
||||
auth_password_hash: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_PASSWORD_HASH")
|
||||
auth_session_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTH_SESSION_SECRET")
|
||||
authentik_issuer: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ISSUER")
|
||||
authentik_client_id: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_ID")
|
||||
authentik_client_secret: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_CLIENT_SECRET")
|
||||
authentik_allowed_email: str | None = Field(default=None, validation_alias="GEOINTEL_AUTHENTIK_ALLOWED_EMAIL")
|
||||
public_base_url: str = Field(
|
||||
default="http://localhost:1202",
|
||||
validation_alias="GEOINTEL_PUBLIC_BASE_URL",
|
||||
)
|
||||
auth_session_ttl_seconds: int = Field(
|
||||
default=43_200,
|
||||
ge=900,
|
||||
@@ -54,7 +64,18 @@ class Settings(BaseSettings):
|
||||
allow_external_artifact_paths: bool = Field(
|
||||
default=False, validation_alias="GEOINTEL_ALLOW_EXTERNAL_ARTIFACT_PATHS"
|
||||
)
|
||||
max_upload_mb: int = Field(default=500, validation_alias="MAX_UPLOAD_MB")
|
||||
max_upload_mb: int = Field(
|
||||
default=500,
|
||||
ge=1,
|
||||
le=2_048,
|
||||
validation_alias=AliasChoices("GEOINTEL_MAX_UPLOAD_MB", "MAX_UPLOAD_MB"),
|
||||
)
|
||||
max_in_memory_vector_mb: int = Field(
|
||||
default=64,
|
||||
ge=1,
|
||||
le=256,
|
||||
validation_alias="GEOINTEL_MAX_IN_MEMORY_VECTOR_MB",
|
||||
)
|
||||
orthophoto_enabled: bool = Field(default=True, validation_alias="ORTHOPHOTO_ENABLED")
|
||||
orthophoto_wms_url: str = Field(
|
||||
default="https://geo.api.vlaanderen.be/OMWRGBMRVL/wms",
|
||||
@@ -470,6 +491,47 @@ class Settings(BaseSettings):
|
||||
self.guest_display_name = self.guest_display_name.strip()
|
||||
if not self.guest_display_name:
|
||||
raise ValueError("GEOINTEL_GUEST_DISPLAY_NAME must not be blank")
|
||||
for field_name in (
|
||||
"authentik_issuer",
|
||||
"authentik_client_id",
|
||||
"authentik_client_secret",
|
||||
"authentik_allowed_email",
|
||||
):
|
||||
value = getattr(self, field_name)
|
||||
setattr(self, field_name, value.strip() if value else None)
|
||||
self.public_base_url = self.public_base_url.strip().rstrip("/")
|
||||
authentik_values = (
|
||||
self.authentik_issuer,
|
||||
self.authentik_client_id,
|
||||
self.authentik_client_secret,
|
||||
self.authentik_allowed_email,
|
||||
)
|
||||
if any(authentik_values) and not all(authentik_values):
|
||||
raise ValueError("All GEOINTEL_AUTHENTIK_* values must be configured together")
|
||||
if all(authentik_values):
|
||||
if not self.auth_enabled:
|
||||
raise ValueError("GEOINTEL_AUTH_ENABLED must be true when Authentik is configured")
|
||||
for label, value in (
|
||||
("GEOINTEL_AUTHENTIK_ISSUER", self.authentik_issuer),
|
||||
("GEOINTEL_PUBLIC_BASE_URL", self.public_base_url),
|
||||
):
|
||||
parsed = urlsplit(str(value))
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError(f"{label} must be an absolute HTTPS URL without credentials, query or fragment")
|
||||
public_url = urlsplit(self.public_base_url)
|
||||
if public_url.path not in ("", "/"):
|
||||
raise ValueError("GEOINTEL_PUBLIC_BASE_URL must not contain a path")
|
||||
if "@" not in str(self.authentik_allowed_email) or any(
|
||||
character.isspace() for character in str(self.authentik_allowed_email)
|
||||
):
|
||||
raise ValueError("GEOINTEL_AUTHENTIK_ALLOWED_EMAIL must be one valid e-mail address")
|
||||
if not self.auth_enabled:
|
||||
return self
|
||||
if not (self.auth_username or "").strip():
|
||||
|
||||
+8
-2
@@ -26,7 +26,7 @@ from app.services.aoi_operation_worker import AoiOperationWorker
|
||||
|
||||
logger = logging.getLogger("geointel")
|
||||
SAFE_REQUEST_ID = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||
UNSAFE_HOST = re.compile(r"[/\\@\s\x00-\x1f\x7f]")
|
||||
UNSAFE_HOST = re.compile(r"[/\\@?#\s\x00-\x1f\x7f]")
|
||||
|
||||
|
||||
def _to_error_payload(
|
||||
@@ -157,6 +157,8 @@ def create_app() -> FastAPI:
|
||||
f"{settings.api_prefix}/auth/login",
|
||||
f"{settings.api_prefix}/auth/guest",
|
||||
f"{settings.api_prefix}/auth/logout",
|
||||
f"{settings.api_prefix}/auth/authentik/start",
|
||||
f"{settings.api_prefix}/auth/authentik/callback",
|
||||
}
|
||||
direct_loopback_request = (
|
||||
request.client is not None
|
||||
@@ -261,6 +263,7 @@ def create_app() -> FastAPI:
|
||||
guest_safe_post_paths = {
|
||||
f"{settings.api_prefix}/demo/workflow",
|
||||
f"{settings.api_prefix}/external/coverage/resolve",
|
||||
f"{settings.api_prefix}/analysis/change-detection",
|
||||
}
|
||||
guest_scoped_analysis_post_paths = {
|
||||
f"{settings.api_prefix}/detection/run",
|
||||
@@ -274,7 +277,10 @@ def create_app() -> FastAPI:
|
||||
f"{settings.api_prefix}/exports/map-result",
|
||||
}
|
||||
guest_safe_post_suffixes = (
|
||||
"/acquire",
|
||||
"/vector/select",
|
||||
"/vector/select/derive",
|
||||
"/raster/tile",
|
||||
"/raster/bathymetry/select",
|
||||
"/raster/terrain/select",
|
||||
"/raster/flood-hazard/select",
|
||||
@@ -313,7 +319,7 @@ def create_app() -> FastAPI:
|
||||
status_code=403,
|
||||
content=_to_error_payload(
|
||||
"GUEST_READ_ONLY",
|
||||
"Gasttoegang is een tijdelijke, alleen-lezen demo. Meld u aan als operator om gegevens te wijzigen of taken te starten.",
|
||||
"Gasttoegang laat alleen projectgebonden demo-analyses toe. Meld u aan als operator voor beheerwijzigingen.",
|
||||
request_id=request_id,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ class AuthSession(BaseModel):
|
||||
expires_at: datetime | None = None
|
||||
role: Literal["operator", "guest"] | None = None
|
||||
guest_access_enabled: bool = False
|
||||
authentik_enabled: bool = False
|
||||
guest_project_id: UUID | None = None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
import jwt
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
MAX_OIDC_JSON_BYTES = 1_048_576
|
||||
|
||||
|
||||
class _RejectRedirects(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, ANN201
|
||||
return None
|
||||
|
||||
|
||||
class AuthentikOidcService:
|
||||
def __init__(self, settings: Settings):
|
||||
self.settings = settings
|
||||
self.issuer = (settings.authentik_issuer or "").rstrip("/")
|
||||
self.serializer = URLSafeTimedSerializer(
|
||||
settings.auth_session_secret or "",
|
||||
salt="geointel-authentik-v1",
|
||||
)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(
|
||||
self.issuer
|
||||
and self.settings.authentik_client_id
|
||||
and self.settings.authentik_client_secret
|
||||
and self.settings.authentik_allowed_email
|
||||
)
|
||||
|
||||
@property
|
||||
def redirect_uri(self) -> str:
|
||||
return (
|
||||
f"{self.settings.public_base_url.rstrip('/')}"
|
||||
f"{self.settings.api_prefix}/auth/authentik/callback"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _origin(url: str) -> tuple[str, str, int]:
|
||||
parsed = urlsplit(url)
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
raise ValueError("OIDC URLs must use absolute HTTPS URLs")
|
||||
return parsed.scheme, parsed.hostname.casefold(), parsed.port or 443
|
||||
|
||||
def _validate_endpoint(self, url: str) -> str:
|
||||
parsed = urlsplit(url)
|
||||
if (
|
||||
self._origin(url) != self._origin(self.issuer)
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.fragment
|
||||
):
|
||||
raise ValueError("OIDC endpoint is outside the configured issuer origin")
|
||||
return url
|
||||
|
||||
def _fetch_json(
|
||||
self,
|
||||
url: str,
|
||||
data: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
self._validate_endpoint(url)
|
||||
encoded = urlencode(data).encode("utf-8") if data is not None else None
|
||||
headers = {"Accept": "application/json"}
|
||||
if encoded is not None:
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
request = Request(url, data=encoded, headers=headers)
|
||||
try:
|
||||
with build_opener(_RejectRedirects()).open(request, timeout=10) as response:
|
||||
declared_length = response.headers.get("Content-Length")
|
||||
if declared_length and int(declared_length) > MAX_OIDC_JSON_BYTES:
|
||||
raise ValueError("OIDC response exceeds the configured size limit")
|
||||
raw = response.read(MAX_OIDC_JSON_BYTES + 1)
|
||||
except HTTPError as exc:
|
||||
raise ValueError("OIDC endpoint returned an HTTP error or redirect") from exc
|
||||
if len(raw) > MAX_OIDC_JSON_BYTES:
|
||||
raise ValueError("OIDC response exceeds the configured size limit")
|
||||
payload = json.loads(raw)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("OIDC endpoint did not return a JSON object")
|
||||
return payload
|
||||
|
||||
def _discovery(self) -> dict[str, Any]:
|
||||
document = self._fetch_json(
|
||||
f"{self.issuer}/.well-known/openid-configuration"
|
||||
)
|
||||
if str(document.get("issuer", "")).rstrip("/") != self.issuer:
|
||||
raise ValueError("OIDC issuer mismatch")
|
||||
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||
endpoint = document.get(key)
|
||||
if not isinstance(endpoint, str):
|
||||
raise ValueError(f"OIDC discovery is missing {key}")
|
||||
self._validate_endpoint(endpoint)
|
||||
return document
|
||||
|
||||
def start(self) -> tuple[str, str]:
|
||||
if not self.enabled:
|
||||
raise ValueError("Authentik is not configured")
|
||||
state = secrets.token_urlsafe(32)
|
||||
nonce = secrets.token_urlsafe(32)
|
||||
verifier = secrets.token_urlsafe(48)
|
||||
flow = self.serializer.dumps(
|
||||
{"state": state, "nonce": nonce, "verifier": verifier}
|
||||
)
|
||||
challenge = (
|
||||
base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest())
|
||||
.rstrip(b"=")
|
||||
.decode()
|
||||
)
|
||||
discovery = self._discovery()
|
||||
query = urlencode(
|
||||
{
|
||||
"client_id": self.settings.authentik_client_id,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"nonce": nonce,
|
||||
"code_challenge": challenge,
|
||||
"code_challenge_method": "S256",
|
||||
}
|
||||
)
|
||||
return f"{discovery['authorization_endpoint']}?{query}", flow
|
||||
|
||||
def finish(self, *, code: str, state: str, flow_cookie: str) -> dict[str, Any]:
|
||||
if not self.enabled or not code:
|
||||
raise ValueError("OIDC flow is incomplete")
|
||||
try:
|
||||
flow = self.serializer.loads(flow_cookie, max_age=600)
|
||||
except (BadSignature, SignatureExpired) as exc:
|
||||
raise ValueError("Invalid OIDC flow") from exc
|
||||
if not isinstance(flow, dict):
|
||||
raise ValueError("Invalid OIDC flow payload")
|
||||
if not state or not secrets.compare_digest(state, str(flow.get("state", ""))):
|
||||
raise ValueError("OIDC state mismatch")
|
||||
verifier = str(flow.get("verifier", ""))
|
||||
nonce = str(flow.get("nonce", ""))
|
||||
if not verifier or not nonce:
|
||||
raise ValueError("OIDC flow payload is incomplete")
|
||||
|
||||
discovery = self._discovery()
|
||||
token_response = self._fetch_json(
|
||||
str(discovery["token_endpoint"]),
|
||||
{
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"redirect_uri": self.redirect_uri,
|
||||
"client_id": self.settings.authentik_client_id or "",
|
||||
"client_secret": self.settings.authentik_client_secret or "",
|
||||
"code_verifier": verifier,
|
||||
},
|
||||
)
|
||||
token = str(token_response.get("id_token", ""))
|
||||
if not token:
|
||||
raise ValueError("OIDC token response has no ID token")
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "RS256" or not header.get("kid"):
|
||||
raise ValueError("OIDC ID token uses an unsupported signing header")
|
||||
jwks = self._fetch_json(str(discovery["jwks_uri"]))
|
||||
matching_keys = [
|
||||
key
|
||||
for key in jwks.get("keys", [])
|
||||
if isinstance(key, dict) and key.get("kid") == header["kid"]
|
||||
]
|
||||
if len(matching_keys) != 1:
|
||||
raise ValueError("OIDC signing key is missing or ambiguous")
|
||||
signing_key = jwt.PyJWK.from_dict(matching_keys[0]).key
|
||||
claims = jwt.decode(
|
||||
token,
|
||||
signing_key,
|
||||
algorithms=["RS256"],
|
||||
audience=self.settings.authentik_client_id,
|
||||
issuer=discovery["issuer"],
|
||||
options={
|
||||
"require": [
|
||||
"exp",
|
||||
"iat",
|
||||
"iss",
|
||||
"aud",
|
||||
"sub",
|
||||
"nonce",
|
||||
"email",
|
||||
"email_verified",
|
||||
]
|
||||
},
|
||||
)
|
||||
if not secrets.compare_digest(str(claims.get("nonce", "")), nonce):
|
||||
raise ValueError("OIDC nonce mismatch")
|
||||
email = str(claims.get("email", "")).strip().casefold()
|
||||
allowed = str(self.settings.authentik_allowed_email or "").strip().casefold()
|
||||
if claims.get("email_verified") is not True or not secrets.compare_digest(
|
||||
email, allowed
|
||||
):
|
||||
raise ValueError("OIDC identity is not authorized")
|
||||
return claims
|
||||
Reference in New Issue
Block a user