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

9.4 KiB
Raw Blame History

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:

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.


Datenmodell

Keine neue Tabelle. Ergänzungen an tenant_settings:

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):

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:

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.