feat: add operator landing and login
GeoIntel release gates / Compile, test, contracts and builds (push) Canceled after 0s
GeoIntel release gates / Python and npm vulnerability policy (push) Canceled after 0s
GeoIntel release gates / GIS image, SBOM and container scan (push) Canceled after 0s

This commit is contained in:
Codex
2026-07-22 20:10:21 +02:00
parent 36d137e224
commit 115f9850a7
27 changed files with 1448 additions and 8 deletions
+1 -1
View File
@@ -1 +1 @@
__all__ = ["analysis", "areas", "assistant", "datasets", "health", "projects", "exports", "jobs", "external", "qa", "temporal"]
__all__ = ["analysis", "areas", "assistant", "auth", "datasets", "health", "projects", "exports", "jobs", "external", "qa", "temporal"]
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from datetime import UTC, datetime
from fastapi import APIRouter, Request, Response, status
from app.core.config import get_settings
from app.core.errors import AppError
from app.schemas.auth import AuthLoginRequest, AuthSession, AuthSessionEnvelope
from app.services.auth_service import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
COOKIE_NAME = "geointel_session"
def _session_payload(request: Request) -> AuthSession:
settings = get_settings()
if not settings.auth_enabled:
return AuthSession(authentication_required=False, authenticated=True)
principal = AuthService.verify_session_token(request.cookies.get(COOKIE_NAME), settings)
if principal is None:
return AuthSession(authentication_required=True, authenticated=False)
return AuthSession(
authentication_required=True,
authenticated=True,
username=principal.username,
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
)
@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,
)
forwarded_proto = request.headers.get("x-forwarded-proto", "").split(",", 1)[0].strip().lower()
response.set_cookie(
key=COOKIE_NAME,
value=token,
max_age=settings.auth_session_ttl_seconds,
httponly=True,
secure=forwarded_proto == "https" or request.url.scheme == "https",
samesite="strict",
path="/",
)
return AuthSessionEnvelope(
data=AuthSession(
authentication_required=True,
authenticated=True,
username=principal.username,
expires_at=datetime.fromtimestamp(principal.expires_at, tz=UTC),
)
)
@router.post("/logout", response_model=AuthSessionEnvelope)
def logout(response: Response) -> AuthSessionEnvelope:
response.delete_cookie(key=COOKIE_NAME, path="/", httponly=True, samesite="strict")
return AuthSessionEnvelope(
data=AuthSession(authentication_required=True, authenticated=False)
)
+23 -1
View File
@@ -1,4 +1,4 @@
from pydantic import Field, field_validator
from pydantic import Field, field_validator, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -18,6 +18,16 @@ class Settings(BaseSettings):
build_sha: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_SHA")
build_time: str | None = Field(default=None, validation_alias="GEOINTEL_BUILD_TIME")
api_prefix: str = Field(default="/api/v1", validation_alias="GEOINTEL_API_PREFIX")
auth_enabled: bool = Field(default=False, validation_alias="GEOINTEL_AUTH_ENABLED")
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")
auth_session_ttl_seconds: int = Field(
default=43_200,
ge=900,
le=604_800,
validation_alias="GEOINTEL_AUTH_SESSION_TTL_SECONDS",
)
database_url: str = Field(
default="postgresql+psycopg://geointel:geointel@localhost:5432/geointel?connect_timeout=1",
validation_alias="DATABASE_URL",
@@ -380,6 +390,18 @@ class Settings(BaseSettings):
raise ValueError("OLLAMA_BASE_URL must use http or https")
return normalized
@model_validator(mode="after")
def validate_operator_auth(self) -> "Settings":
if not self.auth_enabled:
return self
if not (self.auth_username or "").strip():
raise ValueError("GEOINTEL_AUTH_USERNAME is required when authentication is enabled")
if not (self.auth_password_hash or "").startswith("pbkdf2_sha256$"):
raise ValueError("GEOINTEL_AUTH_PASSWORD_HASH must be a PBKDF2-SHA256 hash")
if len(self.auth_session_secret or "") < 32:
raise ValueError("GEOINTEL_AUTH_SESSION_SECRET must contain at least 32 characters")
return self
def get_settings() -> Settings:
return Settings()
+36 -1
View File
@@ -11,13 +11,14 @@ from fastapi.exceptions import RequestValidationError
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app.api.routes import analysis, areas, assistant, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, selection_partitions, temporal
from app.api.routes import analysis, areas, assistant, auth, datasets, demo, detection, exports, external, health, jobs, projects, qa, quality_checks, segmentation, selection_partitions, temporal
from app.core.config import get_settings
from app.core.errors import AppError
from app.core.logging import configure_logging
from app.core.request_context import reset_request_id, set_request_id
from app.db.session import SessionLocal
from app.services.runtime_reconciliation_service import RuntimeReconciliationService
from app.services.auth_service import AuthService
logger = logging.getLogger("geointel")
@@ -79,6 +80,7 @@ def create_app() -> FastAPI:
)
app.include_router(health.router)
app.include_router(auth.router, prefix=settings.api_prefix)
app.include_router(analysis.router, prefix=settings.api_prefix)
app.include_router(projects.router, prefix=settings.api_prefix)
app.include_router(areas.router, prefix=settings.api_prefix)
@@ -128,6 +130,39 @@ def create_app() -> FastAPI:
)
response.headers["x-request-id"] = request_id
return response
public_auth_paths = {
f"{settings.api_prefix}/auth/session",
f"{settings.api_prefix}/auth/login",
f"{settings.api_prefix}/auth/logout",
}
direct_loopback_request = (
request.client is not None
and request.client.host in {"127.0.0.1", "::1"}
and not request.headers.get("x-real-ip")
and not request.headers.get("x-forwarded-for")
)
if (
settings.auth_enabled
and raw_path.startswith(f"{settings.api_prefix}/")
and raw_path not in public_auth_paths
and not direct_loopback_request
):
principal = AuthService.verify_session_token(
request.cookies.get(auth.COOKIE_NAME),
settings,
)
if principal is None:
response = JSONResponse(
status_code=401,
content=_to_error_payload(
"AUTHENTICATION_REQUIRED",
"Meld u aan om de GeoIntel API te gebruiken.",
request_id=request_id,
),
)
response.headers["x-request-id"] = request_id
return response
request.state.auth_principal = principal
response = await call_next(request)
response.headers["x-request-id"] = request_id
logger.info(
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from datetime import datetime
from pydantic import BaseModel, Field
from app.schemas.common import Envelope
class AuthLoginRequest(BaseModel):
username: str = Field(min_length=1, max_length=128)
password: str = Field(min_length=1, max_length=1024)
class AuthSession(BaseModel):
authentication_required: bool
authenticated: bool
username: str | None = None
expires_at: datetime | None = None
class AuthSessionEnvelope(Envelope[AuthSession]):
pass
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import secrets
import threading
import time
from collections import deque
from dataclasses import dataclass
from app.core.config import Settings
@dataclass(frozen=True)
class AuthPrincipal:
username: str
expires_at: int
class AuthService:
HASH_NAME = "pbkdf2_sha256"
HASH_ITERATIONS = 600_000
MAX_FAILURES = 5
FAILURE_WINDOW_SECONDS = 300
_failures: dict[str, deque[float]] = {}
_failure_lock = threading.Lock()
@staticmethod
def _b64_encode(value: bytes) -> str:
return base64.urlsafe_b64encode(value).decode("ascii").rstrip("=")
@staticmethod
def _b64_decode(value: str) -> bytes:
return base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
@classmethod
def hash_password(
cls,
password: str,
*,
salt: bytes | None = None,
iterations: int | None = None,
) -> str:
resolved_salt = salt or secrets.token_bytes(18)
resolved_iterations = iterations or cls.HASH_ITERATIONS
digest = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
resolved_salt,
resolved_iterations,
)
return "$".join(
(
cls.HASH_NAME,
str(resolved_iterations),
cls._b64_encode(resolved_salt),
cls._b64_encode(digest),
)
)
@classmethod
def verify_password(cls, password: str, encoded: str) -> bool:
try:
algorithm, iterations_raw, salt_raw, expected_raw = encoded.split("$", 3)
if algorithm != cls.HASH_NAME:
return False
iterations = int(iterations_raw)
if iterations < 100_000 or iterations > 2_000_000:
return False
salt = cls._b64_decode(salt_raw)
expected = cls._b64_decode(expected_raw)
actual = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
iterations,
)
return hmac.compare_digest(actual, expected)
except (TypeError, ValueError):
return False
@classmethod
def credentials_match(cls, username: str, password: str, settings: Settings) -> bool:
expected_username = settings.auth_username or ""
expected_password_hash = settings.auth_password_hash or ""
username_matches = hmac.compare_digest(
username.encode("utf-8"),
expected_username.encode("utf-8"),
)
password_matches = cls.verify_password(password, expected_password_hash)
return username_matches and password_matches
@classmethod
def create_session_token(cls, username: str, settings: Settings, *, now: int | None = None) -> str:
issued_at = int(time.time() if now is None else now)
payload = {
"exp": issued_at + settings.auth_session_ttl_seconds,
"iat": issued_at,
"jti": secrets.token_urlsafe(12),
"sub": username,
"v": 1,
}
encoded_payload = cls._b64_encode(
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
)
signature = hmac.new(
(settings.auth_session_secret or "").encode("utf-8"),
encoded_payload.encode("ascii"),
hashlib.sha256,
).digest()
return f"{encoded_payload}.{cls._b64_encode(signature)}"
@classmethod
def verify_session_token(
cls,
token: str | None,
settings: Settings,
*,
now: int | None = None,
) -> AuthPrincipal | None:
if not token:
return None
try:
encoded_payload, encoded_signature = token.split(".", 1)
expected_signature = hmac.new(
(settings.auth_session_secret or "").encode("utf-8"),
encoded_payload.encode("ascii"),
hashlib.sha256,
).digest()
supplied_signature = cls._b64_decode(encoded_signature)
if not hmac.compare_digest(expected_signature, supplied_signature):
return None
payload = json.loads(cls._b64_decode(encoded_payload))
username = str(payload.get("sub") or "")
expires_at = int(payload.get("exp") or 0)
issued_at = int(payload.get("iat") or 0)
current = int(time.time() if now is None else now)
if payload.get("v") != 1 or username != settings.auth_username:
return None
if issued_at <= 0 or issued_at > current + 60 or expires_at <= current:
return None
if expires_at - issued_at > settings.auth_session_ttl_seconds:
return None
return AuthPrincipal(username=username, expires_at=expires_at)
except (TypeError, ValueError, json.JSONDecodeError, UnicodeDecodeError):
return None
@classmethod
def retry_after_seconds(cls, key: str, *, now: float | None = None) -> int:
current = time.monotonic() if now is None else now
with cls._failure_lock:
attempts = cls._failures.setdefault(key, deque())
while attempts and current - attempts[0] >= cls.FAILURE_WINDOW_SECONDS:
attempts.popleft()
if len(attempts) < cls.MAX_FAILURES:
if not attempts:
cls._failures.pop(key, None)
return 0
return max(1, int(cls.FAILURE_WINDOW_SECONDS - (current - attempts[0])))
@classmethod
def record_failure(cls, key: str, *, now: float | None = None) -> None:
current = time.monotonic() if now is None else now
with cls._failure_lock:
attempts = cls._failures.setdefault(key, deque())
while attempts and current - attempts[0] >= cls.FAILURE_WINDOW_SECONDS:
attempts.popleft()
attempts.append(current)
@classmethod
def clear_failures(cls, key: str) -> None:
with cls._failure_lock:
cls._failures.pop(key, None)
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import create_app
from app.services.auth_service import AuthService
def auth_client(monkeypatch) -> TestClient:
password_hash = AuthService.hash_password(
"correct horse battery staple",
salt=b"geointel-test-salt",
iterations=100_000,
)
monkeypatch.setenv("GEOINTEL_AUTH_ENABLED", "true")
monkeypatch.setenv("GEOINTEL_AUTH_USERNAME", "operator")
monkeypatch.setenv("GEOINTEL_AUTH_PASSWORD_HASH", password_hash)
monkeypatch.setenv("GEOINTEL_AUTH_SESSION_SECRET", "test-session-secret-that-is-long-enough")
return TestClient(create_app())
def test_auth_session_and_health_are_public_but_api_is_protected(monkeypatch) -> None:
client = auth_client(monkeypatch)
session = client.get("/api/v1/auth/session")
protected = client.get("/api/v1/protected-probe")
health = client.get("/health/live")
assert session.status_code == 200
assert session.json()["data"] == {
"authentication_required": True,
"authenticated": False,
"username": None,
"expires_at": None,
}
assert protected.status_code == 401
assert protected.json()["error"] == "AUTHENTICATION_REQUIRED"
assert health.status_code == 200
def test_login_uses_http_only_session_cookie_and_logout_revokes_browser_access(monkeypatch) -> None:
client = auth_client(monkeypatch)
invalid = client.post(
"/api/v1/auth/login",
json={"username": "operator", "password": "wrong"},
)
login = client.post(
"/api/v1/auth/login",
json={"username": "operator", "password": "correct horse battery staple"},
)
authenticated = client.get("/api/v1/auth/session")
protected_after_login = client.get("/api/v1/protected-probe")
logout = client.post("/api/v1/auth/logout")
protected_after_logout = client.get("/api/v1/protected-probe")
assert invalid.status_code == 401
assert invalid.json()["error"] == "INVALID_CREDENTIALS"
assert login.status_code == 200
assert login.json()["data"]["username"] == "operator"
cookie = login.headers["set-cookie"].lower()
assert "httponly" in cookie
assert "samesite=strict" in cookie
assert authenticated.json()["data"]["authenticated"] is True
assert protected_after_login.status_code == 404
assert logout.status_code == 200
assert protected_after_logout.status_code == 401
def test_password_hash_and_session_signatures_fail_closed(monkeypatch) -> None:
client = auth_client(monkeypatch)
login = client.post(
"/api/v1/auth/login",
json={"username": "operator", "password": "correct horse battery staple"},
)
token = login.cookies.get("geointel_session")
assert token
client.cookies.set("geointel_session", f"{token}tampered")
session = client.get("/api/v1/auth/session")
assert session.status_code == 200
assert session.json()["data"]["authenticated"] is False
def test_unraid_runtime_carries_only_hashed_operator_credentials() -> None:
root = Path(__file__).resolve().parents[2]
runner = (root / "deploy/unraid/run-dockerman-container.sh").read_text(encoding="utf-8")
example = (root / "deploy/unraid/geointel.env.example").read_text(encoding="utf-8")
browser_smoke = (root / "scripts/verify_browser_runtime.sh").read_text(encoding="utf-8")
assert '-e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH"' in runner
assert "GEOINTEL_AUTH_PASSWORD_HASH=" in example
assert "GEOINTEL_AUTH_PASSWORD=" not in runner
assert "/api/v1/auth/session" in browser_smoke