preserve Tower Authentik operator login WIP
This commit is contained in:
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
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,6 +11,7 @@ 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
|
||||
|
||||
|
||||
@@ -18,6 +20,7 @@ COOKIE_NAME = "geointel_session"
|
||||
|
||||
|
||||
def _session_from_principal(principal: AuthPrincipal, *, guest_access_enabled: bool) -> AuthSession:
|
||||
settings = get_settings()
|
||||
return AuthSession(
|
||||
authentication_required=True,
|
||||
authenticated=True,
|
||||
@@ -25,6 +28,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=AuthentikOidcService(settings).enabled,
|
||||
guest_project_id=principal.project_id,
|
||||
)
|
||||
|
||||
@@ -32,11 +36,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,6 +50,7 @@ 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,
|
||||
@@ -124,6 +131,35 @@ def login(payload: AuthLoginRequest, request: Request, response: Response) -> Au
|
||||
)
|
||||
|
||||
|
||||
@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:
|
||||
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)
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
|
||||
response.set_cookie("geointel_oidc_flow", flow, max_age=600, httponly=True, secure=forwarded_proto == "https" or request.url.scheme == "https", 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)
|
||||
try:
|
||||
service.finish(code=code, state=state, flow_cookie=request.cookies.get("geointel_oidc_flow", ""))
|
||||
token = AuthService.create_session_token(settings.auth_username or "operator", settings)
|
||||
except Exception:
|
||||
return RedirectResponse(f"{settings.public_base_url.rstrip('/')}?authentik=error", status_code=status.HTTP_302_FOUND)
|
||||
response = RedirectResponse(settings.public_base_url.rstrip("/") + "/", 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("geointel_oidc_flow", path=f"{settings.api_prefix}/auth/authentik")
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/guest", response_model=AuthSessionEnvelope)
|
||||
def guest_login(
|
||||
request: Request,
|
||||
|
||||
@@ -22,6 +22,11 @@ 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:8000", validation_alias="GEOINTEL_PUBLIC_BASE_URL")
|
||||
auth_session_ttl_seconds: int = Field(
|
||||
default=43_200,
|
||||
ge=900,
|
||||
@@ -470,6 +475,21 @@ 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")
|
||||
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")
|
||||
if not str(self.authentik_issuer).startswith("https://"):
|
||||
raise ValueError("GEOINTEL_AUTHENTIK_ISSUER must use HTTPS")
|
||||
if not self.public_base_url.startswith("https://"):
|
||||
raise ValueError("GEOINTEL_PUBLIC_BASE_URL must use HTTPS for Authentik")
|
||||
if not self.auth_enabled:
|
||||
return self
|
||||
if not (self.auth_username or "").strip():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
from urllib.parse import urlencode, urlparse
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
import jwt
|
||||
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
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('/')}{self.settings.api_prefix}/auth/authentik/callback"
|
||||
|
||||
def _discovery(self) -> dict:
|
||||
document = self._fetch_json(f"{self.issuer}/.well-known/openid-configuration")
|
||||
if document.get("issuer", "").rstrip("/") != self.issuer:
|
||||
raise ValueError("OIDC issuer mismatch")
|
||||
issuer_origin = urlparse(self.issuer)
|
||||
for key in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
|
||||
endpoint = urlparse(document[key])
|
||||
if endpoint.scheme != "https" or (endpoint.hostname, endpoint.port) != (issuer_origin.hostname, issuer_origin.port):
|
||||
raise ValueError("Untrusted OIDC endpoint")
|
||||
return document
|
||||
|
||||
@staticmethod
|
||||
def _fetch_json(url: str, data: dict[str, str] | None = None) -> dict:
|
||||
encoded = urlencode(data).encode("utf-8") if data is not None else None
|
||||
request = Request(url, data=encoded, headers={"Accept": "application/json"})
|
||||
with urlopen(request, timeout=10) as response: # noqa: S310 - URL is validated against the configured HTTPS issuer.
|
||||
return json.loads(response.read())
|
||||
|
||||
def start(self) -> tuple[str, str]:
|
||||
if not self.enabled:
|
||||
raise ValueError("Authentik is not configured")
|
||||
state, nonce, verifier = secrets.token_urlsafe(32), secrets.token_urlsafe(32), 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:
|
||||
try:
|
||||
flow = self.serializer.loads(flow_cookie, max_age=600)
|
||||
except (BadSignature, SignatureExpired) as exc:
|
||||
raise ValueError("Invalid OIDC flow") from exc
|
||||
if not state or not secrets.compare_digest(state, str(flow.get("state", ""))):
|
||||
raise ValueError("OIDC state mismatch")
|
||||
discovery = self._discovery()
|
||||
token = self._fetch_json(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": flow["verifier"]}).get("id_token", "")
|
||||
claims = jwt.decode(token, jwt.PyJWKClient(discovery["jwks_uri"]).get_signing_key_from_jwt(token).key, algorithms=["RS256"], audience=self.settings.authentik_client_id, issuer=discovery["issuer"], options={"require": ["exp", "iat", "iss", "aud", "sub", "nonce"]})
|
||||
if claims.get("nonce") != flow["nonce"]:
|
||||
raise ValueError("OIDC nonce mismatch")
|
||||
email = str(claims.get("email", "")).strip().lower()
|
||||
allowed = str(self.settings.authentik_allowed_email or "").strip().lower()
|
||||
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