From 3991362e67665ba4344f8d30804e3365481fb9e2 Mon Sep 17 00:00:00 2001 From: stephan Date: Sun, 28 Dec 2025 09:16:16 +0100 Subject: [PATCH] feature: add oidc login flow --- README.md | 15 ++-- backend/app.py | 100 ++++++++++++++++++++++- backend/auth.py | 18 +++- backend/oidc.py | 140 ++++++++++++++++++++++++++++++++ backend/requirements.txt | 2 + backend/settings.py | 17 ++++ backend/templates/index.html | 36 +++++++- env.example | 10 +++ scripts/register_oidc_client.sh | 74 +++++++++++++++++ 9 files changed, 398 insertions(+), 14 deletions(-) create mode 100644 backend/oidc.py create mode 100755 scripts/register_oidc_client.sh diff --git a/README.md b/README.md index 2e72093..be19f2f 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,13 @@ Then open `http://localhost:8000/` and set the API token in the UI. ## Configuration Set in `/etc/skd/env` (see `env.example`): -- `SKD_AUTH_SECRET`: HMAC secret for bearer tokens (set a strong value). +- `SKD_AUTH_MODE`: `pam` (default) or `oidc`. +- `SKD_AUTH_SECRET`: HMAC secret for bearer tokens/cookies (set a strong value). - `SKD_TOKEN_TTL_SECONDS`: token lifetime (default 900s). -- `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in. -- `SKD_AUTH_ALLOWED_GROUPS`: groups whose members may log in (default `sudo`). +- `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in (used for PAM and as an allowlist for OIDC claims). +- `SKD_AUTH_ALLOWED_GROUPS`: groups whose members may log in (PAM only, default `sudo`). +- `SKD_OIDC_*`: `ISSUER`, `CLIENT_ID`, `CLIENT_SECRET`, `REDIRECT_URI`, `SCOPES` to point at your OIDC provider; set `SKD_SESSION_COOKIE_SECURE=true` for HTTPS. +- OIDC dynamic registration helper: `scripts/register_oidc_client.sh` (requires `OIDC_INITIAL_ACCESS_TOKEN` and `SKD_OIDC_ISSUER`; uses `SKD_OIDC_REDIRECT_URI` for the redirect). Run once during setup if your provider issues initial access tokens for client creation. - `SKD_ALLOWED_USERS`: optional comma list to limit manageable accounts (must exist on the system). - `SKD_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`: behavior defaults. - `SKD_DRY_RUN=true` to test without real account changes or shutdown. @@ -34,7 +37,8 @@ Notes: ## Running - Service: managed by systemd; `./scripts/install.sh` writes the unit dynamically to `/etc/systemd/system/skd.service` with the current repo path and restarts it. - Manual run: `./scripts/run.sh` (uses `.venv`, defaults to `0.0.0.0:8000`). -- Login: `curl -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login` +- Login (PAM): `curl -X POST -H "Content-Type: application/json" -d '{"username":"root","password":"..."}' http://localhost:8000/login` +- Login (OIDC): open `http://localhost:8000/login/oidc/start` → provider → redirected back with session cookie set. - Health: `curl -H "Authorization: Bearer " http://localhost:8000/health` ## API (Bearer token via `/login`) @@ -42,6 +46,7 @@ Notes: - `POST /users/{name}/disable` with JSON `{countdown?, sound?, message?}` - `POST /users/{name}/enable` - `GET /health` +- `GET /me` (returns current user + auth mode when a session/bearer token is present) Example: ```bash @@ -53,7 +58,7 @@ curl -X POST -H "Authorization: Bearer $token" \ ``` ## Web UI -Served at `/`. Login mit Root-Account, danach werden verfügbare System-User angezeigt; Aktionen senden Bearer Token automatisch. +Served at `/`. Nutze den Button „Login via OIDC“ (setzt Session-Cookie) oder das PAM-Formular, falls OIDC deaktiviert; danach werden verfügbare System-User angezeigt und Aktionen senden Token/Cookies automatisch. ## Updates - Remote update via SSH: `ssh user@kid-laptop 'cd /opt/sk && ./scripts/update.sh'` (fetch/reset to `origin/main`, reinstalls deps, restarts service). diff --git a/backend/app.py b/backend/app.py index 3b3cd80..e58c4ed 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,14 +1,21 @@ import logging from typing import List -from fastapi import Body, Depends, FastAPI, HTTPException, Request, status -from fastapi.responses import HTMLResponse +from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status +from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from backend import actions from backend.actions import ActionError -from backend.auth import authenticate_admin_user, get_current_admin, issue_token, list_manageable_users +from backend.auth import ( + authenticate_admin_user, + get_current_admin, + is_authorized_admin, + issue_token, + list_manageable_users, +) from backend.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus +from backend.oidc import OIDCClient, OIDCError from backend.settings import Settings, get_settings logging.basicConfig( @@ -21,6 +28,20 @@ app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0") templates = Jinja2Templates(directory="backend/templates") +def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient: + if settings.auth_mode != "oidc": + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="OIDC auth not enabled", + ) + try: + return OIDCClient(settings) + except OIDCError as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc) + ) from exc + + def validate_user(username: str, settings: Settings = Depends(get_settings)) -> str: allowed = set(list_manageable_users(settings)) if username not in allowed: @@ -33,13 +54,84 @@ def health(settings: Settings = Depends(get_settings)) -> dict: return {"status": "ok", "dry_run": settings.dry_run} +@app.get("/me") +def whoami( + current_user: str = Depends(get_current_admin), + settings: Settings = Depends(get_settings), +) -> dict: + return {"user": current_user, "auth_mode": settings.auth_mode} + + @app.post("/login", response_model=LoginResponse) -def login(payload: LoginRequest, settings: Settings = Depends(get_settings)) -> LoginResponse: +def login( + payload: LoginRequest, + response: Response, + settings: Settings = Depends(get_settings), +) -> LoginResponse: authenticate_admin_user(payload.username, payload.password, settings) token = issue_token(payload.username, settings) + response.set_cookie( + settings.session_cookie_name, + token, + max_age=settings.token_ttl_seconds, + httponly=True, + secure=settings.session_cookie_secure, + samesite="lax", + ) return LoginResponse(token=token, expires_in=settings.token_ttl_seconds) +@app.get("/login/oidc/start") +def oidc_start( + settings: Settings = Depends(get_settings), + oidc: OIDCClient = Depends(get_oidc_client), +): + state = oidc.build_state_token() + redirect = RedirectResponse(url=oidc.authorization_url(state)) + redirect.set_cookie( + settings.oidc_state_cookie_name, + state, + max_age=300, + httponly=True, + secure=settings.session_cookie_secure, + samesite="lax", + ) + return redirect + + +@app.get("/login/oidc/callback") +def oidc_callback( + request: Request, + code: str, + state: str, + settings: Settings = Depends(get_settings), + oidc: OIDCClient = Depends(get_oidc_client), +): + stored_state = request.cookies.get(settings.oidc_state_cookie_name, "") + if not oidc.is_state_valid(state, stored_state): + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid OIDC state") + + claims = oidc.exchange_code_for_claims(code) + username = oidc.extract_username(claims) + if not username: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing username claim") + if not is_authorized_admin(username, settings): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not authorized to log in") + + token = issue_token(username, settings) + redirect = RedirectResponse(url="/") + redirect.set_cookie( + settings.session_cookie_name, + token, + max_age=settings.token_ttl_seconds, + httponly=True, + secure=settings.session_cookie_secure, + samesite="lax", + ) + redirect.delete_cookie(settings.oidc_state_cookie_name) + return redirect + + @app.get("/users", response_model=List[UserStatus], dependencies=[Depends(get_current_admin)]) def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]: logged_in = set(actions.list_logged_in_users()) diff --git a/backend/auth.py b/backend/auth.py index d766639..a402bd4 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -24,6 +24,11 @@ def _is_member_of(username: str, groups: Set[str]) -> bool: def is_authorized_admin(username: str, settings: Settings) -> bool: + if settings.auth_mode == "oidc": + allowed_users = set(settings.auth_allowed_users) + if allowed_users and username not in allowed_users: + return False + return True # UID 0 always allowed try: entry = pwd.getpwnam(username) @@ -41,6 +46,11 @@ def is_authorized_admin(username: str, settings: Settings) -> bool: def authenticate_admin_user(username: str, password: str, settings: Settings) -> None: + if settings.auth_mode != "pam": + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Password login disabled; OIDC is configured", + ) if not is_authorized_admin(username, settings): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, @@ -82,9 +92,13 @@ def get_current_admin( settings: Settings = Depends(get_settings), ) -> str: auth_header = request.headers.get("authorization") - if not auth_header or not auth_header.lower().startswith("bearer "): + token = None + if auth_header and auth_header.lower().startswith("bearer "): + token = auth_header.split(" ", 1)[1].strip() + if not token: + token = request.cookies.get(settings.session_cookie_name) + if not token: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token") - token = auth_header.split(" ", 1)[1].strip() return decode_token(token, settings) diff --git a/backend/oidc.py b/backend/oidc.py new file mode 100644 index 0000000..60b5d8d --- /dev/null +++ b/backend/oidc.py @@ -0,0 +1,140 @@ +import hashlib +import hmac +import secrets +import urllib.parse +from dataclasses import dataclass +from functools import lru_cache +from typing import Any, Dict, Optional + +import httpx +import jwt +from fastapi import HTTPException, status +from jwt import PyJWKClient + +from backend.settings import Settings + + +@dataclass +class OIDCConfig: + issuer: str + authorization_endpoint: str + token_endpoint: str + jwks_uri: str + userinfo_endpoint: Optional[str] = None + + +class OIDCError(Exception): + """Raised when the OIDC provider cannot be used.""" + + +@lru_cache(maxsize=1) +def _load_provider_config(issuer: str) -> OIDCConfig: + discovery_url = urllib.parse.urljoin(issuer.rstrip("/") + "/", ".well-known/openid-configuration") + try: + with httpx.Client(timeout=5.0) as client: + resp = client.get(discovery_url) + resp.raise_for_status() + except httpx.HTTPError as exc: # pragma: no cover - network failure branch + raise OIDCError(f"Failed to load discovery document: {exc}") from exc + + data = resp.json() + required = ("issuer", "authorization_endpoint", "token_endpoint", "jwks_uri") + if not all(key in data for key in required): + raise OIDCError("Discovery document missing required fields") + + return OIDCConfig( + issuer=data["issuer"], + authorization_endpoint=data["authorization_endpoint"], + token_endpoint=data["token_endpoint"], + jwks_uri=data["jwks_uri"], + userinfo_endpoint=data.get("userinfo_endpoint"), + ) + + +class OIDCClient: + def __init__(self, settings: Settings) -> None: + self.settings = settings + if not settings.oidc_issuer: + raise OIDCError("SKD_OIDC_ISSUER not configured") + if not settings.oidc_client_id or not settings.oidc_client_secret: + raise OIDCError("SKD_OIDC_CLIENT_ID/SECRET must be set") + self.config = _load_provider_config(settings.oidc_issuer) + self.jwk_client = PyJWKClient(self.config.jwks_uri) + + def build_state_token(self) -> str: + raw = secrets.token_urlsafe(24) + sig = hmac.new(self.settings.auth_secret.encode(), raw.encode(), hashlib.sha256).hexdigest() + return f"{raw}.{sig}" + + def is_state_valid(self, provided: str, stored: str) -> bool: + if not provided or not stored or provided != stored: + return False + try: + raw, sig = provided.split(".", 1) + except ValueError: + return False + expected = hmac.new(self.settings.auth_secret.encode(), raw.encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(sig, expected) + + def authorization_url(self, state: str) -> str: + params = { + "client_id": self.settings.oidc_client_id, + "redirect_uri": self.settings.oidc_redirect_uri, + "response_type": "code", + "scope": self.settings.oidc_scopes, + "state": state, + } + return f"{self.config.authorization_endpoint}?{urllib.parse.urlencode(params)}" + + def exchange_code_for_claims(self, code: str) -> Dict[str, Any]: + payload = { + "grant_type": "authorization_code", + "code": code, + "redirect_uri": self.settings.oidc_redirect_uri, + "client_id": self.settings.oidc_client_id, + "client_secret": self.settings.oidc_client_secret, + } + headers = {"Content-Type": "application/x-www-form-urlencoded"} + try: + with httpx.Client(timeout=10.0) as client: + resp = client.post(self.config.token_endpoint, data=payload, headers=headers) + except httpx.HTTPError as exc: # pragma: no cover - network failure branch + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=f"OIDC token request failed: {exc}", + ) from exc + + if resp.status_code != status.HTTP_200_OK: + detail = resp.text or "token request failed" + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"OIDC token exchange failed: {detail}", + ) + + token_response = resp.json() + id_token = token_response.get("id_token") + if not id_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="OIDC response missing id_token", + ) + + signing_key = self.jwk_client.get_signing_key_from_jwt(id_token).key + try: + claims = jwt.decode( + id_token, + signing_key, + algorithms=["RS256"], + audience=self.settings.oidc_client_id, + issuer=self.config.issuer, + ) + except jwt.PyJWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=f"Invalid ID token: {exc}", + ) from exc + return claims + + @staticmethod + def extract_username(claims: Dict[str, Any]) -> Optional[str]: + return claims.get("preferred_username") or claims.get("email") or claims.get("sub") diff --git a/backend/requirements.txt b/backend/requirements.txt index ed84f22..947fbe8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,5 @@ jinja2==3.1.4 PyJWT==2.9.0 python-pam==2.0.2 six==1.16.0 +httpx==0.27.2 +cryptography==43.0.1 diff --git a/backend/settings.py b/backend/settings.py index 4a11b11..4a7ebbf 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -7,6 +7,9 @@ class Settings: """Application settings loaded from environment.""" def __init__(self) -> None: + self.auth_mode: str = os.getenv("SKD_AUTH_MODE", "pam").lower() + if self.auth_mode not in ("pam", "oidc"): + self.auth_mode = "pam" self.allowed_users: List[str] = self._parse_list(os.getenv("SKD_ALLOWED_USERS", "")) self.auth_secret: str = os.getenv("SKD_AUTH_SECRET", "change-me-secret") self.token_ttl_seconds: int = int(os.getenv("SKD_TOKEN_TTL_SECONDS", "900")) @@ -15,6 +18,20 @@ class Settings: os.getenv("SKD_AUTH_ALLOWED_GROUPS", "sudo") ) self.auth_pam_service: str = os.getenv("SKD_AUTH_PAM_SERVICE", "login") + self.oidc_issuer: str = os.getenv("SKD_OIDC_ISSUER", "") + self.oidc_client_id: str = os.getenv("SKD_OIDC_CLIENT_ID", "") + self.oidc_client_secret: str = os.getenv("SKD_OIDC_CLIENT_SECRET", "") + self.oidc_redirect_uri: str = os.getenv( + "SKD_OIDC_REDIRECT_URI", "http://localhost:8000/login/oidc/callback" + ) + self.oidc_scopes: str = os.getenv("SKD_OIDC_SCOPES", "openid profile email") + self.session_cookie_name: str = os.getenv("SKD_SESSION_COOKIE_NAME", "skd_session") + self.session_cookie_secure: bool = ( + os.getenv("SKD_SESSION_COOKIE_SECURE", "false").lower() == "true" + ) + self.oidc_state_cookie_name: str = os.getenv( + "SKD_OIDC_STATE_COOKIE_NAME", "skd_oidc_state" + ) self.default_countdown: int = int(os.getenv("SKD_DEFAULT_COUNTDOWN", "60")) self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true" self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5")) diff --git a/backend/templates/index.html b/backend/templates/index.html index 0ff9766..c7011dc 100644 --- a/backend/templates/index.html +++ b/backend/templates/index.html @@ -19,6 +19,10 @@

Login (nur Root-User)

+

Bevorzugt OIDC nutzen, falls konfiguriert. Die Anmeldung öffnet den Identity Provider und setzt eine Session-Cookie.

+ +
+

Lokale Anmeldung (PAM) nur falls OIDC nicht verfügbar:

@@ -91,7 +95,7 @@ currentToken = token; if (token) { sessionStorage.setItem(tokenKey, token); - loginStatus.textContent = 'Angemeldet'; + loginStatus.textContent = 'Angemeldet (Token gespeichert)'; } else { sessionStorage.removeItem(tokenKey); loginStatus.textContent = 'Nicht angemeldet'; @@ -107,11 +111,31 @@ async function api(path, options = {}) { const headers = { ...authHeaders(), ...(options.headers || {}) }; - const res = await fetch(path, { ...options, headers }); - if (!res.ok) throw new Error(`${res.status} ${res.statusText}`); + const res = await fetch(path, { ...options, headers, credentials: 'same-origin' }); + if (!res.ok) { + const text = await res.text(); + const error = new Error(text || `${res.status} ${res.statusText}`); + error.status = res.status; + throw error; + } return res.json(); } + async function checkSession() { + try { + const data = await api('/me'); + loginStatus.textContent = `Angemeldet als ${data.user} (${data.auth_mode})`; + return true; + } catch (err) { + if (err.status === 401) { + loginStatus.textContent = 'Nicht angemeldet'; + } else { + loginStatus.textContent = `Session-Check fehlgeschlagen: ${err.message}`; + } + return false; + } + } + async function refreshUsers() { statusDiv.textContent = 'Lade...'; try { @@ -149,6 +173,10 @@ } }); + document.getElementById('oidcLogin').addEventListener('click', () => { + window.location.href = '/login/oidc/start'; + }); + document.getElementById('refreshBtn').addEventListener('click', refreshUsers); document.getElementById('actionForm').addEventListener('submit', async (e) => { @@ -176,6 +204,8 @@ resultDiv.textContent = `Fehler: ${err.message}`; } }); + + checkSession(); diff --git a/env.example b/env.example index 7d55c43..bacefa3 100644 --- a/env.example +++ b/env.example @@ -3,9 +3,19 @@ SKD_ALLOWED_USERS=child1,child2 SKD_AUTH_SECRET=change-me-secret SKD_TOKEN_TTL_SECONDS=900 +# Auth mode: pam (default) or oidc +SKD_AUTH_MODE=pam SKD_AUTH_ALLOWED_USERS= SKD_AUTH_ALLOWED_GROUPS=sudo SKD_AUTH_PAM_SERVICE=login +SKD_OIDC_ISSUER= +SKD_OIDC_CLIENT_ID= +SKD_OIDC_CLIENT_SECRET= +SKD_OIDC_REDIRECT_URI=http://localhost:8000/login/oidc/callback +SKD_OIDC_SCOPES=openid profile email +SKD_SESSION_COOKIE_NAME=skd_session +SKD_SESSION_COOKIE_SECURE=false +SKD_OIDC_STATE_COOKIE_NAME=skd_oidc_state SKD_DEFAULT_COUNTDOWN=60 SKD_DEFAULT_SOUND=false SKD_NOTIFY_TIMEOUT=5 diff --git a/scripts/register_oidc_client.sh b/scripts/register_oidc_client.sh new file mode 100755 index 0000000..5ff6b3e --- /dev/null +++ b/scripts/register_oidc_client.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "Required command not found: $1" >&2 + exit 1 + fi +} + +require_cmd curl +require_cmd python3 + +ISSUER="${SKD_OIDC_ISSUER:-}" +REG_ENDPOINT_DEFAULT="" +if [[ -n "${ISSUER}" ]]; then + REG_ENDPOINT_DEFAULT="${ISSUER%/}/connect/register" +fi + +REG_ENDPOINT="${OIDC_REGISTRATION_ENDPOINT:-$REG_ENDPOINT_DEFAULT}" +INITIAL_TOKEN="${OIDC_INITIAL_ACCESS_TOKEN:-}" +CLIENT_NAME="${OIDC_CLIENT_NAME:-Safe Kiddo Daemon}" +REDIRECT_URI="${SKD_OIDC_REDIRECT_URI:-http://localhost:8000/login/oidc/callback}" + +if [[ -z "${REG_ENDPOINT}" || -z "${INITIAL_TOKEN}" ]]; then + cat >&2 <<'EOF' +Missing configuration. Set: + SKD_OIDC_ISSUER (or OIDC_REGISTRATION_ENDPOINT) + OIDC_INITIAL_ACCESS_TOKEN +Optional: + OIDC_CLIENT_NAME (default: Safe Kiddo Daemon) + SKD_OIDC_REDIRECT_URI (default: http://localhost:8000/login/oidc/callback) +EOF + exit 1 +fi + +log "Registering client at ${REG_ENDPOINT} with redirect ${REDIRECT_URI}..." + +TMP_RESP="$(mktemp)" +trap 'rm -f "${TMP_RESP}"' EXIT + +HTTP_CODE=$(curl -sS -o "${TMP_RESP}" -w '%{http_code}' \ + -X POST "${REG_ENDPOINT}" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${INITIAL_TOKEN}" \ + -d "{\"client_name\":\"${CLIENT_NAME}\",\"redirect_uris\":[\"${REDIRECT_URI}\"]}") + +if [[ "${HTTP_CODE}" != "200" && "${HTTP_CODE}" != "201" ]]; then + echo "Client registration failed (HTTP ${HTTP_CODE}):" >&2 + cat "${TMP_RESP}" >&2 + exit 1 +fi + +python3 - "$TMP_RESP" <<'PYCODE' +import json, sys +path = sys.argv[1] +data = json.load(open(path, "r")) +client_id = data.get("client_id") +client_secret = data.get("client_secret") +print("Client registered.") +if client_id: + print(f"Client ID: {client_id}") +if client_secret: + print(f"Client Secret: {client_secret}") +if not (client_id and client_secret): + print("Warning: Response missing client_id or client_secret", file=sys.stderr) +PYCODE