- 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>
312 lines
13 KiB
Python
312 lines
13 KiB
Python
#!/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()
|