feature: add oidc login flow
This commit is contained in:
15
README.md
15
README.md
@ -20,10 +20,13 @@ Then open `http://localhost:8000/` and set the API token in the UI.
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
Set in `/etc/skd/env` (see `env.example`):
|
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_TOKEN_TTL_SECONDS`: token lifetime (default 900s).
|
||||||
- `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in.
|
- `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 (default `sudo`).
|
- `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_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_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`: behavior defaults.
|
||||||
- `SKD_DRY_RUN=true` to test without real account changes or shutdown.
|
- `SKD_DRY_RUN=true` to test without real account changes or shutdown.
|
||||||
@ -34,7 +37,8 @@ Notes:
|
|||||||
## Running
|
## 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.
|
- 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`).
|
- 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`
|
- Health: `curl -H "Authorization: Bearer <token>" http://localhost:8000/health`
|
||||||
|
|
||||||
## API (Bearer token via `/login`)
|
## API (Bearer token via `/login`)
|
||||||
@ -42,6 +46,7 @@ Notes:
|
|||||||
- `POST /users/{name}/disable` with JSON `{countdown?, sound?, message?}`
|
- `POST /users/{name}/disable` with JSON `{countdown?, sound?, message?}`
|
||||||
- `POST /users/{name}/enable`
|
- `POST /users/{name}/enable`
|
||||||
- `GET /health`
|
- `GET /health`
|
||||||
|
- `GET /me` (returns current user + auth mode when a session/bearer token is present)
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```bash
|
```bash
|
||||||
@ -53,7 +58,7 @@ curl -X POST -H "Authorization: Bearer $token" \
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Web UI
|
## 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
|
## Updates
|
||||||
- Remote update via SSH: `ssh user@kid-laptop 'cd /opt/sk && ./scripts/update.sh'` (fetch/reset to `origin/main`, reinstalls deps, restarts service).
|
- Remote update via SSH: `ssh user@kid-laptop 'cd /opt/sk && ./scripts/update.sh'` (fetch/reset to `origin/main`, reinstalls deps, restarts service).
|
||||||
|
|||||||
100
backend/app.py
100
backend/app.py
@ -1,14 +1,21 @@
|
|||||||
import logging
|
import logging
|
||||||
from typing import List
|
from typing import List
|
||||||
|
|
||||||
from fastapi import Body, Depends, FastAPI, HTTPException, Request, status
|
from fastapi import Body, Depends, FastAPI, HTTPException, Request, Response, status
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from backend import actions
|
from backend import actions
|
||||||
from backend.actions import ActionError
|
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.models import ActionRequest, ActionResponse, LoginRequest, LoginResponse, UserStatus
|
||||||
|
from backend.oidc import OIDCClient, OIDCError
|
||||||
from backend.settings import Settings, get_settings
|
from backend.settings import Settings, get_settings
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@ -21,6 +28,20 @@ app = FastAPI(title="Safe Kiddo Daemon", version="1.0.0")
|
|||||||
templates = Jinja2Templates(directory="backend/templates")
|
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:
|
def validate_user(username: str, settings: Settings = Depends(get_settings)) -> str:
|
||||||
allowed = set(list_manageable_users(settings))
|
allowed = set(list_manageable_users(settings))
|
||||||
if username not in allowed:
|
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}
|
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)
|
@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)
|
authenticate_admin_user(payload.username, payload.password, settings)
|
||||||
token = issue_token(payload.username, 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)
|
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)])
|
@app.get("/users", response_model=List[UserStatus], dependencies=[Depends(get_current_admin)])
|
||||||
def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]:
|
def users(settings: Settings = Depends(get_settings)) -> List[UserStatus]:
|
||||||
logged_in = set(actions.list_logged_in_users())
|
logged_in = set(actions.list_logged_in_users())
|
||||||
|
|||||||
@ -24,6 +24,11 @@ def _is_member_of(username: str, groups: Set[str]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def is_authorized_admin(username: str, settings: Settings) -> 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
|
# UID 0 always allowed
|
||||||
try:
|
try:
|
||||||
entry = pwd.getpwnam(username)
|
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:
|
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):
|
if not is_authorized_admin(username, settings):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_403_FORBIDDEN,
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
@ -82,9 +92,13 @@ def get_current_admin(
|
|||||||
settings: Settings = Depends(get_settings),
|
settings: Settings = Depends(get_settings),
|
||||||
) -> str:
|
) -> str:
|
||||||
auth_header = request.headers.get("authorization")
|
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")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
|
||||||
token = auth_header.split(" ", 1)[1].strip()
|
|
||||||
return decode_token(token, settings)
|
return decode_token(token, settings)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
140
backend/oidc.py
Normal file
140
backend/oidc.py
Normal 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")
|
||||||
@ -5,3 +5,5 @@ jinja2==3.1.4
|
|||||||
PyJWT==2.9.0
|
PyJWT==2.9.0
|
||||||
python-pam==2.0.2
|
python-pam==2.0.2
|
||||||
six==1.16.0
|
six==1.16.0
|
||||||
|
httpx==0.27.2
|
||||||
|
cryptography==43.0.1
|
||||||
|
|||||||
@ -7,6 +7,9 @@ class Settings:
|
|||||||
"""Application settings loaded from environment."""
|
"""Application settings loaded from environment."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
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.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.auth_secret: str = os.getenv("SKD_AUTH_SECRET", "change-me-secret")
|
||||||
self.token_ttl_seconds: int = int(os.getenv("SKD_TOKEN_TTL_SECONDS", "900"))
|
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")
|
os.getenv("SKD_AUTH_ALLOWED_GROUPS", "sudo")
|
||||||
)
|
)
|
||||||
self.auth_pam_service: str = os.getenv("SKD_AUTH_PAM_SERVICE", "login")
|
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_countdown: int = int(os.getenv("SKD_DEFAULT_COUNTDOWN", "60"))
|
||||||
self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true"
|
self.default_sound: bool = os.getenv("SKD_DEFAULT_SOUND", "false").lower() == "true"
|
||||||
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
|
self.notify_timeout: int = int(os.getenv("SKD_NOTIFY_TIMEOUT", "5"))
|
||||||
|
|||||||
@ -19,6 +19,10 @@
|
|||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h3>Login (nur Root-User)</h3>
|
<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">
|
<form id="loginForm">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<div>
|
<div>
|
||||||
@ -91,7 +95,7 @@
|
|||||||
currentToken = token;
|
currentToken = token;
|
||||||
if (token) {
|
if (token) {
|
||||||
sessionStorage.setItem(tokenKey, token);
|
sessionStorage.setItem(tokenKey, token);
|
||||||
loginStatus.textContent = 'Angemeldet';
|
loginStatus.textContent = 'Angemeldet (Token gespeichert)';
|
||||||
} else {
|
} else {
|
||||||
sessionStorage.removeItem(tokenKey);
|
sessionStorage.removeItem(tokenKey);
|
||||||
loginStatus.textContent = 'Nicht angemeldet';
|
loginStatus.textContent = 'Nicht angemeldet';
|
||||||
@ -107,11 +111,31 @@
|
|||||||
|
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const headers = { ...authHeaders(), ...(options.headers || {}) };
|
const headers = { ...authHeaders(), ...(options.headers || {}) };
|
||||||
const res = await fetch(path, { ...options, headers });
|
const res = await fetch(path, { ...options, headers, credentials: 'same-origin' });
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
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();
|
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() {
|
async function refreshUsers() {
|
||||||
statusDiv.textContent = 'Lade...';
|
statusDiv.textContent = 'Lade...';
|
||||||
try {
|
try {
|
||||||
@ -149,6 +173,10 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('oidcLogin').addEventListener('click', () => {
|
||||||
|
window.location.href = '/login/oidc/start';
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('refreshBtn').addEventListener('click', refreshUsers);
|
document.getElementById('refreshBtn').addEventListener('click', refreshUsers);
|
||||||
|
|
||||||
document.getElementById('actionForm').addEventListener('submit', async (e) => {
|
document.getElementById('actionForm').addEventListener('submit', async (e) => {
|
||||||
@ -176,6 +204,8 @@
|
|||||||
resultDiv.textContent = `Fehler: ${err.message}`;
|
resultDiv.textContent = `Fehler: ${err.message}`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
checkSession();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
10
env.example
10
env.example
@ -3,9 +3,19 @@
|
|||||||
SKD_ALLOWED_USERS=child1,child2
|
SKD_ALLOWED_USERS=child1,child2
|
||||||
SKD_AUTH_SECRET=change-me-secret
|
SKD_AUTH_SECRET=change-me-secret
|
||||||
SKD_TOKEN_TTL_SECONDS=900
|
SKD_TOKEN_TTL_SECONDS=900
|
||||||
|
# Auth mode: pam (default) or oidc
|
||||||
|
SKD_AUTH_MODE=pam
|
||||||
SKD_AUTH_ALLOWED_USERS=
|
SKD_AUTH_ALLOWED_USERS=
|
||||||
SKD_AUTH_ALLOWED_GROUPS=sudo
|
SKD_AUTH_ALLOWED_GROUPS=sudo
|
||||||
SKD_AUTH_PAM_SERVICE=login
|
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_COUNTDOWN=60
|
||||||
SKD_DEFAULT_SOUND=false
|
SKD_DEFAULT_SOUND=false
|
||||||
SKD_NOTIFY_TIMEOUT=5
|
SKD_NOTIFY_TIMEOUT=5
|
||||||
|
|||||||
74
scripts/register_oidc_client.sh
Executable file
74
scripts/register_oidc_client.sh
Executable 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
|
||||||
Reference in New Issue
Block a user