Files
kaffeekasse-saas/docs/m9-anmeldung-magic-link-und-sso.md
T
clemensandClaude Opus 4.8 143d7efc97 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>
2026-07-22 16:41:34 +02:00

229 lines
9.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.