Files
portal/scripts/authentik-setup.py
T
Kerim AtilganandClaude Opus 5.5 edc64f159a Abmelden beendet auch die Authentik-Sitzung und springt zurück ins Tool
- Kopfleiste meldet immer über das Portal ab, auch ohne Portal-Sitzung,
  und gibt die aktuelle Seite als returnTo mit.
- Portal merkt sich den Rücksprung über den Umweg zu Authentik.
- Setup-Skript setzt den Abmelde-Ablauf des Providers 'Portal' auf
  default-invalidation-flow (der Provider-Ablauf meldet nur bei der App ab).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-26 09:55:42 +01:00

312 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
Richtet Authentik für das Handy-Insel Portal ein (idempotent, mehrfach ausführbar).
AK_TOKEN=<admin-api-token> python3 scripts/authentik-setup.py [--apply] [--env /etc/hi-portal/portal.env]
Ohne --apply wird nur angezeigt, was passieren würde (Probelauf).
Schritte:
1. Apps an Gruppen binden (wer sieht/öffnet welches Tool)
2. OIDC-Provider + App "Portal" anlegen (für alle HI-Mitarbeiter)
3. Service-Account "hi-portal" mit Leserecht auf "Apps pro Benutzer" + API-Token
4. Branding (Titel "Handy-Insel", Logo/Favicon vom Portal)
5. Client-ID/Secret/Token in die portal.env schreiben (falls --env angegeben)
"""
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
AK_URL = os.environ.get("AK_URL", "https://login.handy-insel.de").rstrip("/")
PORTAL_URL = os.environ.get("PORTAL_URL", "https://portal.handy-insel.de").rstrip("/")
# Wer darf welche App öffnen (Slug -> Gruppen). Mitglieder einer der Gruppen haben Zugriff.
APP_ACCESS = {
"preisliste": ["HI-Mitarbeiter"],
"vvl": ["HI-Mitarbeiter"],
"zeiterfassung": ["HI-Mitarbeiter"],
"timer": ["HI-Mitarbeiter"],
"kundenkarte": ["HI-Mitarbeiter"],
"datenuebertragungen": ["HI-Mitarbeiter"],
"service": ["HI-Mitarbeiter"],
"portal": ["HI-Mitarbeiter"],
"paperless": ["HI-Leitung", "HI-Admin"],
"patch-mon": ["HI-Admin"],
"preisliste-dev": ["HI-Admin"],
"zeit-dev": ["HI-Admin"],
}
# Launch-URL = SSO-Startpunkt der App. Mit bestehender Authentik-Sitzung ist man nach dem
# Klick auf die Kachel ohne Zwischenschritt angemeldet (statt auf der Anmeldeseite der App).
LAUNCH_URLS = {
"vvl": "https://vvl.handy-insel.de/auth/login",
"timer": "https://timer.handy-insel.de/auth/login",
"zeiterfassung": "https://zeit.handy-insel.de/api/auth/sso/login",
"preisliste": "https://preis.handy-insel.de/api/auth/oidc/start",
"datenuebertragungen": "https://data.handy-insel.de/api/auth/oidc/start",
}
LOGOUT_FLOW = "default-invalidation-flow"
SERVICE_ACCOUNT = "hi-portal"
SERVICE_GROUP = "hi-portal-service"
SERVICE_ROLE = "Portal: Apps pro Benutzer lesen"
SERVICE_PERMISSIONS = ["authentik_core.view_user", "authentik_core.view_user_applications"]
APPLY = False
def call(method, path, body=None):
if method != "GET" and not APPLY:
print(f" [Probelauf] {method} {path}")
return {}
req = urllib.request.Request(
f"{AK_URL}/api/v3{path}",
method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={
"Authorization": f"Bearer {os.environ['AK_TOKEN']}",
"Content-Type": "application/json",
"Accept": "application/json",
},
)
try:
with urllib.request.urlopen(req, timeout=20) as r:
raw = r.read()
return json.loads(raw) if raw else {}
except urllib.error.HTTPError as e:
sys.exit(f"FEHLER {method} {path}: HTTP {e.code} {e.read().decode()[:400]}")
def results(path):
sep = "&" if "?" in path else "?"
return call("GET", f"{path}{sep}page_size=200")["results"]
def step(title):
print(f"\n== {title}")
def bind_apps(groups, apps):
step("2. Apps an Gruppen binden")
for slug, group_names in APP_ACCESS.items():
app = apps.get(slug)
if not app:
print(f" - {slug}: App existiert (noch) nicht, übersprungen")
continue
existing = results(f"/policies/bindings/?target={app['pk']}")
bound = {b.get("group") for b in existing}
orders = {b["order"] for b in existing}
for name in group_names:
if groups[name] in bound:
print(f" = {slug} -> {name}")
continue
order = 10
while order in orders:
order += 1
orders.add(order)
call("POST", "/policies/bindings/", {
"target": app["pk"], "group": groups[name], "order": order,
"enabled": True, "negate": False, "timeout": 30, "failure_result": False,
})
print(f" + {slug} -> {name}")
if app.get("policy_engine_mode") != "any":
call("PATCH", f"/core/applications/{slug}/", {"policy_engine_mode": "any"})
def set_launch_urls(apps):
step("3. Launch-URLs (Kachel meldet direkt per SSO an)")
for slug, url in LAUNCH_URLS.items():
app = apps.get(slug)
if not app:
print(f" - {slug}: App existiert (noch) nicht, übersprungen")
elif app.get("meta_launch_url") == url:
print(f" = {slug}: {url}")
else:
call("PATCH", f"/core/applications/{slug}/", {"meta_launch_url": url})
print(f" + {slug}: {app.get('meta_launch_url') or '-'} -> {url}")
def ensure_portal_app(apps):
step("1. OIDC-Provider und App 'Portal'")
template = next(p for p in results("/providers/oauth2/") if p["name"] == "Preisliste")
provider = next((p for p in results("/providers/oauth2/") if p["name"] == "Portal"), None)
# "Abmelden" in Portal und Kopfleiste soll auch die Authentik-Sitzung beenden. Der übliche
# default-provider-invalidation-flow meldet nur bei der App ab, dieser Ablauf auch bei Authentik.
logout_flow = next(f for f in results("/flows/instances/?designation=invalidation") if f["slug"] == LOGOUT_FLOW)
if provider:
print(" = Provider 'Portal' vorhanden")
if provider.get("invalidation_flow") != logout_flow["pk"]:
call("PATCH", f"/providers/oauth2/{provider['pk']}/", {"invalidation_flow": logout_flow["pk"]})
print(f" + Abmelde-Ablauf: {LOGOUT_FLOW} (beendet auch die Authentik-Sitzung)")
else:
print(f" = Abmelde-Ablauf: {LOGOUT_FLOW}")
else:
provider = call("POST", "/providers/oauth2/", {
"name": "Portal",
"authorization_flow": template["authorization_flow"],
"invalidation_flow": logout_flow["pk"],
"client_type": "confidential",
# Neuere Authentik-Versionen setzen per API keine Standard-Grant-Types;
# ohne diese Liste lehnt Authentik jede Anmeldung als "malformed" ab.
"grant_types": ["authorization_code", "refresh_token"],
"redirect_uris": [
{"matching_mode": "strict", "url": f"{PORTAL_URL}/auth/callback", "redirect_uri_type": "authorization"},
# Rücksprung nach dem Abmelden (post_logout_redirect_uri)
{"matching_mode": "strict", "url": f"{PORTAL_URL}/", "redirect_uri_type": "logout"},
],
"property_mappings": template["property_mappings"],
"signing_key": template.get("signing_key"),
"sub_mode": template.get("sub_mode", "hashed_user_id"),
"include_claims_in_id_token": True,
})
print(" + Provider 'Portal' angelegt")
if "portal" in apps:
print(" = App 'portal' vorhanden")
elif APPLY:
apps["portal"] = call("POST", "/core/applications/", {
"name": "Portal", "slug": "portal", "provider": provider["pk"],
"meta_launch_url": f"{PORTAL_URL}/", "policy_engine_mode": "any",
})
print(" + App 'portal' angelegt")
return provider
def ensure_service_account(groups):
step("4. Service-Account für die App-Liste")
user = next((u for u in results(f"/core/users/?username={SERVICE_ACCOUNT}") if u["username"] == SERVICE_ACCOUNT), None)
token_key = None
if user:
print(f" = Service-Account '{SERVICE_ACCOUNT}' vorhanden")
else:
res = call("POST", "/core/users/service_account/", {"name": SERVICE_ACCOUNT, "create_group": False, "expiring": False})
user = {"pk": res.get("user_pk")}
print(f" + Service-Account '{SERVICE_ACCOUNT}' angelegt")
role = next((r for r in results("/rbac/roles/") if r["name"] == SERVICE_ROLE), None)
if not role:
role = call("POST", "/rbac/roles/", {"name": SERVICE_ROLE})
print(f" + Rolle '{SERVICE_ROLE}' angelegt")
if role.get("pk"):
call("POST", f"/rbac/permissions/assigned_by_roles/{role['pk']}/assign/", {"permissions": SERVICE_PERMISSIONS})
print(" + Rechte: " + ", ".join(SERVICE_PERMISSIONS))
if SERVICE_GROUP not in groups:
call("POST", "/core/groups/", {
"name": SERVICE_GROUP, "is_superuser": False,
"users": [user["pk"]] if user.get("pk") else [],
"roles": [role["pk"]] if role.get("pk") else [],
})
print(f" + Gruppe '{SERVICE_GROUP}' (Rolle + Service-Account)")
else:
print(f" = Gruppe '{SERVICE_GROUP}' vorhanden")
# Der Service-Account bekommt von Authentik nur ein App-Passwort (intent=app_password),
# das die API nicht als Bearer-Token akzeptiert. Deshalb eigener API-Token.
if APPLY and user.get("pk"):
ident = f"{SERVICE_ACCOUNT}-api"
if not results(f"/core/tokens/?identifier={ident}"):
call("POST", "/core/tokens/", {
"identifier": ident, "user": user["pk"], "intent": "api", "expiring": False,
"description": "Portal: App-Liste pro Benutzer lesen",
})
print(f" + API-Token '{ident}' angelegt")
token_key = call("GET", f"/core/tokens/{ident}/view_key/")["key"]
return user, token_key
def branding():
step("5. Branding")
try:
urllib.request.urlopen(f"{PORTAL_URL}/icon-192.png", timeout=10)
except Exception:
print(f" - übersprungen: {PORTAL_URL} ist noch nicht erreichbar (sonst kaputtes Logo auf der Login-Seite)")
return
brand = next((b for b in results("/core/brands/") if b.get("default")), None) or results("/core/brands/")[0]
call("PATCH", f"/core/brands/{brand['brand_uuid']}/", {
"branding_title": "Handy-Insel",
"branding_logo": f"{PORTAL_URL}/icon-192.png",
"branding_favicon": f"{PORTAL_URL}/favicon.png",
})
print(" + Titel 'Handy-Insel', Logo und Favicon vom Portal")
def write_env(path, values):
step(f"6. {path} aktualisieren")
text = open(path).read() if os.path.exists(path) else ""
for key, value in values.items():
if not value:
continue
line = f"{key}={value}"
text = re.sub(rf"^{key}=.*$", line, text, flags=re.M) if re.search(rf"^{key}=", text, flags=re.M) else text + f"\n{line}\n"
if APPLY:
with open(path, "w") as f:
f.write(text)
print(" + " + ", ".join(k for k, v in values.items() if v))
def verify(token, user_pk):
step("Prüfung: Kann der Service-Account die Apps eines Benutzers lesen?")
kerim = next((u for u in results("/core/users/?username=kerim")), None)
if not (token and kerim):
print(" (übersprungen)")
return
req = urllib.request.Request(
f"{AK_URL}/api/v3/core/applications/?for_user={kerim['pk']}&page_size=200",
headers={"Authorization": f"Bearer {token}", "Accept": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=20) as r:
slugs = [a["slug"] for a in json.load(r)["results"]]
print(f" OK – Apps für kerim: {', '.join(slugs)}")
except urllib.error.HTTPError as e:
print(f" FEHLER HTTP {e.code}: {e.read().decode()[:300]}")
def main():
global APPLY
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--apply", action="store_true", help="Änderungen wirklich durchführen")
parser.add_argument("--env", help="Pfad zur portal.env, in die Client-ID/Secret/Token geschrieben werden")
parser.add_argument("--skip-branding", action="store_true")
args = parser.parse_args()
APPLY = args.apply
if "AK_TOKEN" not in os.environ:
sys.exit("Bitte AK_TOKEN (API-Token eines Authentik-Admins) setzen.")
print(f"Authentik: {AK_URL} | Portal: {PORTAL_URL} | {'AUSFÜHREN' if APPLY else 'PROBELAUF'}")
groups = {g["name"]: g["pk"] for g in results("/core/groups/")}
for needed in ("HI-Mitarbeiter", "HI-Leitung", "HI-Admin"):
if needed not in groups:
sys.exit(f"Gruppe {needed} fehlt in Authentik.")
apps = {a["slug"]: a for a in results("/core/applications/?superuser_full_list=true")}
provider = ensure_portal_app(apps)
bind_apps(groups, apps)
set_launch_urls(apps)
user, token = ensure_service_account(groups)
if not args.skip_branding:
branding()
if args.env:
write_env(args.env, {
"OIDC_CLIENT_ID": provider.get("client_id"),
"OIDC_CLIENT_SECRET": provider.get("client_secret"),
"AUTHENTIK_TOKEN": token,
})
elif APPLY:
print("\nFür die portal.env:")
print(f" OIDC_CLIENT_ID={provider.get('client_id')}")
print(f" OIDC_CLIENT_SECRET={provider.get('client_secret')}")
print(f" AUTHENTIK_TOKEN={token}")
if APPLY:
verify(token, user.get("pk"))
print("\nFertig." if APPLY else "\nProbelauf beendet – mit --apply ausführen.")
if __name__ == "__main__":
main()