Planung M9: Anmeldung per Magic-Link und SSO ueber Entra ID

Noch keine Umsetzung - nur Datenmodell, Ablaeufe und Reihenfolge zum
Abschaetzen. Zwei unabhaengig lieferbare Stufen: erst Magic-Link (klein und
stellt danach den Notzugang bereit), dann OIDC gegen Entra ID.

Festgehaltene Entscheidungen: OIDC statt SAML, ADFS indirekt ueber Entra,
kein automatisches Anlegen von Konten beim SSO-Login, SSO-Pflicht pro Mandant
mit Magic-Link als Notzugang fuer owner/admin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 16:41:34 +02:00
co-authored by Claude Opus 4.8
parent d18c55eef5
commit 143d7efc97
+228
View File
@@ -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=<slug>`: 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:
624 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.