#!/usr/bin/env python3 """ Richtet Authentik für das Handy-Insel Portal ein (idempotent, mehrfach ausführbar). AK_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()