feat: add operator landing and login
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user