diff --git a/CHANGELOG.md b/CHANGELOG.md index ab038331..17148843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ ## Unreleased - Post-V1 capability completion (2026-07-19) +- Implemented the supplied Stitch landing-page direction as the real React + entry surface, with responsive navigation, accurate Belgian/North Sea + product copy, a project-owned optimized hero asset, loading/error states and + a direct transition into the existing map-first workbench. +- Added an optional single-operator login gate with PBKDF2-SHA256 password + verification, signed HttpOnly/SameSite sessions, expiry, brute-force + throttling, logout and API middleware enforcement. Plaintext credentials are + never committed or returned to the browser; trusted direct-loopback operator + scripts remain available without introducing multi-user persistence. + - Added the official WALOUS 2018 GeoTIFF as a third live-provisioned Walloon land-cover epoch. Its stable SPW artifact, archive/raster checksums, EPSG:3812 identity and published stacked class codes are validated fail-closed. The diff --git a/backend/app/api/routes/__init__.py b/backend/app/api/routes/__init__.py index 9bbd49fb..3b08153b 100644 --- a/backend/app/api/routes/__init__.py +++ b/backend/app/api/routes/__init__.py @@ -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"] diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 00000000..52b5c36b --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -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) + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 6be53d2c..f549f66f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 87947855..bfa4d203 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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( diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 00000000..f4c13942 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 00000000..d592242d --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 00000000..bc0fd7c2 --- /dev/null +++ b/backend/tests/test_auth.py @@ -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 diff --git a/deploy/unraid/README.md b/deploy/unraid/README.md index fc3d1140..e5d12435 100644 --- a/deploy/unraid/README.md +++ b/deploy/unraid/README.md @@ -76,6 +76,13 @@ bash deploy/unraid/deploy-release.sh ``` Set `GEOINTEL_POSTGRES_PASSWORD` to a unique value before that first start. + +For a browser login, set `GEOINTEL_AUTH_ENABLED=true`, configure one exact +`GEOINTEL_AUTH_USERNAME`, a `pbkdf2_sha256` password hash and an independent +random `GEOINTEL_AUTH_SESSION_SECRET` of at least 32 characters. The plaintext +password is never stored in the repository or container configuration. Browser +API calls require the signed HttpOnly session cookie; direct loopback calls to +the backend remain available to trusted in-container operator scripts. Production startup fails before replacing the active container when the password is empty or one of the documented defaults. diff --git a/deploy/unraid/geointel-unraid-template.xml b/deploy/unraid/geointel-unraid-template.xml index 2d5979c5..4a73390b 100644 --- a/deploy/unraid/geointel-unraid-template.xml +++ b/deploy/unraid/geointel-unraid-template.xml @@ -32,6 +32,11 @@ change-me-before-shared-use http://localhost:1202,http://127.0.0.1:1202,http://192.168.10.150:1202 500 + false + + + + 43200 true https://geo.api.vlaanderen.be/OMWRGBMRVL/wms Ortho diff --git a/deploy/unraid/geointel.env.example b/deploy/unraid/geointel.env.example index 0ae6c580..95028eaf 100644 --- a/deploy/unraid/geointel.env.example +++ b/deploy/unraid/geointel.env.example @@ -29,6 +29,15 @@ GEOINTEL_CORS_ORIGINS=http://localhost:1202,http://127.0.0.1:1202,http://192.168 # Upload guard in MiB. The same 1-2048 limit is applied by nginx and FastAPI. GEOINTEL_MAX_UPLOAD_MB=500 +# Optional single-operator access gate. Never store a plaintext password here. +# Generate the password hash with AuthService.hash_password and use a unique, +# random session secret of at least 32 characters. +GEOINTEL_AUTH_ENABLED=false +GEOINTEL_AUTH_USERNAME= +GEOINTEL_AUTH_PASSWORD_HASH= +GEOINTEL_AUTH_SESSION_SECRET= +GEOINTEL_AUTH_SESSION_TTL_SECONDS=43200 + # Explicit, bounded acquisition from the official Digitaal Vlaanderen WMS. ORTHOPHOTO_ENABLED=true ORTHOPHOTO_WMS_URL=https://geo.api.vlaanderen.be/OMWRGBMRVL/wms diff --git a/deploy/unraid/run-dockerman-container.sh b/deploy/unraid/run-dockerman-container.sh index 23f36b59..415102ef 100644 --- a/deploy/unraid/run-dockerman-container.sh +++ b/deploy/unraid/run-dockerman-container.sh @@ -33,6 +33,11 @@ GEOINTEL_POSTGRES_USER="${GEOINTEL_POSTGRES_USER:-geointel}" GEOINTEL_POSTGRES_PASSWORD="${GEOINTEL_POSTGRES_PASSWORD:-}" GEOINTEL_CORS_ORIGINS="${GEOINTEL_CORS_ORIGINS:-http://localhost:${GEOINTEL_FRONTEND_PORT},http://127.0.0.1:${GEOINTEL_FRONTEND_PORT},http://192.168.10.150:${GEOINTEL_FRONTEND_PORT}}" GEOINTEL_MAX_UPLOAD_MB="${GEOINTEL_MAX_UPLOAD_MB:-500}" +GEOINTEL_AUTH_ENABLED="${GEOINTEL_AUTH_ENABLED:-false}" +GEOINTEL_AUTH_USERNAME="${GEOINTEL_AUTH_USERNAME:-}" +GEOINTEL_AUTH_PASSWORD_HASH="${GEOINTEL_AUTH_PASSWORD_HASH:-}" +GEOINTEL_AUTH_SESSION_SECRET="${GEOINTEL_AUTH_SESSION_SECRET:-}" +GEOINTEL_AUTH_SESSION_TTL_SECONDS="${GEOINTEL_AUTH_SESSION_TTL_SECONDS:-43200}" ORTHOPHOTO_ENABLED="${ORTHOPHOTO_ENABLED:-true}" ORTHOPHOTO_WMS_URL="${ORTHOPHOTO_WMS_URL:-https://geo.api.vlaanderen.be/OMWRGBMRVL/wms}" ORTHOPHOTO_WMS_LAYER="${ORTHOPHOTO_WMS_LAYER:-Ortho}" @@ -171,6 +176,29 @@ validate_runtime_config() { return 2 fi + case "$GEOINTEL_AUTH_ENABLED" in + true|false) ;; + *) + echo "GEOINTEL_AUTH_ENABLED must be true or false." >&2 + return 2 + ;; + esac + if [ "$GEOINTEL_AUTH_ENABLED" = "true" ]; then + if [ -z "$GEOINTEL_AUTH_USERNAME" ] \ + || [ -z "$GEOINTEL_AUTH_PASSWORD_HASH" ] \ + || [ "${#GEOINTEL_AUTH_SESSION_SECRET}" -lt 32 ]; then + echo "Enabled operator authentication requires username, password hash and a 32+ character session secret." >&2 + return 2 + fi + case "$GEOINTEL_AUTH_PASSWORD_HASH" in + pbkdf2_sha256\$*) ;; + *) + echo "GEOINTEL_AUTH_PASSWORD_HASH must use the pbkdf2_sha256 format." >&2 + return 2 + ;; + esac + fi + case "$GEOINTEL_POSTGRES_PASSWORD" in ''|geointel|postgres|password|changeme|change-me-before-shared-use) echo "Refusing deployment with an empty or known-default PostGIS password." >&2 @@ -241,6 +269,11 @@ docker run -d \ -e GEOINTEL_STORAGE_ROOT=/app/storage \ -e GEOINTEL_CORS_ORIGINS="$GEOINTEL_CORS_ORIGINS" \ -e GEOINTEL_MAX_UPLOAD_MB="$GEOINTEL_MAX_UPLOAD_MB" \ + -e GEOINTEL_AUTH_ENABLED="$GEOINTEL_AUTH_ENABLED" \ + -e GEOINTEL_AUTH_USERNAME="$GEOINTEL_AUTH_USERNAME" \ + -e GEOINTEL_AUTH_PASSWORD_HASH="$GEOINTEL_AUTH_PASSWORD_HASH" \ + -e GEOINTEL_AUTH_SESSION_SECRET="$GEOINTEL_AUTH_SESSION_SECRET" \ + -e GEOINTEL_AUTH_SESSION_TTL_SECONDS="$GEOINTEL_AUTH_SESSION_TTL_SECONDS" \ -e ORTHOPHOTO_ENABLED="$ORTHOPHOTO_ENABLED" \ -e ORTHOPHOTO_WMS_URL="$ORTHOPHOTO_WMS_URL" \ -e ORTHOPHOTO_WMS_LAYER="$ORTHOPHOTO_WMS_LAYER" \ diff --git a/docs/API_CONTRACTS.md b/docs/API_CONTRACTS.md index 2e302afc..2c21a568 100644 --- a/docs/API_CONTRACTS.md +++ b/docs/API_CONTRACTS.md @@ -45,6 +45,57 @@ Any valid GeoJSON geometry object. V1 primarily expects `Polygon` and `MultiPoly } ``` +## Operator authentication + +Authentication is an optional single-operator access gate, not multi-user +account management. When `GEOINTEL_AUTH_ENABLED=true`, every `/api/v1/*` +request except the three authentication endpoints below requires a valid +signed `geointel_session` cookie. Missing, expired or modified sessions return +HTTP 401 with `AUTHENTICATION_REQUIRED`. Direct loopback calls to the backend +without proxy headers remain available to trusted in-container operator tools; +the backend is bound to loopback in the all-in-one runtime. + +The runtime stores only a PBKDF2-SHA256 password hash and an independent +session-signing secret. The browser receives an HttpOnly, SameSite=Strict, +time-limited cookie. Five failed attempts for one client/username combination +within five minutes temporarily return HTTP 429 `LOGIN_RATE_LIMITED`. + +### GET `/api/v1/auth/session` + +Public session probe used by the frontend before it mounts the workbench. +When authentication is disabled, `authenticated` is true and +`authentication_required` is false so local development retains its existing +direct workflow. + +```json +{ + "data": { + "authentication_required": true, + "authenticated": false, + "username": null, + "expires_at": null + } +} +``` + +### POST `/api/v1/auth/login` + +```json +{ + "username": "operator", + "password": "user-supplied secret" +} +``` + +Successful login sets the session cookie and returns the authenticated session +shape. Invalid credentials return HTTP 401 `INVALID_CREDENTIALS`; username +existence is not disclosed. + +### POST `/api/v1/auth/logout` + +Clears the browser cookie and returns an unauthenticated session. Logout is +idempotent and remains callable when the current cookie is missing or expired. + ## Health ### GET `/health/live` diff --git a/docs/CODEX_EXECUTION_LOG.md b/docs/CODEX_EXECUTION_LOG.md index 305934c8..b4e93b34 100644 --- a/docs/CODEX_EXECUTION_LOG.md +++ b/docs/CODEX_EXECUTION_LOG.md @@ -11215,3 +11215,32 @@ Pre-release evidence: coverage 1.0, mean 159.3529 m DNG, minimum 51.7473 m DNG, maximum 223.4930 m DNG and mean slope 5.2786 degrees. Water depth and volume remain explicitly unsupported because an MNT cannot establish either quantity. + +## 2026-07-22 - Stitch landing page and operator login + +Implemented: + +- translated the supplied `stitch_geointel_complete_workbench_redesign.zip` + into a native responsive React landing page rather than embedding its static + Tailwind mockup or temporary external image URLs; +- added a project-owned optimized Belgium/North Sea hero asset and kept the + existing operational workbench unchanged behind the access boundary; +- added public session probing, server-side PBKDF2-SHA256 credential checks, + signed HttpOnly/SameSite session cookies, expiry, failed-login throttling and + idempotent logout; +- protected proxied browser API requests while retaining trusted direct + loopback access for in-container operator scripts; no account table, + registration, role system or other multi-user scope was introduced; +- extended the Unraid environment/template and release runtime smoke to carry + the login configuration without storing a plaintext password. + +Pre-deployment validation: + +- the complete readiness gate passed with 1,107 backend tests and 36 frontend + tests, backend compile, API/documentation contract audit, frontend typecheck, + production build, Alembic head `202607160001` and all script checks; +- the authentication tests cover unauthenticated API rejection, successful + login with an HttpOnly/SameSite cookie, logout, signature tampering and the + deployment guarantee that only a password hash reaches the container. + +Live deployment and login-journey evidence are appended after rollout. diff --git a/docs/KNOWN_LIMITATIONS.md b/docs/KNOWN_LIMITATIONS.md index e8005c53..1c38f0ee 100644 --- a/docs/KNOWN_LIMITATIONS.md +++ b/docs/KNOWN_LIMITATIONS.md @@ -6,6 +6,11 @@ These limitations are explicit, bounded and non-deceptive. None is a hidden CRITICAL or HIGH release defect. Coverage and capability responses remain the runtime source of truth. +- The access gate intentionally supports one environment-configured operator + account. There is no registration, password-recovery email, role model, + organisation management or multi-user database. Password rotation is an + operator configuration action followed by a runtime restart. + ## Source coverage - National administrative land and maritime scope is operational from diff --git a/docs/TODO.md b/docs/TODO.md index 5c36c137..22b59fb4 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -2,6 +2,11 @@ ## Actieve post-RC datadekkingsfase +- [x] Implementeer de aangeleverde Stitch-landingspagina als echte React- + toegangspoort en bescherm de workbench met één veilig geconfigureerd + operatoraccount, sessieverval, uitloggen en zichtbare foutstatussen. Dit is + geen multi-user- of tenantbeheersysteem. + `docs/POST_RC_DATA_COVERAGE_ROADMAP_BELGIUM_NORTH_SEA.md` was het autonome uitvoeringsbord na `v1.0.0-rc.1` en is nu afgesloten voor `v1.0.0`. Dit is geen RC-12. De definitieve release blijft immutable bewijs; verdere databronnen diff --git a/frontend/index.html b/frontend/index.html index 5d73b070..11e268a1 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,9 +1,11 @@ - + + + GeoIntel diff --git a/frontend/public/landing-hero-belgium.webp b/frontend/public/landing-hero-belgium.webp new file mode 100644 index 00000000..b62701ec Binary files /dev/null and b/frontend/public/landing-hero-belgium.webp differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 926df9b7..e9d35892 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react' +import { LogOut, UserRound } from 'lucide-react' import '@fontsource/manrope/latin-500.css' import '@fontsource/manrope/latin-600.css' import '@fontsource/manrope/latin-700.css' @@ -8,6 +9,7 @@ import '@fontsource/public-sans/latin-600.css' import './styles/app.css' import './styles/premium.css' import './styles/atlas-workbench.css' +import { LandingPage } from './components/auth/LandingPage' import { ChangeDetectionPanel } from './components/analysis/ChangeDetectionPanel' import { GeoAssistantPanel } from './components/assistant/GeoAssistantPanel' import { DatasetPanel } from './components/datasets/DatasetPanel' @@ -38,6 +40,7 @@ import { useExportWorkflow } from './hooks/useExportWorkflow' import { useMapSelectionDataset } from './hooks/useMapSelectionDataset' import { useMapSelectionQa } from './hooks/useMapSelectionQa' import { useMapWorkspaceState } from './hooks/useMapWorkspaceState' +import { useOperatorSession } from './hooks/useOperatorSession' import { useMapSelectionExtract } from './hooks/useMapSelectionExtract' import { useMapOrthophotoAnalysis } from './hooks/useMapOrthophotoAnalysis' import { isDetectionImageryDataset, isMapRasterDataset } from './lib/datasetCapabilities' @@ -81,7 +84,13 @@ const workspaceNavGroups: WorkspaceNavigationGroup[] = [ { label: 'Beheer', keys: ['overview', 'system'] }, ] -function App(): JSX.Element { +interface WorkbenchAppProps { + username: string | null + loggingOut: boolean + onLogout: () => void +} + +function WorkbenchApp({ username, loggingOut, onLogout }: WorkbenchAppProps): JSX.Element { const [activeWorkspace, setActiveWorkspace] = useState('map') const [inspectorOpen, setInspectorOpen] = useState(false) const [mapContentMode, setMapContentMode] = useState<'dataset' | 'analysis'>('dataset') @@ -818,6 +827,16 @@ function App(): JSX.Element {