feature: add oidc login flow

This commit is contained in:
2025-12-28 09:16:16 +01:00
parent 6f49528bbd
commit 3991362e67
9 changed files with 398 additions and 14 deletions

View File

@ -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 <token>" 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).

View File

@ -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())

View File

@ -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 "):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
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")
return decode_token(token, settings)

140
backend/oidc.py Normal file
View File

@ -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")

View File

@ -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

View File

@ -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"))

View File

@ -19,6 +19,10 @@
<section>
<h3>Login (nur Root-User)</h3>
<p>Bevorzugt OIDC nutzen, falls konfiguriert. Die Anmeldung öffnet den Identity Provider und setzt eine Session-Cookie.</p>
<button id="oidcLogin" type="button">Login via OIDC</button>
<hr />
<p>Lokale Anmeldung (PAM) nur falls OIDC nicht verfügbar:</p>
<form id="loginForm">
<div class="grid">
<div>
@ -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();
</script>
</body>
</html>

View File

@ -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

74
scripts/register_oidc_client.sh Executable file
View File

@ -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