Files
perinco-CRM/docs/13-anmeldung-sso.md
T
Sascha WolfandClaude Opus 5.5 e2ad8ab03e feat(auth): Sitzungen laufen standardmaessig 7 Tage
JWT_EXPIRES_IN faellt ohne Eintrag in .env jetzt auf 7d statt 12h zurueck.
Doku und .env.example angepasst.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 22:05:24 +02:00

272 lines
13 KiB
Markdown

# 13 Anmeldung über Microsoft (SSO)
Wer ein Microsoft-365-Konto von perinco hat, meldet sich im CRM mit einem Klick
an - ohne zweites Passwort. Die Anmeldung mit Benutzername und Passwort bleibt
daneben bestehen.
## Auf einen Blick
| | |
| --- | --- |
| Verfahren | OpenID Connect, Authorization Code Flow mit PKCE |
| Wo läuft es | vollständig im Backend; der Browser sieht keine Tenant- oder Client-ID |
| App-Registrierung | dieselbe wie die [Postfachanbindung](11-postfach.md) - kein zweites Secret |
| Neue Konten | **werden nicht angelegt**; es braucht ein bestehendes Konto in der Nutzerverwaltung |
| Passwortanmeldung | bleibt für alle bestehen (Notzugang, falls Entra ausfällt) |
| Sitzung | wie bisher ein CRM-JWT aus `signToken()`, Standard 7 Tage |
## Ablauf
```mermaid
sequenceDiagram
participant B as Browser
participant API as Backend
participant E as Entra
participant G as Microsoft Graph
participant DB as MariaDB
B->>API: GET /api/auth/sso/start
Note right of API: state, nonce,<br/>PKCE-Verifier merken
API-->>B: 302 zu login.microsoftonline.com
B->>E: Anmeldung, Kontoauswahl
E-->>B: 302 /api/auth/sso/callback?code&state
B->>API: GET /callback
API->>API: state verbrauchen (gilt einmal)
API->>E: code + Verifier + Client-Secret
E-->>API: id_token, access_token
API->>API: aud, tid, nonce, exp prüfen
API->>G: GET /me (delegiert)
G-->>API: Objekt-ID, Name, Adresse
API->>DB: Konto über entra_oid, sonst über E-Mail
DB-->>API: Benutzer oder nichts
API->>G: GET /me/photos/96x96/$value
API-->>B: 302 /?sso_ticket=...
B->>API: POST /api/auth/sso/ticket
API-->>B: CRM-Token + Benutzer
```
### Warum ein Ticket und kein Token in der Adresszeile
Die Rückleitung von Microsoft ist ein `GET`. Stünde das CRM-Token darin, läge es
im Browserverlauf, im Apache-Log und in jedem weitergegebenen Link. Stattdessen
kommt ein Einmal-Ticket zurück, das der Browser sofort per `POST` gegen das
Token tauscht. Das Ticket gilt 60 Sekunden und genau einmal; `auth.tsx` räumt die
Adresszeile anschliessend mit `history.replaceState()` auf.
### Warum zusätzlich `/me` bei Graph
Das ID-Token kommt über TLS direkt vom Token-Endpunkt und ist damit
vertrauenswürdig - OIDC Core 3.1.3.7 erlaubt in diesem Fall den Verzicht auf die
Signaturprüfung, deshalb braucht das Backend keine JWKS-Bibliothek. Geprüft
werden trotzdem `aud`, `tid`, `nonce` und `exp`. Der Graph-Aufruf kommt dazu,
weil er zweierlei belegt: dass das Zugriffstoken wirklich gilt, und welche
Objekt-ID zum Konto gehört.
## Zuordnung zum CRM-Konto
```mermaid
flowchart TD
A["Profil von Graph:<br/>Objekt-ID, E-Mail"] --> B{"crm_users.entra_oid<br/>= Objekt-ID?"}
B -->|ja| Z["anmelden"]
B -->|nein| C{"crm_users.email = Adresse<br/>und entra_oid leer?"}
C -->|ja| D["entra_oid festschreiben"] --> Z
C -->|nein| E["abweisen:<br/>kein CRM-Konto"]
Z --> F{"is_active?"}
F -->|nein| G["abweisen:<br/>Konto deaktiviert"]
```
Die Spalte `crm_users.entra_oid` (Unique) ist die dauerhafte Verknüpfung: die
E-Mail-Adresse eines Menschen kann sich ändern, die Objekt-ID des
Microsoft-Kontos nicht. Sie wird bei der ersten Anmeldung gesetzt und danach
nicht mehr über die Adresse gesucht.
Ein Konto, das bereits an einer **anderen** Objekt-ID hängt, wird nicht
übernommen - die Bedingung `entra_oid IS NULL` schliesst das aus.
### Profilfoto
Hat der Benutzer im CRM noch kein Foto, holt die erste Anmeldung
`/me/photos/96x96/$value` und legt es als Data-URL in `crm_users.avatar` ab -
dieselbe Spalte, die auch der Bild-Upload und
[der LinkedIn-Weg](09-quellcode.md) benutzen. Ein selbst gesetztes Foto bleibt
unangetastet, und wer kein Foto in M365 hat, meldet sich trotzdem an (Graph
antwortet dann mit 404, das ist eingeplant).
## Endpunkte
| Methode | Pfad | Zugriff | Zweck |
| --- | --- | --- | --- |
| `GET` | `/api/auth/sso/status` | offen | `{ enabled }` - zeigt die Anmeldemaske den Knopf? |
| `GET` | `/api/auth/sso/start` | offen | Umleitung zu Entra |
| `GET` | `/api/auth/sso/callback` | offen | Rückleitung von Entra, endet in `/?sso_ticket=...` |
| `POST` | `/api/auth/sso/ticket` | offen | Ticket gegen CRM-Token tauschen |
| `GET` | `/api/auth/sso/config` | admin | Status, fehlende Variablen, Umleitungsadresse, Zustand des Secrets |
| `PUT` | `/api/auth/microsoft-secret` | admin | Secret und/oder Ablaufdatum setzen |
| `DELETE` | `/api/auth/microsoft-secret` | admin | hinterlegtes Secret verwerfen, `.env` gilt wieder |
Scheitert etwas, landet der Browser auf `/?sso_error=<Meldung>`; die
Anmeldemaske zeigt den Text im gewohnten roten Kasten.
## Client-Secret pflegen
Das Secret der App-Registrierung läuft ab - in Entra sind 6 oder 24 Monate
üblich. Läuft es aus, stehen **Anmeldung und Postfachabgleich gleichzeitig**
still, denn beide hängen an derselben Registrierung. Damit dafür niemand auf den
Server muss, lässt es sich unter *Einstellungen → Anmeldung* eintragen.
```mermaid
flowchart LR
A["Einstellungen →<br/>Anmeldung"] -->|"PUT /microsoft-secret"| B["Probe bei Microsoft:<br/>Client-Credentials-Token"]
B -->|abgelehnt| C["nichts gespeichert,<br/>Meldung von Entra"]
B -->|angenommen| D["AES-256-GCM<br/>verschlüsseln"]
D --> E[("crm_settings")]
E --> F["Graph-Token verwerfen -<br/>der Zwischenspeicher<br/>gehört zum alten Secret"]
```
**Reihenfolge beim Lesen:** erst `crm_settings`, dann `MS_CLIENT_SECRET` aus der
`.env`. Wer nichts einträgt, merkt von der Neuerung nichts; der `.env`-Wert
bleibt die Rückfallebene. *Hinterlegtes Secret verwerfen* schaltet bewusst
dorthin zurück.
**Warum verschlüsselt:** ein Geheimnis im Klartext in der Datenbank stünde in
jedem Dump. Der Schlüssel kommt aus `CRM_ENCRYPTION_KEY`, ersatzweise aus
`JWT_SECRET` - beide stehen in der `.env` und damit nie im selben Dump. Wird
eine dieser Variablen gewechselt, lässt sich das gespeicherte Secret nicht mehr
lesen; die Oberfläche sagt das dann ausdrücklich, und es wird einmal neu
eingetragen. Ein stiller Rückfall auf die `.env` findet in diesem Fall **nicht**
statt - sonst sucht man den Fehler an der falschen Stelle.
**Probe vor dem Speichern:** das CRM holt mit dem eingegebenen Wert ein
Client-Credentials-Token. Erst wenn Microsoft es ausstellt, wird gespeichert. Ein
Tippfehler legt so nichts lahm.
### Warnung vor dem Ablauf
Das Ablaufdatum wird von Hand eingetragen - es aus Entra zu lesen bräuchte die
Berechtigung `Application.Read.All`, die die App bewusst nicht hat.
Ab **30 Tagen** vor dem Ablauf (`WARN_DAYS` in `msSecret.js`) erscheint für
Administratoren über jeder Ansicht ein Hinweisband, das direkt in diesen
Einstellungsreiter führt; nach dem Ablauf wird es rot. Der Hinweis reist mit
`GET /api/badges` mit, das die App ohnehin bei jedem Laden abruft - ein eigener
Endpunkt wäre eine zweite Anfrage für eine Zahl. Benutzer ohne Adminrolle
bekommen ihn nicht: sie können das Secret nicht erneuern.
| Zustand | Bedeutung |
| --- | --- |
| `ok` | mehr als 30 Tage übrig |
| `warnung` | 0 bis 30 Tage übrig - Hinweisband in Gelb |
| `abgelaufen` | Datum liegt in der Vergangenheit - Hinweisband in Rot |
| `unbekannt` | kein Datum hinterlegt, keine Warnung möglich |
### So wird erneuert
1. Entra → *App-Registrierungen* → **perinco crm** → *Zertifikate & Geheimnisse*
→ **Neuer geheimer Clientschlüssel**.
2. Wert sofort kopieren - Entra zeigt ihn nur einmal.
3. Im CRM unter *Einstellungen → Anmeldung* eintragen, Ablaufdatum übernehmen,
speichern.
4. Erst danach das alte Geheimnis in Entra löschen.
Ein Neustart des Backends ist nicht nötig: das Secret wird bei jedem Bedarf
frisch gelesen, und das zwischengespeicherte Graph-Token wird beim Speichern
verworfen.
## Einrichtung in Entra
Die App-Registrierung **perinco crm** existiert bereits für die
Postfachanbindung. Für die Anmeldung kommen zwei Dinge dazu.
**1. Umleitungsadresse.** *App-Registrierungen → perinco crm → Authentifizierung
→ Plattform hinzufügen → Web*:
```
https://crm.perinco.de/api/auth/sso/callback
```
Zeichengenau, sonst antwortet Entra mit `AADSTS50011`. Front-Channel-Abmelde-URL
bleibt leer, die Haken bei *Implizite Genehmigung* bleiben **aus** - der Code
Flow braucht sie nicht. Für die lokale Entwicklung zusätzlich
`http://localhost:3100/api/auth/sso/callback`; `http` erlaubt Entra nur für
localhost.
**2. Delegierte Berechtigungen** mit Administratorzustimmung:
| Berechtigung | Wofür |
| --- | --- |
| `openid`, `profile` | Anmeldung, Grunddaten |
| `User.Read` | `/me` - Objekt-ID, Name, Adresse |
| `ProfilePhoto.Read.All` | Profilfoto beim ersten Anmelden |
Die Anwendungsberechtigung `Mail.Read` gehört zur Postfachanbindung und hat mit
der Anmeldung nichts zu tun.
Weiter empfohlen: *Unterstützte Kontotypen* auf den eigenen Mandanten begrenzen
und *Öffentliche Clientflows zulassen* auf **Nein** - das Backend ist ein
vertraulicher Client mit Secret.
## Umgebungsvariablen
`server/.env`, Vorlage in `server/.env.example`.
| Variable | Pflicht | Bedeutung |
| --- | --- | --- |
| `MS_SSO_ENABLED` | ja | nur `true` schaltet die Anmeldung ein |
| `MS_SSO_REDIRECT_URI` | ja | exakt die in Entra eingetragene Adresse |
| `MS_SSO_ALLOWED_DOMAINS` | nein | Positivliste von E-Mail-Domains, leer = keine zusätzliche Prüfung. Im Betrieb: `perinco.de,icon-experts.de` |
| `MS_TENANT_ID`, `MS_CLIENT_ID` | ja | dieselben Werte wie beim Postfach |
| `MS_CLIENT_SECRET` | nein | Rückfallebene; Vorrang hat das in den Einstellungen hinterlegte Secret |
| `CRM_ENCRYPTION_KEY` | nein | verschlüsselt das hinterlegte Secret; ohne den Wert dient `JWT_SECRET` als Schlüssel |
Nicht jedes CRM-Konto trägt eine `@perinco.de`-Adresse - `icon-experts.de`
gehört dazu. Wer eine Domain vergisst, sperrt die betroffenen Leute vom
Microsoft-Weg aus; die Anmeldung mit Passwort funktioniert für sie weiterhin.
> Die Werte werden **beim Start** gelesen. Nach einer Änderung an der `.env`
> muss `perinco-crm-backend` neu starten. Fehlt etwas, bleibt die Anmeldung aus
> und der Einstellungsreiter *Anmeldung* nennt die fehlende Variable beim Namen -
> genau wie beim Postfach.
## Was das Backend nicht tut
* **Keine Konten anlegen.** Wer im CRM kein Konto hat, wird abgewiesen - mit dem
Hinweis, sich an einen Administrator zu wenden. Zugriff vergibt weiterhin die
Nutzerverwaltung.
* **Keine Rollen aus Entra übernehmen.** `admin` bleibt eine Entscheidung im CRM.
* **Keine Cookies.** Die Sitzung ist dasselbe JWT wie bei der Passwortanmeldung,
gespeichert im `localStorage`.
* **Kein Refresh-Token verwenden.** Entra stellt zwar eines aus, das CRM legt es
nicht ab; die Sitzungsdauer regelt allein `JWT_EXPIRES_IN`.
* **Keine Abmeldung bei Microsoft.** *Abmelden* beendet die CRM-Sitzung; das
Microsoft-Konto bleibt im Browser angemeldet.
## Störungssuche
| Symptom | Ursache |
| --- | --- |
| Kein Microsoft-Knopf auf der Anmeldemaske | `MS_SSO_ENABLED` ist nicht `true` oder eine Variable fehlt - Reiter *Einstellungen → Anmeldung* zeigt welche |
| `AADSTS50011: redirect_uri mismatch` | `MS_SSO_REDIRECT_URI` und der Eintrag in Entra weichen ab (Schrägstrich, `http`/`https`, Gross-/Kleinschreibung) |
| `AADSTS7000215: Invalid client secret` | `MS_CLIENT_SECRET` abgelaufen oder falsch - betrifft dann auch das Postfach |
| „Für … gibt es kein CRM-Konto" | kein Benutzer mit dieser E-Mail-Adresse; in der Nutzerverwaltung anlegen |
| „Die Anmeldung ist abgelaufen" | älter als 10 Minuten, oder das Backend wurde zwischendurch neu gestartet (der offene Vorgang liegt im Prozessspeicher) |
| „Anmeldung kommt aus einem fremden Verzeichnis" | Antwort gehört zu einem anderen Tenant - `MS_TENANT_ID` prüfen |
| Profilfoto fehlt | kein Foto in M365, Bild grösser als 200 KB, oder `ProfilePhoto.Read.All` ohne Zustimmung |
| „Microsoft hat dieses Secret abgelehnt" | Wert falsch kopiert oder das Geheimnis in Entra bereits gelöscht - es wurde nichts gespeichert |
| „nicht entschlüsselbar" im Reiter *Anmeldung* | `JWT_SECRET` oder `CRM_ENCRYPTION_KEY` wurde gewechselt; Secret einmal neu eintragen |
| Hinweisband erscheint nicht | kein Ablaufdatum hinterlegt, oder das Konto ist kein Administrator |
## Quellen im Code
| Datei | Inhalt |
| --- | --- |
| `server/src/sso/config.js` | Variablen, `SSO_CONFIGURED`, Domainprüfung |
| `server/src/sso/flow.js` | PKCE, Authorize-URL, Token-Tausch, `/me`, Foto, Einmal-Tickets |
| `server/src/routes/auth.js` | die fünf Endpunkte und die Zuordnung `findSsoUser()` |
| `server/src/schema.sql` | Spalte `crm_users.entra_oid` samt Unique-Index |
| `src/lib/auth.tsx` | Ticket einlösen, Adresszeile aufräumen, `ssoError` |
| `src/components/LoginScreen.tsx` | Knopf „Mit Microsoft anmelden" |
| `src/components/settings/SsoSettings.tsx` | Reiter *Anmeldung* samt Pflege des Secrets |
| `server/src/msSecret.js` | Secret verschlüsseln, Ablauf berechnen, bei Microsoft prüfen |
| `src/App.tsx` | Hinweisband für Administratoren vor dem Ablauf |
| `server/test/sso.test.mjs` | Prüfungen ohne Netz und Datenbank |