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

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

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>