diff --git a/docs/m9-anmeldung-magic-link-und-sso.md b/docs/m9-anmeldung-magic-link-und-sso.md new file mode 100644 index 0000000..7b8298e --- /dev/null +++ b/docs/m9-anmeldung-magic-link-und-sso.md @@ -0,0 +1,228 @@ +# M9 Anmeldung: Magic-Link und SSO über Entra ID + +Stand: 2026-07-22 — **Planung, noch nicht umgesetzt.** + +Ziel: Mitglieder sollen sich ohne eigenes Passwort anmelden können, und ein +Mandanten-Administrator soll das selbst einrichten können. Zwei Stufen, die +unabhängig voneinander lieferbar sind: + +1. **Magic-Link** — Anmeldung per Einmal-Link in der E-Mail. +2. **SSO über Entra ID** (OpenID Connect) — Anmeldung über das Konto des + Kunden; dient gleichzeitig der Anbindung eines dahinterliegenden ADFS. + +Die Reihenfolge ist bewusst so gewählt: Der Magic-Link ist klein, sofort +nützlich und stellt danach den **Notzugang** bereit, den die SSO-Pflicht +braucht. + +## Entscheidungen (getroffen) + +| Frage | Entscheidung | +|---|---| +| Protokoll | OpenID Connect gegen Entra ID; **kein** SAML | +| ADFS | indirekt über Entra ID, nicht direkt angebunden | +| Konten anlegen | **kein** automatisches Anlegen beim SSO-Login | +| SSO-Pflicht | pro Mandant einschaltbar, Passwort dann deaktiviert | +| Notzugang | Magic-Link, beschränkt auf `owner`/`admin` | +| Entra-Modell | Kunde registriert **eigene** App in seinem Verzeichnis | + +### Warum kein automatisches Anlegen + +Beim SSO-Login wird ausschließlich ein **bestehendes** Mitglied dieses +Mandanten angemeldet. Gibt es keines, erscheint ein Hinweis. Das passt zum +vorhandenen Ablauf (Mitglied anlegen → „Zugang gewähren" über +`saas_grant_participant_access()`) und macht eine DNS-Domain-Verifikation für +die Sicherheit entbehrlich: Ohne Anlegen und ohne Verschmelzen fremder Konten +kann ein Mandant über sein IdP keine Identitäten beanspruchen, die ihm nicht +gehören. + +`tenant_domains` bleibt damit vorerst ungenutzt. Eine Verifikation wäre später +nur Komfort (Mandant automatisch an der E-Mail-Domain erkennen). + +## Was schon vorhanden ist + +Beide Stufen setzen fast vollständig auf Bestehendem auf: + +```text +app/saas-auth.php saas_create_auth_token(), saas_fetch_valid_auth_token(), + saas_session_login(), saas_grant_participant_access() +app/saas-mail.php saas_send_mail(), saas_log_outbound_email(), saas_app_url() +app/rate-limit.php app_rate_limit_check() +app/audit.php app_audit_log() +user_auth_tokens user_id, tenant_id (nullable), token_type, token_hash, + expires_at, consumed_at +users password_hash ist NULL-fähig -> Konten ohne Passwort + sind bereits darstellbar +``` + +`saas_create_auth_token()`/`saas_fetch_valid_auth_token()` sind über +`token_type` generisch. Der Magic-Link ist damit ein **neuer Token-Typ**, keine +neue Infrastruktur. `passwort-vergessen.php` dient als Vorlage für den Ablauf +inklusive Rate-Limits. + +--- + +## Stufe 1: Magic-Link + +### Datenmodell + +Keine neue Tabelle. Ergänzungen an `tenant_settings`: + +```sql +login_password_enabled TINYINT(1) NOT NULL DEFAULT 1 +login_magic_link_enabled TINYINT(1) NOT NULL DEFAULT 0 +magic_link_ttl_minutes INT NOT NULL DEFAULT 15 +``` + +Neuer Wert für `user_auth_tokens.token_type`: `magic_link`. Anders als beim +Passwort-Reset wird `tenant_id` **gesetzt** — der Link meldet für genau einen +Mandanten an. + +### Ablauf + +1. Auf `login.php` neben dem Passwortfeld: „Link per E-Mail anfordern" + (nur sichtbar, wenn für den Mandanten aktiviert). +2. Eingabe E-Mail + Kundenkürzel. Rate-Limit analog `passwort-vergessen.php` + (pro E-Mail und pro IP). +3. Nutzer mit aktiver Mitgliedschaft in diesem Mandanten suchen. Antwort ist + **immer gleich**, unabhängig davon, ob es ihn gibt. +4. Token erzeugen (TTL aus den Einstellungen), Link per Mail, Versand über + `saas_log_outbound_email()` protokollieren. +5. Neue Seite `magic-login.php`: Token prüfen, als verbraucht markieren, + `saas_session_login()` mit **festem** Mandanten aufrufen, `app_audit_log()`. + +### Zu beachten + +- Token ist **einmal** gültig (`consumed_at`) und kurzlebig (15 Minuten). +- Bestehende, noch offene Magic-Link-Token des Nutzers beim Neuanfordern + entwerten — sonst sammeln sich mehrere gültige Links an. +- Der Link darf **nicht** über den `pending`-Weg der Mandantenauswahl laufen, + sondern direkt in den Mandanten, für den er ausgestellt wurde. +- Link nur per `POST`-Bestätigung einlösen oder mit `Referrer-Policy` schützen, + damit er nicht über Referer/Prefetch abfließt. + +### Aufwand + +Klein — eine Migration, zwei Seiten, eine Mailvorlage, Erweiterung von +`login.php` und den Mandant-Einstellungen. + +--- + +## Stufe 2: SSO über Entra ID (OIDC) + +### Datenmodell + +Neue Tabelle `tenant_sso_providers` (eine Konfiguration je Mandant): + +```text +id INT PK +tenant_id INT NOT NULL UNIQUE -> tenants(id) ON DELETE CASCADE +provider VARCHAR(30) 'entra' +directory_id VARCHAR(100) Entra-Verzeichnis (tid), hart geprüft +client_id VARCHAR(200) +client_secret_enc VARBINARY mit libsodium verschlüsselt +discovery_url VARCHAR(500) +enabled TINYINT(1) +enforce_sso TINYINT(1) Passwort/Magic-Link für Mitglieder aus +created_at/updated_at DATETIME +``` + +Ergänzung an `users` für die stabile Zuordnung: + +```sql +sso_subject VARCHAR(200) NULL -- Entra: tid|oid +UNIQUE KEY uq_users_sso_subject (sso_subject) +``` + +Die E-Mail dient nur der **ersten** Zuordnung zum vorhandenen Mitglied; danach +wird über `sso_subject` erkannt. `email`/`preferred_username` sind in Entra +veränderbar und taugen nicht als Schlüssel. + +### Ablauf (Authorization Code + PKCE) + +1. `sso-start.php?tenant=`: Konfiguration laden, `state`, `nonce` und + `code_verifier` in der Session ablegen, zu Entra weiterleiten. +2. `sso-callback.php`: `state` prüfen, Code gegen Token tauschen + (`https`-Stream-Wrapper, kein curl nötig). +3. **ID-Token prüfen**: Signatur über JWKS (RS256, `openssl`), dazu `iss`, + `aud`, `exp`/`iat`, `nonce` — und `tid` **hart gegen `directory_id`**. +4. Nutzer über `sso_subject` suchen, sonst einmalig über die E-Mail einem + vorhandenen Mitglied **dieses** Mandanten zuordnen und `sso_subject` + festschreiben. +5. Kein Treffer → Hinweisseite: „Für diese E-Mail-Adresse ist in diesem + Mandanten kein Zugang hinterlegt." Kein Konto anlegen. +6. Treffer → `saas_session_login()` mit dem Mandanten aus Schritt 1. + +### Sicherheitsregeln + +- **`tid` immer prüfen.** Ohne diese Prüfung könnte sich jeder Entra-Mandant + der Welt anmelden — der klassische Fehler bei mandantenfähigen Entra-Apps. +- **Nie über die Mandantenauswahl.** Der SSO-Rückkanal ruft + `saas_session_login()` direkt mit festem Mandanten auf. Heute ist dieser Weg + ohnehin zu (`mandant-auswahl.php` verlangt den `pending`-Zustand); er darf + nicht nachträglich geöffnet werden. +- **Client-Secret verschlüsselt** ablegen (libsodium, Schlüssel aus der Env, + z. B. `APP_SECRET_KEY`), niemals im Klartext und nie im Formular + zurückspielen — nur „gesetzt/nicht gesetzt" anzeigen. +- **Ablauf des Secrets** ist die wahrscheinlichste Störung (Entra-Standard: + 6–24 Monate). Ablaufdatum mitpflegen und rechtzeitig warnen. + +### SSO-Pflicht und Notzugang + +Ist `enforce_sso` gesetzt: + +| Rolle | Passwort | Magic-Link | Entra | +|---|---|---|---| +| `member`, `viewer`, `treasurer` | aus | aus | ja | +| `owner`, `admin` | aus | **ja (Notzugang)** | ja | + +Jede Nutzung des Notzugangs wird im Audit-Log vermerkt; zusätzlich eine +Info-Mail an die übrigen Inhaber, damit eine stille Umgehung auffällt. Grund: +Ein abgelaufenes Client-Secret sperrt sonst genau die Personen aus, die es +reparieren müssten — der Mailversand ist davon nicht betroffen. + +`enforce_sso` darf sich erst einschalten lassen, **nachdem** mindestens eine +erfolgreiche Testanmeldung über Entra stattgefunden hat. Sonst sperrt eine +fehlerhafte Konfiguration den Mandanten sofort aus. + +### Einrichtung durch den Mandanten-Administrator + +Neuer Abschnitt in `mandant-einstellungen.php`: + +1. Anzeige der Redirect-URI zum Kopieren (`https://…/sso-callback.php`). +2. Eingabe von Verzeichnis-ID, Client-ID, Client-Secret. +3. Schaltfläche „Verbindung testen" — führt einen vollständigen Anmeldelauf + aus und zeigt die zurückgelieferten Claims an. +4. Erst nach erfolgreichem Test: „SSO verpflichtend" aktivierbar. + +Als Anleitung für die Entra-Seite: App-Registrierung anlegen, Redirect-URI vom +Typ *Web* eintragen, Client-Secret erzeugen, Berechtigungen `openid`, +`profile`, `email`. + +### Aufwand + +Deutlich größer als Stufe 1 — der Flow selbst ist überschaubar, aber +JWKS-Abruf mit Cache, Token-Prüfung, verschlüsselte Secrets, Testfunktion und +die Admin-Oberfläche summieren sich. + +--- + +## Reihenfolge + +1. Migration Login-Einstellungen + Magic-Link (Stufe 1) — danach ist der + Notzugang vorhanden. +2. `tenant_sso_providers` + Secret-Verschlüsselung. +3. OIDC-Flow ohne Oberfläche, per Konfiguration in der Env testbar. +4. Admin-Oberfläche inklusive Testanmeldung. +5. `enforce_sso` scharfschalten, Notzugangs-Regeln und Protokollierung. + +## Offene Punkte + +- **Composer**: Für OIDC nicht zwingend nötig (`openssl` + `json` + + `https`-Wrapper genügen). Falls doch eine JWT-Bibliothek gewünscht ist, + müsste Composer auf dem Netcup-Webspace eingerichtet werden. Welche + PHP-Erweiterungen dort verfügbar sind, ist noch zu prüfen (`php -m`). +- **Mehrere IdPs je Mandant** ist bewusst nicht vorgesehen (eine + Konfiguration je Mandant). Bei Bedarf später `UNIQUE` auf `tenant_id` lösen. +- **SCIM/automatische Bereitstellung** aus Entra ist bewusst nicht Teil des + Plans — durch die Entscheidung gegen automatisches Anlegen bleibt die + Mitgliederpflege in der Kaffeeliste.