Ergaenzt den Abschnitt, was eine direkte SAML-Anbindung ans ADFS zusaetzlich kosten wuerde - als Entscheidungsgrundlage neben dem beschlossenen OIDC-Weg, nicht als beschlossener Weg. Kernpunkte: SAML braucht ext-dom, ext-simplexml und ext-mbstring sowie Composer; in der aktuellen Dev-Umgebung fehlt jede XML-Faehigkeit (nicht einmal DOMDocument existiert), SAML liesse sich dort weder bauen noch testen. Dazu eigenes SP-Zertifikat samt Erneuerung, eine Tabelle gegen Wiedereinspielung, eine umfangreiche Pruefliste fuer eingehende Assertions und der automatische Zertifikatswechsel von ADFS als haeufigste Stoerungsursache. Empfehlung bleibt OIDC ueber Entra; SAML waere Stufe 3 nach Magic-Link und OIDC, mit der Erweiterungspruefung auf dem Zielhost als erster Aufgabe. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
398 lines
16 KiB
Markdown
398 lines
16 KiB
Markdown
# 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:
|
||
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.
|
||
|
||
---
|
||
|
||
## Alternative: SAML 2.0 direkt gegen ADFS
|
||
|
||
Nur relevant, wenn ein Kunde die Anmeldung **direkt** am ADFS will statt über
|
||
Entra ID. Hier steht, was das zusätzlich kostet — als Entscheidungsgrundlage,
|
||
nicht als beschlossener Weg.
|
||
|
||
### Voraussetzungen (zuerst klären)
|
||
|
||
SAML heißt XML, und XML heißt PHP-Erweiterungen:
|
||
|
||
```text
|
||
ext-dom Pflicht
|
||
ext-simplexml Pflicht
|
||
ext-mbstring Pflicht
|
||
ext-openssl Pflicht (vorhanden)
|
||
ext-zlib für Redirect-Binding (vorhanden)
|
||
Composer für die Bibliothek
|
||
```
|
||
|
||
**In der aktuellen Dev-Umgebung ist davon nichts vorhanden** — `DOMDocument`
|
||
existiert nicht einmal. SAML lässt sich hier weder entwickeln noch testen. Vor
|
||
allem anderen muss deshalb geklärt werden:
|
||
|
||
1. Welche Erweiterungen bringt der Netcup-Webspace mit (`php -m` dort)?
|
||
2. Wie sieht eine Entwicklungsumgebung aus, in der man das überhaupt bauen kann?
|
||
|
||
Ist Punkt 1 negativ, endet der SAML-Weg hier.
|
||
|
||
### Bibliothek
|
||
|
||
`onelogin/php-saml` (v4) ist der De-facto-Standard und die realistische Wahl.
|
||
SimpleSAMLphp ist ein vollständiges Föderations-Framework und für einen
|
||
einzelnen Service Provider deutlich überdimensioniert.
|
||
|
||
**Selbst implementieren ist ausgeschlossen.** XML-Signaturprüfung ist der
|
||
klassische Ort für *XML Signature Wrapping*: Der Angreifer hängt eine zweite,
|
||
manipulierte Assertion so ins Dokument, dass die Signaturprüfung das echte
|
||
Element prüft, die Anwendung aber das gefälschte liest. Diese Klasse von
|
||
Fehlern hat über Jahre reihenweise SAML-Implementierungen getroffen.
|
||
|
||
### Datenmodell
|
||
|
||
`tenant_sso_providers` um SAML-Felder erweitern (oder eigene Tabelle):
|
||
|
||
```text
|
||
protocol VARCHAR(10) 'oidc' | 'saml'
|
||
idp_entity_id VARCHAR(500)
|
||
idp_sso_url VARCHAR(500)
|
||
idp_slo_url VARCHAR(500) optional
|
||
idp_x509_certs TEXT mehrere! siehe Zertifikatswechsel
|
||
idp_metadata_url VARCHAR(500) für automatische Aktualisierung
|
||
sp_entity_id VARCHAR(500)
|
||
attr_email VARCHAR(200) welches Attribut die E-Mail trägt
|
||
```
|
||
|
||
Zusätzlich eine Tabelle gegen Wiedereinspielung:
|
||
|
||
```text
|
||
saml_seen_assertions assertion_id (UNIQUE), tenant_id, expires_at
|
||
```
|
||
|
||
Ohne diese Tabelle kann eine einmal abgefangene Assertion innerhalb ihres
|
||
Gültigkeitsfensters mehrfach eingelöst werden.
|
||
|
||
### Endpunkte
|
||
|
||
Alle pfadbasiert auf `APP_HOST`, **keine** Subdomain je Mandant:
|
||
|
||
```text
|
||
/saml/metadata.php?tenant=<slug> SP-Metadaten (XML) für den Kunden
|
||
/saml/acs.php?tenant=<slug> Assertion Consumer Service (HTTP-POST)
|
||
/saml/sls.php?tenant=<slug> Single Logout (optional)
|
||
```
|
||
|
||
### SP-Zertifikat
|
||
|
||
Der Service Provider braucht ein eigenes Schlüsselpaar (selbstsigniert
|
||
genügt), dessen öffentlicher Teil in den Metadaten steht:
|
||
|
||
- Privater Schlüssel **außerhalb** des Webroots, Rechte 0600.
|
||
- Laufzeit und Erneuerung einplanen — läuft es ab, bricht die Anmeldung.
|
||
- Bei Erneuerung müssen die Kunden die Metadaten neu importieren, sofern sie
|
||
sie nicht per URL automatisch beziehen.
|
||
|
||
### Prüfliste für eingehende Assertions
|
||
|
||
Diese Punkte muss die Umsetzung nachweislich abdecken:
|
||
|
||
- Die **Assertion** selbst muss signiert sein, nicht nur die Response.
|
||
- `Issuer` stimmt mit `idp_entity_id` des Mandanten überein.
|
||
- `Audience` ist unsere `sp_entity_id`.
|
||
- `Destination`/`Recipient` entsprechen unserer ACS-URL.
|
||
- `NotBefore` / `NotOnOrAfter` gültig, mit definierter Toleranz.
|
||
- `InResponseTo` passt zu einer von uns gestellten Anfrage.
|
||
- `assertion_id` wurde noch nicht verwendet (Wiedereinspielung).
|
||
- Unsignierte Assertions und SHA-1 werden **abgelehnt**.
|
||
|
||
Zur Zeittoleranz: SAML reagiert empfindlich auf Uhrabweichungen. Die
|
||
Angleichung von PHP und MySQL (siehe Commit `064c872`) ist dafür Voraussetzung;
|
||
zusätzlich sollte die Serverzeit per NTP laufen.
|
||
|
||
### ADFS-Besonderheiten
|
||
|
||
- **Automatischer Zertifikatswechsel**: ADFS erneuert sein Token-Signing-
|
||
Zertifikat standardmäßig selbsttätig (jährlich, Ankündigung 30 Tage vorher).
|
||
Wer nur ein Zertifikat fest hinterlegt, steht an diesem Tag still. Deshalb
|
||
entweder **mehrere** Zertifikate parallel akzeptieren oder die
|
||
IdP-Metadaten regelmäßig per Cron neu einlesen. Das ist die mit Abstand
|
||
häufigste Störungsursache bei ADFS-Anbindungen.
|
||
- **Claim Rules** muss der Kunde bei sich anlegen: E-Mail bzw. UPN als NameID
|
||
oder als Attribut. Ohne passende Regel kommt eine Assertion ohne E-Mail an.
|
||
- ADFS erwartet SHA-256; SHA-1 ist abgekündigt.
|
||
- Der Kunde importiert unsere Metadaten als **Relying Party Trust** (per URL
|
||
oder Datei).
|
||
|
||
### Mandantenfähigkeit
|
||
|
||
Ein Service Provider, viele IdPs. Der Mandant ergibt sich aus dem
|
||
aufgerufenen ACS-Pfad **und** muss zusätzlich gegen den `Issuer` der Assertion
|
||
geprüft werden. Stimmen beide nicht überein, wird abgelehnt — sonst könnte
|
||
eine gültige Assertion aus Mandant A an der ACS-URL von Mandant B eingereicht
|
||
werden.
|
||
|
||
Es gelten unverändert die Regeln aus dem OIDC-Teil: kein automatisches
|
||
Anlegen von Konten, `saas_session_login()` direkt mit festem Mandanten, nie
|
||
über die Mandantenauswahl.
|
||
|
||
### Oberfläche für den Mandanten-Administrator
|
||
|
||
1. SP-Metadaten zum Herunterladen bzw. als URL zum Kopieren.
|
||
2. IdP-Metadaten hinterlegen: als URL (bevorzugt, ermöglicht automatische
|
||
Aktualisierung) oder als hochgeladene XML-Datei.
|
||
3. Beim Einlesen fremder XML-Dateien externe Entitäten und Netzwerkzugriffe
|
||
unterbinden (XXE); unter PHP 8 ist das Standard, muss aber abgesichert
|
||
bleiben.
|
||
4. Testanmeldung, bevor `enforce_sso` aktiviert werden darf.
|
||
|
||
### Testbarkeit
|
||
|
||
Ohne echtes ADFS zum Ausprobieren wird das nichts. Möglichkeiten, absteigend
|
||
nach Aussagekraft: ein ADFS-Testsystem des Kunden, ein lokales SimpleSAMLphp
|
||
als Test-IdP, oder Entra ID als SAML-IdP konfiguriert (der Kunde hat es
|
||
ohnehin) — Letzteres testet den Weg allerdings nicht gegen ADFS selbst.
|
||
|
||
### Aufwand im Vergleich
|
||
|
||
| | OIDC (Entra) | SAML (ADFS) |
|
||
|---|---|---|
|
||
| Zusätzliche PHP-Erweiterungen | keine | dom, simplexml, mbstring |
|
||
| Composer nötig | nein | ja |
|
||
| Fremdbibliothek | optional | zwingend |
|
||
| Eigene Zertifikate | nein | ja, inkl. Erneuerung |
|
||
| Zertifikatswechsel der Gegenseite | entfällt | jährlich, bricht sonst |
|
||
| Wiedereinspielungsschutz | über `nonce` | eigene Tabelle nötig |
|
||
| Prüfschritte am Token | überschaubar | umfangreiche Prüfliste |
|
||
| In der Dev-Umgebung baubar | ja | **nein** |
|
||
|
||
### Empfehlung
|
||
|
||
Weiterhin OIDC über Entra ID. SAML lohnt sich nur, wenn ein Kunde
|
||
ausdrücklich kein Entra für diese Anwendung zulässt — und selbst dann wäre der
|
||
Umweg über einen Broker (Keycloak/Authentik übersetzt SAML nach OIDC) meist
|
||
günstiger als eine eigene SAML-Implementierung samt Zertifikatspflege.
|
||
|
||
Falls SAML doch kommt: **nach** Stufe 1 und 2, als eigenständige Stufe 3, und
|
||
mit der Erweiterungsprüfung auf dem Zielhost als erster Aufgabe.
|
||
|
||
## 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.
|