diff --git a/README.md b/README.md index 9f924ad..bba6318 100644 --- a/README.md +++ b/README.md @@ -47,20 +47,61 @@ Admin-UI Änderungen macht, sollte sie **in die JSON zurückspielen**. **Wichtig:** Das compose-`--import-realm` greift nur beim **ersten** Start (solange das `keycloak-data`-Volume leer ist) — spätere JSON-Änderungen landen -NICHT automatisch in Keycloak. Zum Anwenden/Bootstrappen: +NICHT automatisch in Keycloak. Realm anwenden/bootstrappen geht daher über +`tool/keycloak_bootstrap.sh` (spricht nur die Admin-REST-API an, funktioniert +gegen **Dev-Docker wie baremetal-Prod**): ```bash -# Importiert den Realm über die Admin-REST-API (idempotent, startet Keycloak -# bei Bedarf via docker compose). Tut nichts, falls der Realm schon existiert. -./tool/keycloak_bootstrap.sh +# Dev (lokales Docker mit hochfahren, Default-Realm "holzleitner") +./tool/keycloak_bootstrap.sh --up + +# Production (baremetal Keycloak, eigener Realm, frisches Secret, kein Test-User) +./tool/keycloak_bootstrap.sh \ + --url https://auth.holzleitner.de \ + --realm holzleitner-prod \ + --admin kc-admin --admin-password "$KC_PW" \ + --provisioner-secret "$(openssl rand -hex 24)" \ + --no-test-user # Sauberer Neu-Import (LÖSCHT den Realm inkl. provisionierter Fahrer-Konten): -./tool/keycloak_bootstrap.sh --reset +./tool/keycloak_bootstrap.sh --url ... --realm ... --reset ``` -Das Skript gibt am Ende die `[keycloak]`-Werte aus, die in die `config.toml` -gehören (issuer_url, audience, provisioner-secret …). Overrides via -`KC_URL` / `KC_ADMIN` / `KC_ADMIN_PASSWORD` / `REALM_FILE`. +Wichtigste Optionen: `--url` (Keycloak-Basis inkl. Port), `--realm` (Realm-Name), +`--admin`/`--admin-password`, `--provisioner-secret`, `--no-test-user`, `--reset`, +`--up`. Alles auch via Env (`KC_URL`, `REALM`, `KC_ADMIN`, `KC_ADMIN_PASSWORD`, +`PROVISIONER_SECRET`, `REALM_FILE`). Default ohne `--up` wird **kein** Docker +angefasst. Das Skript gibt am Ende die passenden `[keycloak]`-Werte für die +`config.toml` aus. + +### Wie die Authentifizierung funktioniert + +Es gibt zwei getrennte Auth-Kontexte: + +**a) Bootstrap-Skript → Keycloak (einmalig/selten).** Das Skript meldet sich am +`master`-Realm über den eingebauten Client `admin-cli` per Passwort-Grant an +(`--admin`/`--admin-password`) und nutzt das Admin-Token nur, um den Realm +anzulegen. In Prod ein echtes Admin-Konto + HTTPS verwenden; das Passwort besser +über die Env-Variable `KC_ADMIN_PASSWORD` setzen (nicht in der History). + +**b) Laufzeit — App ↔ Backend ↔ Keycloak.** +1. Die Flutter-App loggt sich per **OIDC Authorization Code + PKCE** gegen + `{KC_URL}/realms/{realm}` ein und erhält ein **Access-Token (JWT, RS256)** mit + `aud=holzleitner-api`, Claim `personalnummer` und Realm-Rolle `driver`. +2. Das Backend **validiert das JWT public-key-basiert**: es holt die Realm-JWKS + von `{issuer}/protocol/openid-connect/certs`, prüft Signatur, `iss` (== + `config.toml [keycloak] issuer_url`), `aud` (enthält `holzleitner-api`) und + Ablauf. Kein gemeinsames Secret nötig — nur der öffentliche Schlüssel. +3. Die `/admin`-Maschinen-Endpunkte des Backends laufen NICHT über Keycloak, + sondern über den statischen `X-Admin-Api-Key` (siehe `[admin] api_key`). +4. Der **Provisioner** (`holzleitner-provisioner`, confidential, Client- + Credentials-Grant mit `manage-users`) wird vom ERP-Sync genutzt, um neue + Fahrer-Konten im Realm anzulegen — dafür gilt das `provisioner_client_secret`. + +Damit b) klappt, müssen `issuer_url`/`realm`/`admin_url` in der `config.toml` +**exakt** auf denselben Server + Realm zeigen, den das Skript angelegt hat — und +`issuer_url` muss dem von Keycloak ausgestellten `iss`-Claim entsprechen (in +Prod also Keycloaks Frontend-/Hostname-URL korrekt setzen, sonst `invalid issuer`). ### Token für Dev-Tests holen diff --git a/tool/keycloak_bootstrap.sh b/tool/keycloak_bootstrap.sh index f8c460d..4f976fe 100755 --- a/tool/keycloak_bootstrap.sh +++ b/tool/keycloak_bootstrap.sh @@ -1,169 +1,221 @@ #!/usr/bin/env bash # -# Keycloak-Bootstrap: importiert den Realm `holzleitner` über die Admin-REST-API -# und konfiguriert ihn so, dass er zum Backend passt (Client `holzleitner-app` -# mit PKCE + Audience-Mapper `holzleitner-api` + `personalnummer`-Claim, Rolle -# `driver`, Service-Account-Client `holzleitner-provisioner`, Test-User). +# Keycloak-Bootstrap: importiert den Holzleitner-Realm über die Admin-REST-API +# und konfiguriert ihn passend zum Backend (Client `holzleitner-app` mit PKCE + +# Audience-Mapper `holzleitner-api` + `personalnummer`-Claim, Rolle `driver`, +# Service-Account-Client `holzleitner-provisioner`, optional ein Test-User). # -# WARUM dieses Skript? Das docker-compose-`--import-realm` greift NUR beim -# allerersten Start (solange das `keycloak-data`-Volume leer ist). Spätere -# Änderungen an realm-holzleitner.json landen sonst NICHT in Keycloak. Dieses -# Skript wendet den Realm jederzeit idempotent über die REST-API an. +# Funktioniert gegen JEDE Keycloak-Instanz (Dev-Docker ODER baremetal-Prod) — +# es spricht nur die REST-API an. Quelle der Realm-Definition ist +# `keycloak/import/realm-holzleitner.json`; Realm-Name, Keycloak-Adresse, +# Provisioner-Secret und Test-User lassen sich beim Aufruf überschreiben. # -# Quelle der Wahrheit ist `keycloak/import/realm-holzleitner.json` — dieselbe -# Datei, die auch docker-compose importiert. +# ── Aufruf ──────────────────────────────────────────────────────────────── +# ./tool/keycloak_bootstrap.sh \ +# --url https://auth.holzleitner.de \ +# --realm holzleitner \ +# --admin admin --admin-password '****' # -# Aufruf (vom Projekt-Root): -# ./tool/keycloak_bootstrap.sh # anlegen, falls Realm fehlt -# ./tool/keycloak_bootstrap.sh --reset # vorhandenen Realm LÖSCHEN + neu -# ./tool/keycloak_bootstrap.sh --no-up # Keycloak nicht via compose starten +# Production-Beispiel (eigener Realm, neues Secret, ohne Test-User): +# ./tool/keycloak_bootstrap.sh \ +# --url https://auth.holzleitner.de \ +# --realm holzleitner-prod \ +# --admin kc-admin --admin-password "$KC_PW" \ +# --provisioner-secret "$(openssl rand -hex 24)" \ +# --no-test-user # -# Umgebungs-Overrides: -# KC_URL Keycloak-Basis-URL (Default http://localhost:8080) -# KC_ADMIN Bootstrap-Admin-User (Default admin) -# KC_ADMIN_PASSWORD Bootstrap-Admin-Passwort (Default admin) -# REALM_FILE Pfad zur Realm-JSON (Default keycloak/import/realm-holzleitner.json) +# Dev (lokales Docker mit hochfahren): +# ./tool/keycloak_bootstrap.sh --up +# +# ── Optionen ──────────────────────────────────────────────────────────────── +# --url Keycloak-Basis-URL inkl. Port (Default http://localhost:8080) +# --realm Realm-Name (Default: aus der JSON, "holzleitner") +# --admin Bootstrap-/Admin-User im master-Realm (Default admin) +# --admin-password Admin-Passwort (Default admin) [besser via Env KC_ADMIN_PASSWORD] +# --realm-file Realm-JSON (Default keycloak/import/realm-holzleitner.json) +# --provisioner-secret Überschreibt das Client-Secret von holzleitner-provisioner +# --no-test-user Entfernt den Test-User "testfahrer" aus der Payload (Prod) +# --reset Vorhandenen Realm LÖSCHEN + neu importieren (destruktiv!) +# --up Vor dem Import `docker compose up -d` (nur Dev) +# +# Alles auch via Env: KC_URL, REALM, KC_ADMIN, KC_ADMIN_PASSWORD, REALM_FILE, +# PROVISIONER_SECRET. CLI-Flags haben Vorrang. +# +# Deps: bash, curl, python3 (kein jq nötig). # # ACHTUNG: `--reset` löscht den kompletten Realm inkl. aller per ERP-Sync -# provisionierten Fahrer-Konten. Ohne `--reset` lässt das Skript einen bereits -# existierenden Realm unangetastet (und sagt, wie man ihn zurücksetzt). +# provisionierten Fahrer-Konten. set -euo pipefail -# ── Konfiguration ───────────────────────────────────────────────────────── SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +# ── Defaults (Env-Overrides) ─────────────────────────────────────────────── KC_URL="${KC_URL:-http://localhost:8080}" +REALM="${REALM:-}" # leer ⇒ aus JSON lesen KC_ADMIN="${KC_ADMIN:-admin}" KC_ADMIN_PASSWORD="${KC_ADMIN_PASSWORD:-admin}" REALM_FILE="${REALM_FILE:-${PROJECT_ROOT}/keycloak/import/realm-holzleitner.json}" - +PROVISIONER_SECRET="${PROVISIONER_SECRET:-}" # leer ⇒ Wert aus JSON behalten +NO_TEST_USER=0 RESET=0 -DO_UP=1 -for arg in "$@"; do - case "$arg" in - --reset) RESET=1 ;; - --no-up) DO_UP=0 ;; - -h|--help) - sed -n '2,30p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' - exit 0 ;; - *) echo "Unbekannte Option: $arg" >&2; exit 2 ;; +DO_UP=0 + +# ── Arg-Parsing (Flags haben Vorrang vor Env) ────────────────────────────── +while [ $# -gt 0 ]; do + case "$1" in + --url) KC_URL="$2"; shift 2 ;; + --realm) REALM="$2"; shift 2 ;; + --admin) KC_ADMIN="$2"; shift 2 ;; + --admin-password) KC_ADMIN_PASSWORD="$2"; shift 2 ;; + --realm-file) REALM_FILE="$2"; shift 2 ;; + --provisioner-secret) PROVISIONER_SECRET="$2"; shift 2 ;; + --no-test-user) NO_TEST_USER=1; shift ;; + --reset) RESET=1; shift ;; + --up) DO_UP=1; shift ;; + -h|--help) sed -n '2,60p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "Unbekannte Option: $1" >&2; exit 2 ;; esac done -# ── Vorbedingungen ──────────────────────────────────────────────────────── -command -v curl >/dev/null 2>&1 || { echo "✗ curl fehlt." >&2; exit 1; } -command -v python3 >/dev/null 2>&1 || { echo "✗ python3 fehlt (für JSON-Parsing)." >&2; exit 1; } +KC_URL="${KC_URL%/}" # evtl. abschließenden Slash entfernen + +# ── Vorbedingungen ────────────────────────────────────────────────────────── +command -v curl >/dev/null 2>&1 || { echo "✗ curl fehlt." >&2; exit 1; } +command -v python3 >/dev/null 2>&1 || { echo "✗ python3 fehlt (für JSON)." >&2; exit 1; } [ -f "${REALM_FILE}" ] || { echo "✗ Realm-Datei nicht gefunden: ${REALM_FILE}" >&2; exit 1; } -# Realm-Name aus der JSON lesen (statt hart zu kodieren). -REALM="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["realm"])' "${REALM_FILE}")" -echo "→ Realm '${REALM}' aus ${REALM_FILE}" -echo "→ Keycloak: ${KC_URL}" +# Realm-Name: CLI/Env hat Vorrang, sonst aus der JSON. +if [ -z "${REALM}" ]; then + REALM="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["realm"])' "${REALM_FILE}")" +fi -# ── 1. Keycloak (optional) starten ──────────────────────────────────────── +echo "→ Keycloak : ${KC_URL}" +echo "→ Realm : ${REALM}" +echo "→ Quelle : ${REALM_FILE}" + +# ── 1. (Dev) Keycloak via docker compose starten ──────────────────────────── if [ "${DO_UP}" -eq 1 ]; then if command -v docker >/dev/null 2>&1; then - echo "→ Stelle sicher, dass Keycloak + Postgres laufen (docker compose up -d) …" + echo "→ docker compose up -d keycloak postgres (Dev) …" (cd "${PROJECT_ROOT}" && docker compose up -d keycloak postgres >/dev/null) else - echo " (docker nicht gefunden — überspringe Start, erwarte laufendes Keycloak)" + echo " (docker nicht gefunden — überspringe --up)" fi fi -# ── 2. Auf Keycloak warten ──────────────────────────────────────────────── -echo -n "→ Warte auf Keycloak" +# ── 2. Payload aufbauen (Realm-Name/Secret/Test-User anwenden) ────────────── +PAYLOAD="$(mktemp -t kc-realm.XXXXXX.json)" +trap 'rm -f "${PAYLOAD}" /tmp/kc_import_resp.$$' EXIT +python3 - "${REALM_FILE}" "${PAYLOAD}" "${REALM}" "${PROVISIONER_SECRET}" "${NO_TEST_USER}" <<'PY' +import json, sys +src, dst, realm, secret, no_test_user = sys.argv[1:6] +d = json.load(open(src)) +d["realm"] = realm +if secret: + for c in d.get("clients", []): + if c.get("clientId") == "holzleitner-provisioner": + c["secret"] = secret +if no_test_user == "1": + d["users"] = [u for u in d.get("users", []) if u.get("username") != "testfahrer"] +json.dump(d, open(dst, "w")) +PY + +# ── 3. Auf Keycloak warten + Admin-Token holen ────────────────────────────── +# Authentifizierung des Skripts gegen die Admin-REST-API: Resource-Owner- +# Password-Grant im master-Realm über den eingebauten Client `admin-cli`. +echo -n "→ Admin-Login (${KC_ADMIN} @ master) " TOKEN="" for _ in $(seq 1 60); do TOKEN="$(curl -s --fail \ -d "client_id=admin-cli" \ -d "username=${KC_ADMIN}" \ - -d "password=${KC_ADMIN_PASSWORD}" \ + --data-urlencode "password=${KC_ADMIN_PASSWORD}" \ -d "grant_type=password" \ "${KC_URL}/realms/master/protocol/openid-connect/token" 2>/dev/null \ | python3 -c 'import json,sys -try: - print(json.load(sys.stdin).get("access_token","")) -except Exception: - print("")' 2>/dev/null || true)" - if [ -n "${TOKEN}" ]; then echo " — bereit."; break; fi +try: print(json.load(sys.stdin).get("access_token","")) +except Exception: print("")' 2>/dev/null || true)" + [ -n "${TOKEN}" ] && { echo "— ok."; break; } echo -n "." sleep 2 done if [ -z "${TOKEN}" ]; then echo - echo "✗ Konnte kein Admin-Token von ${KC_URL} holen (Admin ${KC_ADMIN})." >&2 - echo " Läuft Keycloak? Stimmen KC_ADMIN/KC_ADMIN_PASSWORD?" >&2 + echo "✗ Kein Admin-Token von ${KC_URL}. Läuft Keycloak? Stimmen --admin/--admin-password?" >&2 exit 1 fi +AUTH="Authorization: Bearer ${TOKEN}" -auth_header="Authorization: Bearer ${TOKEN}" - -# ── 3. Existiert der Realm schon? ───────────────────────────────────────── -http_code="$(curl -s -o /dev/null -w '%{http_code}' -H "${auth_header}" "${KC_URL}/admin/realms/${REALM}")" - -if [ "${http_code}" = "200" ]; then +# ── 4. Realm vorhanden? ───────────────────────────────────────────────────── +CODE="$(curl -s -o /dev/null -w '%{http_code}' -H "${AUTH}" "${KC_URL}/admin/realms/${REALM}")" +if [ "${CODE}" = "200" ]; then if [ "${RESET}" -eq 1 ]; then - echo "→ Realm '${REALM}' existiert — wird wegen --reset GELÖSCHT (inkl. aller provisionierten Konten) …" - curl -s --fail -X DELETE -H "${auth_header}" "${KC_URL}/admin/realms/${REALM}" >/dev/null + echo "→ Realm '${REALM}' existiert — wird wegen --reset GELÖSCHT (inkl. provisionierter Konten) …" + curl -s --fail -X DELETE -H "${AUTH}" "${KC_URL}/admin/realms/${REALM}" >/dev/null echo " gelöscht." else echo echo "✓ Realm '${REALM}' existiert bereits — keine Änderung." - echo " Zum sauberen Neu-Import (löscht provisionierte Fahrer-Konten!):" - echo " ./tool/keycloak_bootstrap.sh --reset" + echo " Sauberer Neu-Import (löscht provisionierte Konten!): … --reset" exit 0 fi -elif [ "${http_code}" != "404" ]; then - echo "✗ Unerwartete Antwort beim Realm-Check: HTTP ${http_code}" >&2 +elif [ "${CODE}" != "404" ]; then + echo "✗ Unerwartete Antwort beim Realm-Check: HTTP ${CODE}" >&2 exit 1 fi -# ── 4. Realm importieren (volle RealmRepresentation) ────────────────────── +# ── 5. Importieren ────────────────────────────────────────────────────────── echo "→ Importiere Realm '${REALM}' …" -import_code="$(curl -s -o /tmp/kc_import_resp.txt -w '%{http_code}' \ - -X POST -H "${auth_header}" -H "Content-Type: application/json" \ - --data-binary "@${REALM_FILE}" \ - "${KC_URL}/admin/realms")" - -if [ "${import_code}" != "201" ]; then - echo "✗ Import fehlgeschlagen: HTTP ${import_code}" >&2 - echo " Antwort: $(cat /tmp/kc_import_resp.txt 2>/dev/null)" >&2 +RESP="/tmp/kc_import_resp.$$" +IMP="$(curl -s -o "${RESP}" -w '%{http_code}' \ + -X POST -H "${AUTH}" -H "Content-Type: application/json" \ + --data-binary "@${PAYLOAD}" "${KC_URL}/admin/realms")" +if [ "${IMP}" != "201" ]; then + echo "✗ Import fehlgeschlagen: HTTP ${IMP}" >&2 + echo " Antwort: $(cat "${RESP}" 2>/dev/null)" >&2 exit 1 fi echo " importiert (HTTP 201)." -# ── 5. Verifizieren ─────────────────────────────────────────────────────── -echo "→ Verifiziere Konfiguration …" -clients_json="$(curl -s --fail -H "${auth_header}" "${KC_URL}/admin/realms/${REALM}/clients")" -echo "${clients_json}" | python3 -c ' +# ── 6. Verifizieren ───────────────────────────────────────────────────────── +echo "→ Verifiziere Clients …" +curl -s --fail -H "${AUTH}" "${KC_URL}/admin/realms/${REALM}/clients" | python3 -c ' import json, sys -clients = {c["clientId"]: c for c in json.load(sys.stdin)} +ids = {c["clientId"] for c in json.load(sys.stdin)} ok = True for cid in ("holzleitner-app", "holzleitner-provisioner"): - present = cid in clients + present = cid in ids print(" client " + cid + ": " + ("OK" if present else "FEHLT")) ok = ok and present sys.exit(0 if ok else 1) ' -# ── 6. Summary ──────────────────────────────────────────────────────────── +# ── 7. Summary ────────────────────────────────────────────────────────────── ISSUER="${KC_URL}/realms/${REALM}" +if [ -n "${PROVISIONER_SECRET}" ]; then + SECRET_LINE="provisioner_client_secret = \"${PROVISIONER_SECRET}\" (gesetzt)" +else + SECRET_LINE="provisioner_client_secret = \"provisioner-dev-secret\" (Dev-Wert aus der JSON — in Prod via --provisioner-secret setzen!)" +fi echo echo "✓ Fertig. Realm '${REALM}' ist eingerichtet und passt zum Backend." echo " ───────────────────────────────────────────────────────────────" -echo " Backend-config.toml muss dazu passen ([keycloak]-Section):" +echo " config.toml [keycloak]:" echo " issuer_url = \"${ISSUER}\"" echo " audience = \"holzleitner-api\"" echo " realm = \"${REALM}\"" echo " admin_url = \"${KC_URL}\"" echo " provisioner_client_id = \"holzleitner-provisioner\"" -echo " provisioner_client_secret = \"provisioner-dev-secret\" (Dev-Wert aus dem Realm)" +echo " ${SECRET_LINE}" echo " ───────────────────────────────────────────────────────────────" -echo " Admin-Console : ${KC_URL}/admin/ (${KC_ADMIN} / ***)" -echo " Test-Fahrer : testfahrer / test (Personalnummer 1001, Rolle driver)" +echo " Admin-Console : ${KC_URL}/admin/" +if [ "${NO_TEST_USER}" -eq 0 ]; then + echo " Test-Fahrer : testfahrer / test (Personalnummer 1001, Rolle driver)" +fi echo -echo " Hinweis: issuer_url muss EXAKT dem 'iss'-Claim entsprechen, das Keycloak" -echo " ausstellt (abhängig von KC_HOSTNAME). Bei LAN-/USB-Wechsel siehe" -echo " tool/dev_usb.sh." +echo " WICHTIG: issuer_url muss EXAKT dem 'iss'-Claim entsprechen, das Keycloak" +echo " ausstellt. In Prod dazu Keycloaks Frontend-/Hostname-URL passend setzen" +echo " (KC_HOSTNAME bzw. --hostname), sonst lehnt das Backend Tokens mit" +echo " 'invalid issuer' ab."