Compare commits

...

2 Commits

Author SHA1 Message Date
e380288755 code: keep pam enabled and gate oidc 2025-12-28 13:49:30 +01:00
97ea7070cc docs: enforce pam always available 2025-12-28 13:49:22 +01:00
11 changed files with 50 additions and 21 deletions

View File

@ -14,6 +14,7 @@ By: Codex (GPT-5)
| 28.12.2025 | 📝 Req | ID: US_000025 Runbook/Validierungsschritte dokumentiert. By: Codex (GPT-5) | | 28.12.2025 | 📝 Req | ID: US_000025 Runbook/Validierungsschritte dokumentiert. By: Codex (GPT-5) |
| 28.12.2025 | 📝 Req | ID: US_000025 Validation blocked (Service/IdP nicht bereit). By: Codex (GPT-5) | | 28.12.2025 | 📝 Req | ID: US_000025 Validation blocked (Service/IdP nicht bereit). By: Codex (GPT-5) |
| 28.12.2025 | 🏗️ Planning | ID: PR-Vorbereitung fuer feature/oidc-validation geplant. By: Codex (GPT-5) | | 28.12.2025 | 🏗️ Planning | ID: PR-Vorbereitung fuer feature/oidc-validation geplant. By: Codex (GPT-5) |
| 28.12.2025 | 🏗️ Planning | ID: PAM immer aktiv; OIDC optional mit deaktivierter UI-Option dokumentiert. By: Codex (GPT-5) |
--- ---
## Legende ## Legende

View File

@ -20,7 +20,7 @@ Then open `http://localhost:8000/` and log in via PAM (default) to start quickly
## Configuration ## Configuration
Set in `/etc/skd/env` (see `env.example`): Set in `/etc/skd/env` (see `env.example`):
- `SKD_AUTH_MODE`: `pam` (default) or `oidc`. - PAM-Login ist immer aktiv. OIDC wird zusaetzlich angeboten, wenn konfiguriert.
- `SKD_AUTH_SECRET`: HMAC secret for bearer tokens/cookies (set a strong value). - `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 (used for PAM and as an allowlist for OIDC claims). - `SKD_AUTH_ALLOWED_USERS`: optional comma list of accounts allowed to log in (used for PAM and as an allowlist for OIDC claims).

View File

@ -29,10 +29,10 @@ templates = Jinja2Templates(directory="backend/templates")
def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient: def get_oidc_client(settings: Settings = Depends(get_settings)) -> OIDCClient:
if settings.auth_mode != "oidc": if not settings.oidc_enabled:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="OIDC auth not enabled", detail="OIDC not configured",
) )
try: try:
return OIDCClient(settings) return OIDCClient(settings)
@ -59,7 +59,8 @@ def whoami(
current_user: str = Depends(get_current_admin), current_user: str = Depends(get_current_admin),
settings: Settings = Depends(get_settings), settings: Settings = Depends(get_settings),
) -> dict: ) -> dict:
return {"user": current_user, "auth_mode": settings.auth_mode} auth_mode = "pam+oidc" if settings.oidc_enabled else "pam"
return {"user": current_user, "auth_mode": auth_mode}
@app.post("/login", response_model=LoginResponse) @app.post("/login", response_model=LoginResponse)
@ -115,7 +116,7 @@ def oidc_callback(
username = oidc.extract_username(claims) username = oidc.extract_username(claims)
if not username: if not username:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing username claim") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Missing username claim")
if not is_authorized_admin(username, settings): if not is_authorized_admin(username, settings, mode="oidc"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not authorized to log in") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User not authorized to log in")
token = issue_token(username, settings) token = issue_token(username, settings)
@ -132,6 +133,11 @@ def oidc_callback(
return redirect return redirect
@app.get("/login/oidc/status")
def oidc_status(settings: Settings = Depends(get_settings)) -> dict:
return {"enabled": settings.oidc_enabled}
@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())

View File

@ -1,7 +1,7 @@
import datetime as dt import datetime as dt
import grp import grp
import pwd import pwd
from typing import List, Set from typing import List, Set, Optional
import jwt import jwt
import pam import pam
@ -23,8 +23,8 @@ def _is_member_of(username: str, groups: Set[str]) -> bool:
return bool(user_groups & groups) return bool(user_groups & groups)
def is_authorized_admin(username: str, settings: Settings) -> bool: def is_authorized_admin(username: str, settings: Settings, mode: Optional[str] = None) -> bool:
if settings.auth_mode == "oidc": if mode == "oidc":
allowed_users = set(settings.auth_allowed_users) allowed_users = set(settings.auth_allowed_users)
if allowed_users and username not in allowed_users: if allowed_users and username not in allowed_users:
return False return False
@ -46,12 +46,7 @@ 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": if not is_authorized_admin(username, settings, 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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="User not authorized to log in", detail="User not authorized to log in",
@ -82,7 +77,12 @@ def decode_token(token: str, settings: Settings) -> str:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") from exc
username = payload.get("sub") username = payload.get("sub")
if not username or not is_authorized_admin(username, settings): if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
if not (
is_authorized_admin(username, settings, mode="pam")
or is_authorized_admin(username, settings, mode="oidc")
):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Unauthorized user") raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Unauthorized user")
return username return username

View File

@ -32,6 +32,9 @@ class Settings:
self.oidc_state_cookie_name: str = os.getenv( self.oidc_state_cookie_name: str = os.getenv(
"SKD_OIDC_STATE_COOKIE_NAME", "skd_oidc_state" "SKD_OIDC_STATE_COOKIE_NAME", "skd_oidc_state"
) )
self.oidc_enabled: bool = bool(
self.oidc_issuer and self.oidc_client_id and self.oidc_client_secret
)
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"))

View File

@ -18,11 +18,11 @@
</header> </header>
<section> <section>
<h3>Login (nur Root-User)</h3> <h3>Login</h3>
<p>Bevorzugt OIDC nutzen, falls konfiguriert. Die Anmeldung öffnet den Identity Provider und setzt eine Session-Cookie.</p> <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> <button id="oidcLogin" type="button">Login via OIDC</button>
<hr /> <hr />
<p>Lokale Anmeldung (PAM) nur falls OIDC nicht verfügbar:</p> <p>Lokale Anmeldung (PAM) ist immer moeglich:</p>
<form id="loginForm"> <form id="loginForm">
<div class="grid"> <div class="grid">
<div> <div>
@ -136,6 +136,20 @@
} }
} }
async function checkOidcStatus() {
const button = document.getElementById('oidcLogin');
try {
const data = await api('/login/oidc/status');
if (!data.enabled) {
button.disabled = true;
button.title = 'OIDC nicht konfiguriert';
}
} catch (err) {
button.disabled = true;
button.title = 'OIDC-Status nicht erreichbar';
}
}
async function refreshUsers() { async function refreshUsers() {
statusDiv.textContent = 'Lade...'; statusDiv.textContent = 'Lade...';
try { try {
@ -206,6 +220,7 @@
}); });
checkSession(); checkSession();
checkOidcStatus();
</script> </script>
</body> </body>
</html> </html>

View File

@ -3,7 +3,8 @@
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 # PAM ist immer aktiv; OIDC wird zusaetzlich angeboten, wenn konfiguriert.
# SKD_AUTH_MODE bleibt optional und wird derzeit nicht erzwungen.
SKD_AUTH_MODE=pam SKD_AUTH_MODE=pam
SKD_AUTH_ALLOWED_USERS= SKD_AUTH_ALLOWED_USERS=
SKD_AUTH_ALLOWED_GROUPS=sudo SKD_AUTH_ALLOWED_GROUPS=sudo

View File

@ -13,6 +13,7 @@ Als Admin moechte ich mich per PAM-Login anmelden, damit ich ein Session-Token e
- Then die Antwort enthaelt `token` und `expires_in` - Then die Antwort enthaelt `token` und `expires_in`
- And ein Session-Cookie mit dem Token wird gesetzt - And ein Session-Cookie mit dem Token wird gesetzt
- And der Login ist ohne OIDC-Konfiguration als Schnellstart moeglich - And der Login ist ohne OIDC-Konfiguration als Schnellstart moeglich
- And PAM-Login bleibt auch bei aktivem OIDC verfuegbar
## Task-Platzhalter ## Task-Platzhalter
- TASK_000007: PAM login token (Details bei Story-Start) - TASK_000007: PAM login token (Details bei Story-Start)

View File

@ -8,12 +8,13 @@ Status: Done
Als Admin moechte ich mich per OIDC anmelden, damit ich ohne Passwort-Login zugreifen kann. Als Admin moechte ich mich per OIDC anmelden, damit ich ohne Passwort-Login zugreifen kann.
## Akzeptanzkriterien ## Akzeptanzkriterien
- Given `SKD_AUTH_MODE=oidc` und ein erreichbarer OIDC-Provider - Given OIDC ist konfiguriert und ein erreichbarer OIDC-Provider
- When ein GET auf `/login/oidc/start` erfolgt - When ein GET auf `/login/oidc/start` erfolgt
- Then der Nutzer wird zum Provider umgeleitet und ein State-Cookie gesetzt - Then der Nutzer wird zum Provider umgeleitet und ein State-Cookie gesetzt
- When der Provider auf `/login/oidc/callback` mit Code und State zurueckleitet - When der Provider auf `/login/oidc/callback` mit Code und State zurueckleitet
- Then der State wird validiert und ein Session-Cookie gesetzt - Then der State wird validiert und ein Session-Cookie gesetzt
- And bei ungueltigem State erfolgt eine 400-Antwort - And bei ungueltigem State erfolgt eine 400-Antwort
- And die OIDC-Option wird deaktiviert, wenn keine OIDC-Konfiguration vorliegt
## Task-Platzhalter ## Task-Platzhalter
- TASK_000008: OIDC auth callback (Details bei Story-Start) - TASK_000008: OIDC auth callback (Details bei Story-Start)

View File

@ -10,7 +10,7 @@ Als Operator moechte ich Konfigurationen per ENV setzen, damit Verhalten und Def
## Akzeptanzkriterien ## Akzeptanzkriterien
- Given Umgebungsvariablen aus `env.example` - Given Umgebungsvariablen aus `env.example`
- When der Service startet - When der Service startet
- Then Auth- und Session-Settings werden aus ENV geladen (`SKD_AUTH_MODE`, `SKD_AUTH_SECRET`, `SKD_TOKEN_TTL_SECONDS`, `SKD_AUTH_ALLOWED_USERS`, `SKD_AUTH_ALLOWED_GROUPS`, `SKD_AUTH_PAM_SERVICE`, `SKD_SESSION_COOKIE_NAME`, `SKD_SESSION_COOKIE_SECURE`, `SKD_OIDC_STATE_COOKIE_NAME`) - Then Auth- und Session-Settings werden aus ENV geladen (`SKD_AUTH_SECRET`, `SKD_TOKEN_TTL_SECONDS`, `SKD_AUTH_ALLOWED_USERS`, `SKD_AUTH_ALLOWED_GROUPS`, `SKD_AUTH_PAM_SERVICE`, `SKD_SESSION_COOKIE_NAME`, `SKD_SESSION_COOKIE_SECURE`, `SKD_OIDC_STATE_COOKIE_NAME`)
- And OIDC-Settings werden aus ENV geladen (`SKD_OIDC_ISSUER`, `SKD_OIDC_CLIENT_ID`, `SKD_OIDC_CLIENT_SECRET`, `SKD_OIDC_REDIRECT_URI`, `SKD_OIDC_SCOPES`) - And OIDC-Settings werden aus ENV geladen (`SKD_OIDC_ISSUER`, `SKD_OIDC_CLIENT_ID`, `SKD_OIDC_CLIENT_SECRET`, `SKD_OIDC_REDIRECT_URI`, `SKD_OIDC_SCOPES`)
- And Allowlist/Defaults werden aus ENV geladen (`SKD_ALLOWED_USERS`, `SKD_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`, `SKD_DRY_RUN`) - And Allowlist/Defaults werden aus ENV geladen (`SKD_ALLOWED_USERS`, `SKD_DEFAULT_COUNTDOWN`, `SKD_DEFAULT_SOUND`, `SKD_NOTIFY_TIMEOUT`, `SKD_DRY_RUN`)
- And Sound/Notify-Pfade sind ueber ENV ueberschreibbar (`SKD_SOUND_PLAYER`, `SKD_SOUND_FILE`, `SKD_NOTIFY_SEND_PATH`) - And Sound/Notify-Pfade sind ueber ENV ueberschreibbar (`SKD_SOUND_PLAYER`, `SKD_SOUND_FILE`, `SKD_NOTIFY_SEND_PATH`)

View File

@ -14,6 +14,7 @@ Als Admin moechte ich mich im Web-UI anmelden, Nutzer laden und Aktionen ausfueh
- When ich Nutzer lade und eine Aktion sende - When ich Nutzer lade und eine Aktion sende
- Then die Aktionsergebnisse (Steps/Status) werden als Text angezeigt - Then die Aktionsergebnisse (Steps/Status) werden als Text angezeigt
- And Fehlerantworten werden als Text angezeigt - And Fehlerantworten werden als Text angezeigt
- And die OIDC-Option ist deaktiviert, wenn keine Konfiguration vorliegt
## Task-Platzhalter ## Task-Platzhalter
- TASK_000022: UI login and actions (Details bei Story-Start) - TASK_000022: UI login and actions (Details bei Story-Start)