feat(auth): Anmeldung ueber Microsoft Entra (SSO)

OpenID Connect mit Authorization Code Flow und PKCE, vollstaendig
serverseitig - der Browser sieht weder Tenant- noch Client-ID. Genutzt wird
dieselbe App-Registrierung wie die Postfachanbindung, also kein zweites
Secret; delegiert statt app-only.

Nach dem Token-Tausch beweist ein Aufruf von /me bei Graph, dass das
Zugriffstoken gilt, und liefert die Objekt-ID. Sie landet in der neuen Spalte
crm_users.entra_oid und ist danach die stabile Verknuepfung - die E-Mail-
Adresse eines Menschen kann sich aendern, die Objekt-ID nicht.

Bewusst so entschieden:
  - Es werden keine Konten angelegt. Wer kein CRM-Konto mit derselben Adresse
    hat, wird abgewiesen; Zugriff vergibt weiter die Nutzerverwaltung.
  - Die Anmeldung mit Benutzername und Passwort bleibt fuer alle bestehen -
    Notzugang, falls Entra oder das Secret ausfaellt.
  - Das CRM-Token kommt als Einmal-Ticket zurueck, nicht in der Adresszeile:
    die Rueckleitung ist ein GET und stuende sonst in Browserverlauf und
    Apache-Log.
  - Keine Signaturpruefung des ID-Tokens und damit keine JWKS-Bibliothek. Es
    kommt ueber TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7); geprueft
    werden aud, tid, nonce und exp, und der Graph-Aufruf belegt den Rest.

Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
bisher.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-21 08:14:23 +02:00
co-authored by Claude Opus 5
parent 2d84bac91d
commit 419e57b55d
23 changed files with 1188 additions and 19 deletions
+6
View File
@@ -86,6 +86,12 @@ Anmeldeschirm (`setUnauthorizedHandler` in `src/lib/api.ts`). Das Token wird bei
jeder Anfrage neu gegen die Datenbank geprüft - ein deaktivierter Benutzer ist jeder Anfrage neu gegen die Datenbank geprüft - ein deaktivierter Benutzer ist
sofort draußen, ohne auf den Ablauf des Tokens zu warten. sofort draußen, ohne auf den Ablauf des Tokens zu warten.
Der zweite Weg zu demselben Token ist die Anmeldung über Microsoft Entra: statt
`bcrypt.compare` steht dort der OIDC-Ablauf mit Microsoft, danach geht es
identisch weiter - dasselbe JWT, dieselbe Prüfung bei jeder Anfrage. Wer kein
CRM-Konto hat, kommt auch mit gültigem Microsoft-Konto nicht hinein.
Einzelheiten: [13 Anmeldung über Microsoft](13-anmeldung-sso.md).
## Rollen ## Rollen
```mermaid ```mermaid
+1
View File
@@ -31,6 +31,7 @@ erDiagram
int id PK int id PK
varchar username UK varchar username UK
varchar email UK varchar email UK
varchar entra_oid UK "Objekt-ID des Microsoft-Kontos, NULL = nie per SSO angemeldet"
varchar name varchar name
enum role "admin | user" enum role "admin | user"
varchar color "Terminfarbe, NULL = aus Id abgeleitet" varchar color "Terminfarbe, NULL = aus Id abgeleitet"
+4 -3
View File
@@ -33,8 +33,9 @@ flowchart TD
E --> E1["Nutzerverwaltung (admin)"] E --> E1["Nutzerverwaltung (admin)"]
E --> E2["Mein Konto"] E --> E2["Mein Konto"]
E --> E3["Postfach (admin)"] E --> E3["Postfach (admin)"]
E --> E4["Scanner (admin)"] E --> E4["Anmeldung (admin)"]
E --> E5["Whisper"] E --> E5["Scanner (admin)"]
E --> E6["Whisper"]
``` ```
*Postfach* und *Kontakt-Vorschläge* sind Reiter **innerhalb** der Ansicht *Postfach* und *Kontakt-Vorschläge* sind Reiter **innerhalb** der Ansicht
@@ -127,7 +128,7 @@ src/
Vorbereich "Aus Mails" mit Dublettenabgleich Vorbereich "Aus Mails" mit Dublettenabgleich
mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschläge mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschläge
scanner/ Dashboard, Unpassend, Suchagenten, Börsen-Konten scanner/ Dashboard, Unpassend, Suchagenten, Börsen-Konten
settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Scanner, Whisper settings/ Reiter: Nutzerverwaltung, Konto, Postfach, Anmeldung, Scanner, Whisper
whisper/ Einrichtung der Spracherkennung whisper/ Einrichtung der Spracherkennung
ui/ Modal, Avatare, Diktatfeld ui/ Modal, Avatare, Diktatfeld
``` ```
+7
View File
@@ -63,6 +63,9 @@ mitten im Scrapen beenden.
| `MS_MAIL_FOLDER` | Ordner, Standard `inbox` | | `MS_MAIL_FOLDER` | Ordner, Standard `inbox` |
| `MAIL_SYNC` | Postfachabgleich, Standard an, nur `false` schaltet ab | | `MAIL_SYNC` | Postfachabgleich, Standard an, nur `false` schaltet ab |
| `MAIL_MAX_PAGES`, `MAIL_MAX_BODY_CHARS` | Bremsen für Erstabgleich und Textgröße | | `MAIL_MAX_PAGES`, `MAIL_MAX_BODY_CHARS` | Bremsen für Erstabgleich und Textgröße |
| `MS_SSO_ENABLED` | Anmeldung über Microsoft, Standard aus, nur `true` schaltet ein |
| `MS_SSO_REDIRECT_URI` | Umleitungsadresse, zeichengenau wie in Entra eingetragen |
| `MS_SSO_ALLOWED_DOMAINS` | Positivliste von E-Mail-Domains, leer = keine zusätzliche Prüfung |
Die Scanner-Datenbank wird beim Start automatisch angelegt Die Scanner-Datenbank wird beim Start automatisch angelegt
(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale (`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale
@@ -77,6 +80,10 @@ Exchange Online: [11 Postfachanbindung](11-postfach.md).
> `.env` muss der Dienst neu starten, ein Speichern in der Oberfläche genügt > `.env` muss der Dienst neu starten, ein Speichern in der Oberfläche genügt
> nicht - dort liegen nur Intervall, Schalter und Zeitraum (`crm_settings`). > nicht - dort liegen nur Intervall, Schalter und Zeitraum (`crm_settings`).
Die `MS_SSO_*`-Werte gehören zur Anmeldung über Microsoft und nutzen dieselbe
App-Registrierung wie das Postfach. Ohne sie bleibt es bei Benutzername und
Passwort. Einrichtung: [13 Anmeldung über Microsoft](13-anmeldung-sso.md).
## Playwright ## Playwright
`playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build `playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build
+10 -2
View File
@@ -21,12 +21,20 @@ Ein CRM für die Projektvermittlung. Es beantwortet vier Fragen:
`https://crm.perinco.de` (alternativ `https://pcrm.perinco.de`). Anmelden geht mit **Benutzername oder E-Mail** plus `https://crm.perinco.de` (alternativ `https://pcrm.perinco.de`). Anmelden geht mit **Benutzername oder E-Mail** plus
Passwort. Die Sitzung hält 12 Stunden, danach fragt die Anwendung erneut. Passwort. Die Sitzung hält 12 Stunden, danach fragt die Anwendung erneut.
Steht auf der Maske **Mit Microsoft anmelden**, geht es auch ohne zweites
Passwort: ein Klick, die gewohnte Microsoft-Anmeldung, zurück im CRM. Das setzt
voraus, dass es hier schon ein Konto mit derselben E-Mail-Adresse gibt - sonst
kommt der Hinweis, sich an einen Administrator zu wenden. Wer noch kein
Profilfoto im CRM hat, bekommt bei der ersten Anmeldung das aus Microsoft 365
übernommen. **Abmelden** beendet die CRM-Sitzung, nicht die bei Microsoft.
Einzelheiten: [13 Anmeldung über Microsoft](13-anmeldung-sso.md).
Sagt die Meldung „Benutzername oder Passwort ist falsch", bleibt bewusst offen, Sagt die Meldung „Benutzername oder Passwort ist falsch", bleibt bewusst offen,
welches von beidem nicht stimmte - so kann über die Anmeldemaske niemand welches von beidem nicht stimmte - so kann über die Anmeldemaske niemand
Benutzernamen durchprobieren. Benutzernamen durchprobieren.
Es gibt zwei Rollen: **Administrator** sieht zusätzlich die Nutzerverwaltung und Es gibt zwei Rollen: **Administrator** sieht zusätzlich die Nutzerverwaltung,
die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. die Anmelde- und die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
## Sich zurechtfinden ## Sich zurechtfinden
+1
View File
@@ -197,6 +197,7 @@ Diese Punkte haben real Zeit gekostet:
| **`sequelize.sync({ alter: true })`** | Legt bei jedem Start Unique-Indizes neu an und sprengt irgendwann MySQLs Grenze von 64 Keys. Nur `sync()` benutzen. | | **`sequelize.sync({ alter: true })`** | Legt bei jedem Start Unique-Indizes neu an und sprengt irgendwann MySQLs Grenze von 64 Keys. Nur `sync()` benutzen. |
| **Kaskaden beim Löschen** | Einen Suchagenten löschen löscht alle seine Treffer mit. Inaktiv setzen ist meist gemeint. | | **Kaskaden beim Löschen** | Einen Suchagenten löschen löscht alle seine Treffer mit. Inaktiv setzen ist meist gemeint. |
| **Doppelte Scanner-Läufe** | Scheduler und *Jetzt scannen* teilen eine Sperre. Ohne sie zahlt man jede KI-Bewertung zweimal. | | **Doppelte Scanner-Läufe** | Scheduler und *Jetzt scannen* teilen eine Sperre. Ohne sie zahlt man jede KI-Bewertung zweimal. |
| **Anmeldetoken in der Adresszeile** | Die Rückleitung von Microsoft ist ein `GET`. Stünde das CRM-Token darin, läge es in Browserverlauf und Apache-Log. Deshalb kommt ein Einmal-Ticket zurück, das der Browser per `POST` tauscht - siehe [13 Anmeldung über Microsoft](13-anmeldung-sso.md). |
| **`allow-same-origin` bei `srcDoc`** | Ein so eingebettetes Dokument erbt die Origin der Anwendung und kann `localStorage` lesen - also das Anmeldetoken. Fremdes HTML gehört in `sandbox=""` ohne jedes Flag. Der Preis: die Inhaltshöhe ist nicht auslesbar, deshalb hat der Mailrahmen eine feste Höhe. | | **`allow-same-origin` bei `srcDoc`** | Ein so eingebettetes Dokument erbt die Origin der Anwendung und kann `localStorage` lesen - also das Anmeldetoken. Fremdes HTML gehört in `sandbox=""` ohne jedes Flag. Der Preis: die Inhaltshöhe ist nicht auslesbar, deshalb hat der Mailrahmen eine feste Höhe. |
| **`/me` unter App-only** | Es gibt keinen Benutzer im Token, also auch kein `/me` - jeder Graph-Aufruf muss `/users/{postfach}/…` lauten. Genau daran ist das Altsystem gescheitert, still, weil die Antwort nicht geprüft wurde. | | **`/me` unter App-only** | Es gibt keinen Benutzer im Token, also auch kein `/me` - jeder Graph-Aufruf muss `/users/{postfach}/…` lauten. Genau daran ist das Altsystem gescheitert, still, weil die Antwort nicht geprüft wurde. |
| **UTC neben Wandzeit** | `crm_mail_messages.received_at` steht in UTC (so liefert Graph es), `crm_events.starts_at` in lokaler Wandzeit. Beim Lesen im Frontend `parseMailDate()` benutzen, sonst ist die Anzeige um Stunden verschoben. | | **UTC neben Wandzeit** | `crm_mail_messages.received_at` steht in UTC (so liefert Graph es), `crm_events.starts_at` in lokaler Wandzeit. Beim Lesen im Frontend `parseMailDate()` benutzen, sonst ist die Anzeige um Stunden verschoben. |
+31 -2
View File
@@ -94,7 +94,7 @@ Alle unter `router.use(requireAuth)`.
| Router | Endpunkte | Rechte | | Router | Endpunkte | Rechte |
| --- | --- | --- | | --- | --- | --- |
| `auth.js` | `POST /login`, `GET /me`, `PATCH /me`, `POST /change-password` | `/login` offen, Rest angemeldet | | `auth.js` | `POST /login`, `GET /me`, `PATCH /me`, `POST /change-password`; SSO: `GET /sso/status`, `GET /sso/start`, `GET /sso/callback`, `POST /sso/ticket`, `GET /sso/config` | `/login` und die vier offenen SSO-Endpunkte ohne Anmeldung, `/sso/config` Admin, Rest angemeldet |
| `users.js` | `GET /directory`; danach `requireAdmin`: `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | `/directory` für alle, Rest Admin | | `users.js` | `GET /directory`; danach `requireAdmin`: `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | `/directory` für alle, Rest Admin |
| `leads.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`; Vorbereich: `GET /mail-pool/sources`, `POST /mail-pool/promote`, `POST /:id/promote` | jeder Angemeldete | | `leads.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id`; Vorbereich: `GET /mail-pool/sources`, `POST /mail-pool/promote`, `POST /:id/promote` | jeder Angemeldete |
| `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete | | `connections.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | jeder Angemeldete |
@@ -200,6 +200,35 @@ Eigenheiten, die man kennen muss:
* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf * Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
kommt sofort mit `skipped` zurück, statt parallel zu laufen. kommt sofort mit `skipped` zurück, statt parallel zu laufen.
### `server/src/sso/`
Anmeldung über Microsoft Entra, fachlich in
[13 Anmeldung über Microsoft](13-anmeldung-sso.md). Dieselbe App-Registrierung
wie das Postfach, aber delegiert statt app-only - deshalb ein eigenes Modul und
kein Import aus `mail/`: die Anmeldung läuft auch, wenn die Postfachanbindung
aus ist.
| Datei | Inhalt |
| --- | --- |
| `config.js` | `.env` lesen, `SSO_CONFIGURED`, `missingSsoConfig()`, `domainAllowed()` |
| `flow.js` | `beginLogin()` (PKCE, state, nonce), `takePendingLogin()`, `exchangeCode()`, `verifyIdToken()`, `fetchProfile()`, `fetchPhoto()`, `createTicket()` / `redeemTicket()` |
Eigenheiten, die man kennen muss:
* Offene Anmeldungen und Tickets liegen **im Prozessspeicher**, nicht in der
Datenbank: Lebensdauer 10 Minuten bzw. 60 Sekunden, und das Backend läuft als
einzelne Instanz. Ein Neustart verwirft höchstens eine gerade laufende
Anmeldung.
* `state` und Ticket gelten je genau einmal - beide werden beim Lesen entfernt.
* `verifyIdToken()` prüft `aud`, `tid`, `nonce` und `exp`, aber nicht die
Signatur. Das Token kommt über TLS direkt vom Token-Endpunkt (OIDC Core
3.1.3.7), und der anschliessende `/me`-Aufruf belegt ohnehin, dass das
Zugriffstoken gilt. Das spart eine JWKS-Bibliothek.
* `fetchPhoto()` schluckt jeden Fehler und gibt `null` zurück - wer kein
Profilfoto hat, soll sich trotzdem anmelden können.
* `findSsoUser()` in `routes/auth.js` legt **nie** ein Konto an und sucht über
die E-Mail-Adresse nur, solange `entra_oid IS NULL` ist.
### `server/src/routes/scanner.js` ### `server/src/routes/scanner.js`
Die portierte Scanner-API unter `/api/scanner`, vollständig in Die portierte Scanner-API unter `/api/scanner`, vollständig in
@@ -294,7 +323,7 @@ Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
| Datei | Hält | Eigenheit | | Datei | Hält | Eigenheit |
| --- | --- | --- | | --- | --- | --- |
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prüft beim Start das gespeicherte Token gegen `/auth/me` | | `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser`, `ssoError` | prüft beim Start das gespeicherte Token gegen `/auth/me`; kommt der Browser von Microsoft zurück, hat `?sso_ticket=` Vorrang - es wird eingelöst und die Adresszeile per `history.replaceState()` geleert |
| `theme.tsx` | Farbwelt (perinco/Pixel Art/Outdoor/Cyberpunk/Rainy/Snow, Hintergrundgrafiken in `public/themes/`; bei Rainy hängt er zusätzlich `components/RainyWeather.tsx` mit Regen und Wetterleuchten ein, bei Snow `components/SnowWeather.tsx` mit Schneefall, Schneedecke und Räumfahrzeug, bei Aura `components/AuraWeather.tsx` mit Sonnenstrahlen) und Modus (hell/dunkel/System) | Beides gehört dem Benutzer und steht in `crm_users`; `localStorage` ist nur der Zwischenspeicher fürs Inline-Skript in `index.html`. Doppelklick auf den Modus-Schalter folgt wieder dem System | | `theme.tsx` | Farbwelt (perinco/Pixel Art/Outdoor/Cyberpunk/Rainy/Snow, Hintergrundgrafiken in `public/themes/`; bei Rainy hängt er zusätzlich `components/RainyWeather.tsx` mit Regen und Wetterleuchten ein, bei Snow `components/SnowWeather.tsx` mit Schneefall, Schneedecke und Räumfahrzeug, bei Aura `components/AuraWeather.tsx` mit Sonnenstrahlen) und Modus (hell/dunkel/System) | Beides gehört dem Benutzer und steht in `crm_users`; `localStorage` ist nur der Zwischenspeicher fürs Inline-Skript in `index.html`. Doppelklick auf den Modus-Schalter folgt wieder dem System |
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` öffnet den Kontakt direkt | | `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` öffnet den Kontakt direkt |
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker für die ganze App | | `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker für die ganze App |
+5
View File
@@ -51,6 +51,11 @@ Genau **eine** Berechtigung, **ein** Zustimmungsklick, **eine**
Zugriffsrichtlinie. Kein Schreibrecht, kein Redirect-URI, kein interaktiver Zugriffsrichtlinie. Kein Schreibrecht, kein Redirect-URI, kein interaktiver
Login. Login.
> Dieselbe App-Registrierung trägt seit dem 21.09.2026 zusätzlich die
> [Anmeldung über Microsoft](13-anmeldung-sso.md). Die braucht einen
> Redirect-URI und delegierte Berechtigungen - beides betrifft nur den
> Anmeldeweg, die hier beschriebene App-only-Anbindung bleibt unberührt.
1. **App-Registrierung** in Entra (Name z. B. `perinco CRM`), Kontotyp „nur 1. **App-Registrierung** in Entra (Name z. B. `perinco CRM`), Kontotyp „nur
dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und
Verzeichnis-ID notieren. Verzeichnis-ID notieren.
+193
View File
@@ -0,0 +1,193 @@
# 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 12 h |
## 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 |
Scheitert etwas, landet der Browser auf `/?sso_error=<Meldung>`; die
Anmeldemaske zeigt den Text im gewohnten roten Kasten.
## 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 |
| `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | ja | dieselben Werte wie beim Postfach |
> 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 |
## 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* in den Einstellungen |
| `server/test/sso.test.mjs` | Prüfungen ohne Netz und Datenbank |
+1
View File
@@ -22,6 +22,7 @@ keine Vorkenntnisse voraus.
| [10 Glossar](10-glossar.md) | Agent, Match, Lead, Opportunity, Status- und Portalwerte | | [10 Glossar](10-glossar.md) | Agent, Match, Lead, Opportunity, Status- und Portalwerte |
| [11 Postfachanbindung](11-postfach.md) | crm@perinco.de über Microsoft Graph, Kontakte aus Mails | | [11 Postfachanbindung](11-postfach.md) | crm@perinco.de über Microsoft Graph, Kontakte aus Mails |
| [12 Globale Suche](12-suche.md) | Volltext und erweiterte Suche im Kopf der Anwendung | | [12 Globale Suche](12-suche.md) | Volltext und erweiterte Suche im Kopf der Anwendung |
| [13 Anmeldung über Microsoft](13-anmeldung-sso.md) | SSO mit Entra, Zuordnung der Konten, Einrichtung |
Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im
[README des Projekts](../README.md). [README des Projekts](../README.md).
+26
View File
@@ -94,3 +94,29 @@ SCANNER_SCHEDULER_OFFSET=0
# Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus den Einstellungen. # Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus den Einstellungen.
# OPENAI_API_KEY= # OPENAI_API_KEY=
# ---------------------------------------------------------------------------
# Anmeldung ueber Microsoft Entra (SSO)
# ---------------------------------------------------------------------------
# Nutzt dieselbe App-Registrierung wie die Postfachanbindung - also dieselben
# MS_TENANT_ID / MS_CLIENT_ID / MS_CLIENT_SECRET, kein zweites Secret.
#
# In Entra zusaetzlich noetig:
# - Authentifizierung > Plattform hinzufuegen > Web
# Umleitungs-URI: der Wert von MS_SSO_REDIRECT_URI, zeichengenau
# Implizite Genehmigung: beide Haken aus
# - Delegierte Berechtigungen mit Administratorzustimmung:
# openid, profile, User.Read (Foto zusaetzlich: ProfilePhoto.Read.All)
#
# Es werden keine Konten angelegt: wer sich per Microsoft anmeldet, braucht ein
# bestehendes CRM-Konto mit derselben E-Mail-Adresse. Die Anmeldung mit
# Benutzername und Passwort bleibt parallel bestehen.
#
# Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
# bisher.
MS_SSO_ENABLED=false
MS_SSO_REDIRECT_URI=https://crm.perinco.de/api/auth/sso/callback
# Optionale Einschraenkung auf E-Mail-Domains, kommagetrennt. Leer = keine
# zusaetzliche Pruefung; Tenant und vorhandenes CRM-Konto begrenzen ohnehin.
MS_SSO_ALLOWED_DOMAINS=perinco.de
+1 -1
View File
@@ -12,7 +12,7 @@
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"migrate": "node src/migrate.js", "migrate": "node src/migrate.js",
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs" "test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && node test/sso.test.mjs"
}, },
"dependencies": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
+175 -1
View File
@@ -1,13 +1,31 @@
import express from 'express'; import express from 'express';
import bcrypt from 'bcryptjs'; import bcrypt from 'bcryptjs';
import { pool } from '../db.js'; import { pool } from '../db.js';
import { signToken, requireAuth } from '../middleware/auth.js'; import { signToken, requireAuth, requireAdmin } from '../middleware/auth.js';
import { import {
normalizeAvatar, normalizeAvatar,
normalizeColor, normalizeColor,
normalizeTheme, normalizeTheme,
normalizeThemeMode, normalizeThemeMode,
} from '../userFields.js'; } from '../userFields.js';
import {
ALLOWED_DOMAINS,
REDIRECT_URI,
SSO_CONFIGURED,
domainAllowed,
missingSsoConfig,
} from '../sso/config.js';
import {
SsoError,
beginLogin,
createTicket,
exchangeCode,
fetchPhoto,
fetchProfile,
redeemTicket,
takePendingLogin,
verifyIdToken,
} from '../sso/flow.js';
const router = express.Router(); const router = express.Router();
@@ -135,5 +153,161 @@ router.post('/change-password', requireAuth, async (req, res, next) => {
} }
}); });
/* ---------------------------------------------------------------------------
* Anmeldung ueber Microsoft Entra (OpenID Connect)
*
* Der Browser sieht von der Anbindung nur drei Endpunkte: /sso/status fuer den
* Knopf, /sso/start fuer die Umleitung und /sso/ticket zum Abholen des
* CRM-Tokens. Tenant, Client-ID, Secret und die Antwort von Microsoft bleiben
* beim Backend.
*
* Es werden keine Konten angelegt: wer sich anmeldet, braucht ein bestehendes
* crm_users-Konto mit derselben E-Mail-Adresse. Wer Zugriff bekommt, entscheidet
* damit weiterhin allein die Nutzerverwaltung.
* ------------------------------------------------------------------------- */
/** Basisadresse der Oberflaeche - dieselbe Herkunft wie der Redirect-URI. */
function appOrigin() {
try {
return new URL(REDIRECT_URI).origin;
} catch {
return '';
}
}
/** Zurueck zur Anmeldemaske mit einer Meldung, die das Frontend anzeigt. */
function redirectWithError(res, message) {
const target = `${appOrigin()}/?sso_error=${encodeURIComponent(message)}`;
res.redirect(302, target);
}
// GET /api/auth/sso/status - oeffentlich, damit die Anmeldemaske weiss, ob sie
// den Microsoft-Knopf zeigen soll. Verraet nur ja/nein.
router.get('/sso/status', (req, res) => {
res.json({ enabled: SSO_CONFIGURED });
});
// GET /api/auth/sso/config - Adminsicht fuer die Einstellungen.
router.get('/sso/config', requireAuth, requireAdmin, (req, res) => {
res.json({
enabled: SSO_CONFIGURED,
missing: missingSsoConfig(),
redirect_uri: REDIRECT_URI || null,
allowed_domains: ALLOWED_DOMAINS,
});
});
// GET /api/auth/sso/start - Beginn der Anmeldung, leitet zu Microsoft weiter.
router.get('/sso/start', (req, res) => {
if (!SSO_CONFIGURED) {
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
}
res.redirect(302, beginLogin());
});
// GET /api/auth/sso/callback - Rueckleitung von Microsoft.
router.get('/sso/callback', async (req, res, next) => {
if (!SSO_CONFIGURED) {
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
}
// Abbruch oder Fehler auf der Microsoft-Seite (z.B. Zustimmung verweigert).
if (req.query.error) {
const detail = String(req.query.error_description || req.query.error).split('\n')[0];
return redirectWithError(res, `Microsoft hat die Anmeldung abgelehnt: ${detail}`);
}
const pending = takePendingLogin(String(req.query.state || ''));
if (!pending) {
return redirectWithError(res, 'Die Anmeldung ist abgelaufen. Bitte erneut versuchen.');
}
const code = String(req.query.code || '');
if (!code) return redirectWithError(res, 'Microsoft hat keinen Anmeldecode geschickt.');
try {
const tokens = await exchangeCode(code, pending.verifier);
verifyIdToken(tokens.id_token, pending.nonce);
const profile = await fetchProfile(tokens.access_token);
if (!profile.email) {
return redirectWithError(res, 'Das Microsoft-Konto hat keine E-Mail-Adresse.');
}
if (!domainAllowed(profile.email)) {
return redirectWithError(res, 'Diese E-Mail-Domain ist für das CRM nicht freigegeben.');
}
const user = await findSsoUser(profile);
if (!user) {
return redirectWithError(
res,
`Für ${profile.email} gibt es kein CRM-Konto. Bitte einen Administrator fragen.`
);
}
if (!user.is_active) {
return redirectWithError(res, 'Dieses Benutzerkonto ist deaktiviert.');
}
// Foto nur einmal holen: wer selbst eines gesetzt hat, behaelt es.
if (!user.avatar) {
const photo = await fetchPhoto(tokens.access_token);
if (photo) {
await pool.query('UPDATE crm_users SET avatar = ? WHERE id = ?', [photo, user.id]);
user.avatar = photo;
}
}
const ticket = createTicket({ token: signToken(user), user: publicUser(user) });
res.redirect(302, `${appOrigin()}/?sso_ticket=${encodeURIComponent(ticket)}`);
} catch (err) {
if (err instanceof SsoError) {
console.warn('[SSO] Anmeldung fehlgeschlagen:', err.message);
return redirectWithError(res, err.message);
}
next(err);
}
});
/**
* Sucht das CRM-Konto zum angemeldeten Microsoft-Konto.
*
* Zuerst ueber die Objekt-ID: sie bleibt gleich, auch wenn jemand heiratet und
* die Adresse wechselt. Beim ersten Mal gibt es sie noch nicht - dann greift
* die E-Mail-Adresse, und die Objekt-ID wird festgeschrieben. Konten, die schon
* einer anderen Objekt-ID gehoeren, werden dabei nicht angefasst.
*/
async function findSsoUser(profile) {
const columns = `id, username, email, name, role, color, avatar, theme, theme_mode,
is_active, created_at, updated_at`;
const [byOid] = await pool.query(
`SELECT ${columns} FROM crm_users WHERE entra_oid = ? LIMIT 1`,
[profile.oid]
);
if (byOid[0]) return { ...byOid[0], is_active: Boolean(byOid[0].is_active) };
const candidates = [profile.email, profile.upn].filter(Boolean);
const [byMail] = await pool.query(
`SELECT ${columns} FROM crm_users
WHERE entra_oid IS NULL AND email IN (?) LIMIT 1`,
[candidates]
);
const user = byMail[0];
if (!user) return null;
await pool.query('UPDATE crm_users SET entra_oid = ? WHERE id = ?', [profile.oid, user.id]);
return { ...user, is_active: Boolean(user.is_active) };
}
// POST /api/auth/sso/ticket { ticket }
// Loest das Einmal-Ticket gegen das CRM-Token ein.
router.post('/sso/ticket', (req, res) => {
const payload = redeemTicket(String(req.body?.ticket || ''));
if (!payload) {
return res.status(400).json({ error: 'Die Anmeldung ist abgelaufen. Bitte erneut versuchen.' });
}
res.json(payload);
});
export default router; export default router;
export { publicUser }; export { publicUser };
+8
View File
@@ -34,6 +34,14 @@ ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color;
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme VARCHAR(32) NULL AFTER avatar; ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme VARCHAR(32) NULL AFTER avatar;
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme_mode VARCHAR(16) NULL AFTER theme; ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme_mode VARCHAR(16) NULL AFTER theme;
-- Objekt-ID des Microsoft-Kontos (Entra), gesetzt bei der ersten Anmeldung per
-- SSO. Sie ist die stabile Zuordnung: die E-Mail-Adresse eines Menschen kann
-- sich aendern, die Objekt-ID nicht. NULL = noch nie per Microsoft angemeldet;
-- dann greift der Abgleich ueber die Adresse. Unique, damit nicht zwei
-- CRM-Konten an demselben Microsoft-Konto haengen.
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS entra_oid VARCHAR(64) NULL AFTER email;
ALTER TABLE crm_users ADD UNIQUE INDEX IF NOT EXISTS uq_crm_users_entra_oid (entra_oid);
CREATE TABLE IF NOT EXISTS crm_leads ( CREATE TABLE IF NOT EXISTS crm_leads (
id INT UNSIGNED NOT NULL AUTO_INCREMENT, id INT UNSIGNED NOT NULL AUTO_INCREMENT,
salutation VARCHAR(32) NULL, salutation VARCHAR(32) NULL,
+70
View File
@@ -0,0 +1,70 @@
/*
* Konfiguration der Anmeldung ueber Microsoft Entra (OpenID Connect).
*
* Es ist dieselbe App-Registrierung wie bei der Postfachanbindung, aber ein
* anderer Zugriffsweg: das Postfach laeuft app-only (Client Credentials), die
* Anmeldung delegiert im Namen des Benutzers (Authorization Code + PKCE).
* Deshalb werden die Werte hier eigenstaendig gelesen und nicht aus mail/
* importiert - die Anmeldung soll auch dann funktionieren, wenn die
* Postfachanbindung abgeschaltet ist.
*
* Zugangsdaten bleiben serverseitig. Der Browser bekommt weder Tenant- noch
* Client-ID zu sehen; er kennt nur /api/auth/sso/start.
*/
export const TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
export const CLIENT_ID = (process.env.MS_CLIENT_ID || '').trim();
const CLIENT_SECRET = (process.env.MS_CLIENT_SECRET || '').trim();
/** Hauptschalter. Ohne ausdrueckliches "true" bleibt die Anmeldung per Microsoft aus. */
const ENABLED = process.env.MS_SSO_ENABLED === 'true';
/**
* Muss in Entra unter "Authentifizierung" als Plattform "Web" eingetragen sein -
* zeichengenau, sonst antwortet Entra mit AADSTS50011.
*/
export const REDIRECT_URI = (process.env.MS_SSO_REDIRECT_URI || '').trim();
/**
* Zusaetzliche Einschraenkung auf E-Mail-Domains. Leer = keine Pruefung; der
* Tenant und das vorhandene CRM-Konto begrenzen den Zugang ohnehin.
*/
export const ALLOWED_DOMAINS = String(process.env.MS_SSO_ALLOWED_DOMAINS || '')
.split(',')
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
.filter(Boolean);
export const AUTHORITY = `https://login.microsoftonline.com/${TENANT_ID}`;
export const GRAPH_ROOT = 'https://graph.microsoft.com/v1.0';
/** Angefragte delegierte Rechte. Alle vier sind in Entra bereits zugestimmt. */
export const SCOPES = ['openid', 'profile', 'email', 'offline_access', 'User.Read'];
/** Ohne diese Werte ist die Anmeldung nicht betriebsbereit. */
export const SSO_CONFIGURED = Boolean(ENABLED && TENANT_ID && CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
export function clientSecret() {
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
return CLIENT_SECRET;
}
/**
* Was zum Betrieb fehlt - fuer eine brauchbare Meldung in den Einstellungen,
* ohne Werte zu verraten.
*/
export function missingSsoConfig() {
const missing = [];
if (!ENABLED) missing.push('MS_SSO_ENABLED');
if (!TENANT_ID) missing.push('MS_TENANT_ID');
if (!CLIENT_ID) missing.push('MS_CLIENT_ID');
if (!CLIENT_SECRET) missing.push('MS_CLIENT_SECRET');
if (!REDIRECT_URI) missing.push('MS_SSO_REDIRECT_URI');
return missing;
}
/** Erlaubt die Domain-Liste diese Adresse? Leere Liste heisst: alles erlaubt. */
export function domainAllowed(email) {
if (ALLOWED_DOMAINS.length === 0) return true;
const domain = String(email || '').toLowerCase().split('@')[1] || '';
return ALLOWED_DOMAINS.includes(domain);
}
+248
View File
@@ -0,0 +1,248 @@
import crypto from 'crypto';
import {
AUTHORITY,
CLIENT_ID,
GRAPH_ROOT,
REDIRECT_URI,
SCOPES,
TENANT_ID,
clientSecret,
} from './config.js';
/*
* Authorization Code Flow mit PKCE, vollstaendig serverseitig.
*
* Ablauf:
* 1. beginLogin() - state, nonce und PKCE-Verifier merken, Browser zu Entra
* 2. Entra - Anmeldung beim Benutzer, Rueckleitung mit code + state
* 3. exchangeCode()- code gegen Token tauschen (Backchannel mit Client-Secret)
* 4. fetchProfile()- /me bei Graph; das ist der Identitaetsnachweis
*
* Warum der Graph-Aufruf und nicht nur das ID-Token: das ID-Token kommt ueber
* TLS direkt vom Token-Endpunkt und ist damit vertrauenswuerdig (OIDC Core
* 3.1.3.7 erlaubt in diesem Fall den Verzicht auf die Signaturpruefung), aber
* /me beweist zusaetzlich, dass das Zugriffstoken wirklich gilt, und liefert
* die Objekt-ID, an der das CRM-Konto haengt.
*/
const REQUEST_TIMEOUT_MS = 20_000;
/** Wie lange eine angefangene Anmeldung gueltig bleibt. */
const STATE_TTL_MS = 10 * 60 * 1000;
/** Wie lange das Ticket gilt, mit dem das Frontend das CRM-Token abholt. */
const TICKET_TTL_MS = 60 * 1000;
/*
* Beide Speicher liegen im Prozess, nicht in der Datenbank: es sind Werte mit
* Sekunden- bis Minutenlebensdauer, und das Backend laeuft als einzelne
* Instanz. Ein Neustart verwirft hoechstens gerade laufende Anmeldungen - der
* Benutzer klickt dann noch einmal.
*/
const pendingLogins = new Map();
const tickets = new Map();
function prune(store, ttl) {
const now = Date.now();
for (const [key, entry] of store) {
if (now - entry.createdAt > ttl) store.delete(key);
}
}
const base64url = (buffer) => buffer.toString('base64url');
const randomToken = () => base64url(crypto.randomBytes(32));
export class SsoError extends Error {
constructor(message, { code = null } = {}) {
super(message);
this.name = 'SsoError';
this.code = code;
}
}
/**
* Beginnt eine Anmeldung und liefert die URL, auf die der Browser umgeleitet
* wird. prompt=select_account, damit an einem geteilten Rechner nicht
* stillschweigend das zuletzt benutzte Konto genommen wird.
*/
export function beginLogin() {
prune(pendingLogins, STATE_TTL_MS);
const state = randomToken();
const nonce = randomToken();
const verifier = randomToken();
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
pendingLogins.set(state, { nonce, verifier, createdAt: Date.now() });
const params = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: REDIRECT_URI,
response_mode: 'query',
scope: SCOPES.join(' '),
state,
nonce,
code_challenge: challenge,
code_challenge_method: 'S256',
prompt: 'select_account',
});
return `${AUTHORITY}/oauth2/v2.0/authorize?${params.toString()}`;
}
/** Holt den gemerkten Vorgang und verbraucht ihn - jeder state gilt genau einmal. */
export function takePendingLogin(state) {
prune(pendingLogins, STATE_TTL_MS);
if (!state) return null;
const entry = pendingLogins.get(state);
if (!entry) return null;
pendingLogins.delete(state);
return entry;
}
async function postToken(body) {
let response;
try {
response = await fetch(`${AUTHORITY}/oauth2/v2.0/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
throw new SsoError(`Microsoft ist nicht erreichbar: ${err.message}`);
}
const data = await response.json().catch(() => null);
if (!response.ok || !data?.access_token) {
const detail = data?.error_description || data?.error || `HTTP ${response.status}`;
throw new SsoError(String(detail).split('\n')[0], { code: data?.error ?? null });
}
return data;
}
/** Tauscht den Code gegen Tokens. Der Verifier weist uns als Starter des Vorgangs aus. */
export function exchangeCode(code, verifier) {
return postToken({
client_id: CLIENT_ID,
client_secret: clientSecret(),
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
scope: SCOPES.join(' '),
});
}
/** Nutzlast eines JWT ohne Signaturpruefung - siehe Begruendung oben im Modul. */
function decodePayload(token) {
const part = String(token || '').split('.')[1];
if (!part) return null;
try {
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
} catch {
return null;
}
}
/**
* Prueft das ID-Token gegen den erwarteten Tenant, die eigene Client-ID, die
* Laufzeit und die zu diesem Vorgang gehoerende nonce. Damit ist eine
* untergeschobene Antwort aus einem fremden Verzeichnis ausgeschlossen.
*/
export function verifyIdToken(idToken, expectedNonce) {
const claims = decodePayload(idToken);
if (!claims) throw new SsoError('Antwort von Microsoft war unlesbar.');
if (claims.aud !== CLIENT_ID) throw new SsoError('Antwort gehört zu einer anderen Anwendung.');
if (claims.tid !== TENANT_ID) throw new SsoError('Anmeldung kommt aus einem fremden Verzeichnis.');
if (claims.nonce !== expectedNonce) throw new SsoError('Antwort passt nicht zur Anfrage.');
const now = Math.floor(Date.now() / 1000);
// 120 s Spielraum fuer auseinanderlaufende Uhren.
if (typeof claims.exp === 'number' && claims.exp + 120 < now) {
throw new SsoError('Antwort von Microsoft war bereits abgelaufen.');
}
return claims;
}
async function graphGet(path, accessToken, { accept = 'application/json' } = {}) {
try {
return await fetch(`${GRAPH_ROOT}${path}`, {
headers: { Authorization: `Bearer ${accessToken}`, Accept: accept },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
} catch (err) {
throw new SsoError(`Microsoft Graph ist nicht erreichbar: ${err.message}`);
}
}
/** Das angemeldete Konto: Objekt-ID, Anzeigename, Adresse. */
export async function fetchProfile(accessToken) {
const select = 'id,displayName,givenName,surname,mail,userPrincipalName';
const response = await graphGet(`/me?$select=${select}`, accessToken);
if (!response.ok) {
throw new SsoError(`Profil konnte nicht gelesen werden (HTTP ${response.status}).`);
}
const data = await response.json();
return {
oid: String(data.id || ''),
name: String(data.displayName || '').trim(),
email: String(data.mail || data.userPrincipalName || '')
.trim()
.toLowerCase(),
upn: String(data.userPrincipalName || '')
.trim()
.toLowerCase(),
};
}
/**
* Profilfoto in 96x96 als Data-URL, oder null. Fehler sind hier nie fatal: wer
* kein Foto hinterlegt hat, bekommt 404, und ohne Foto laesst sich trotzdem
* anmelden.
*/
export async function fetchPhoto(accessToken) {
try {
const response = await graphGet('/me/photos/96x96/$value', accessToken, { accept: 'image/jpeg' });
if (!response.ok) return null;
const type = (response.headers.get('content-type') || 'image/jpeg').split(';')[0];
if (!type.startsWith('image/')) return null;
const buffer = Buffer.from(await response.arrayBuffer());
// Sicherheitsnetz gegen unerwartet grosse Bilder - avatar ist zwar LONGTEXT,
// soll aber keine halbe Megabyte pro Benutzer aufnehmen.
if (buffer.length === 0 || buffer.length > 200_000) return null;
return `data:${type};base64,${buffer.toString('base64')}`;
} catch {
return null;
}
}
/**
* Legt ein Einmal-Ticket an. Das CRM-Token wandert damit nicht als
* Adressparameter durch Browserverlauf und Server-Logs - der Browser holt es
* mit einem POST ab, und das Ticket verfaellt beim ersten Gebrauch.
*/
export function createTicket(payload) {
prune(tickets, TICKET_TTL_MS);
const ticket = randomToken();
tickets.set(ticket, { payload, createdAt: Date.now() });
return ticket;
}
export function redeemTicket(ticket) {
prune(tickets, TICKET_TTL_MS);
if (!ticket) return null;
const entry = tickets.get(ticket);
if (!entry) return null;
tickets.delete(ticket);
return entry.payload;
}
/** Nur fuer Tests - leert beide Speicher. */
export function resetSsoState() {
pendingLogins.clear();
tickets.clear();
}
+133
View File
@@ -0,0 +1,133 @@
/*
* Prueft die Anmeldung ueber Microsoft Entra, soweit sie ohne Netz und ohne
* Datenbank pruefbar ist: Konfiguration, Aufbau der Authorize-URL, Einmaligkeit
* von state und Ticket sowie die Abwehr eines untergeschobenen ID-Tokens.
*
* Die Umgebungsvariablen muessen vor dem Import stehen - sso/config.js liest
* sie beim Laden des Moduls, genau wie im Betrieb beim Start des Backends.
*/
import { readFileSync } from 'node:fs';
process.env.MS_SSO_ENABLED = 'true';
process.env.MS_TENANT_ID = 'tenant-fuer-den-test';
process.env.MS_CLIENT_ID = 'client-fuer-den-test';
process.env.MS_CLIENT_SECRET = 'geheim-fuer-den-test';
process.env.MS_SSO_REDIRECT_URI = 'https://crm.perinco.de/api/auth/sso/callback';
process.env.MS_SSO_ALLOWED_DOMAINS = 'perinco.de';
const { SSO_CONFIGURED, domainAllowed, missingSsoConfig } = await import('../src/sso/config.js');
const {
SsoError,
beginLogin,
createTicket,
redeemTicket,
resetSsoState,
takePendingLogin,
verifyIdToken,
} = await import('../src/sso/flow.js');
let failures = 0;
function check(name, condition, detail = '') {
if (condition) {
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
} else {
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
failures += 1;
}
}
function throws(name, fn) {
try {
fn();
check(name, false, 'kein Fehler geworfen');
} catch (err) {
check(name, err instanceof SsoError, err instanceof SsoError ? err.message : String(err));
}
}
/** Baut ein ID-Token, wie Entra es liefert - Signatur ist hier ohne Belang. */
function idToken(claims) {
const part = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
return `${part({ alg: 'RS256' })}.${part(claims)}.signatur`;
}
const validClaims = {
aud: 'client-fuer-den-test',
tid: 'tenant-fuer-den-test',
nonce: 'nonce-1',
exp: Math.floor(Date.now() / 1000) + 3600,
};
console.log('\nAnmeldung ueber Microsoft (SSO)');
console.log('\n Konfiguration');
check('vollstaendige Konfiguration gilt als betriebsbereit', SSO_CONFIGURED === true);
check('nichts fehlt', missingSsoConfig().length === 0, missingSsoConfig().join(', '));
check('eigene Domain erlaubt', domainAllowed('sascha.wolf@perinco.de') === true);
check('fremde Domain abgewiesen', domainAllowed('jemand@example.com') === false);
check('Adresse ohne Domain abgewiesen', domainAllowed('kaputt') === false);
console.log('\n Start der Anmeldung');
resetSsoState();
const url = new URL(beginLogin());
const params = url.searchParams;
check('Ziel ist der Tenant', url.origin === 'https://login.microsoftonline.com');
check('Pfad enthaelt die Tenant-ID', url.pathname.includes('tenant-fuer-den-test'), url.pathname);
check('Code Flow', params.get('response_type') === 'code');
check('PKCE mit S256', params.get('code_challenge_method') === 'S256');
check('Code-Challenge gesetzt', (params.get('code_challenge') || '').length >= 43);
check('Umleitungsadresse unveraendert', params.get('redirect_uri') === process.env.MS_SSO_REDIRECT_URI);
check('state und nonce gesetzt', Boolean(params.get('state')) && Boolean(params.get('nonce')));
check('Kontoauswahl erzwungen', params.get('prompt') === 'select_account');
check('kein Secret in der URL', !url.toString().includes('geheim-fuer-den-test'));
console.log('\n state gilt genau einmal');
const state = params.get('state');
const pending = takePendingLogin(state);
check('Vorgang gefunden', Boolean(pending?.verifier && pending?.nonce));
check('nonce passt zur URL', pending?.nonce === params.get('nonce'));
check('zweiter Versuch mit demselben state scheitert', takePendingLogin(state) === null);
check('unbekannter state scheitert', takePendingLogin('erfunden') === null);
console.log('\n Pruefung des ID-Tokens');
check(
'gueltiges Token wird angenommen',
verifyIdToken(idToken(validClaims), 'nonce-1').aud === 'client-fuer-den-test'
);
throws('fremdes Verzeichnis abgewiesen', () =>
verifyIdToken(idToken({ ...validClaims, tid: 'fremder-tenant' }), 'nonce-1')
);
throws('fremde Anwendung abgewiesen', () =>
verifyIdToken(idToken({ ...validClaims, aud: 'andere-app' }), 'nonce-1')
);
throws('falsche nonce abgewiesen', () => verifyIdToken(idToken(validClaims), 'nonce-2'));
throws('abgelaufenes Token abgewiesen', () =>
verifyIdToken(idToken({ ...validClaims, exp: Math.floor(Date.now() / 1000) - 3600 }), 'nonce-1')
);
throws('unlesbares Token abgewiesen', () => verifyIdToken('kein-jwt', 'nonce-1'));
console.log('\n Einmal-Ticket');
const ticket = createTicket({ token: 'crm-token', user: { id: 1 } });
check('Ticket ist kein Klartext des Tokens', !ticket.includes('crm-token'));
check('Einloesen liefert die Nutzlast', redeemTicket(ticket)?.token === 'crm-token');
check('zweites Einloesen scheitert', redeemTicket(ticket) === null);
check('erfundenes Ticket scheitert', redeemTicket('erfunden') === null);
console.log('\n Konten werden nicht angelegt');
const routes = readFileSync(new URL('../src/routes/auth.js', import.meta.url), 'utf8');
check(
'routes/auth.js legt keinen Benutzer an',
!/INSERT\s+INTO\s+crm_users/i.test(routes),
'die Anmeldung setzt ein bestehendes Konto voraus'
);
check(
'Zuordnung ueber entra_oid vorhanden',
routes.includes('entra_oid = ?'),
'stabile Verknuepfung statt nur E-Mail'
);
resetSsoState();
console.log(failures === 0 ? '\nAlle Pruefungen bestanden.\n' : `\n${failures} Pruefung(en) fehlgeschlagen.\n`);
process.exit(failures === 0 ? 0 : 1);
+57 -5
View File
@@ -1,5 +1,16 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { SSO_START_URL, ssoApi } from '../lib/api';
/** Microsoft-Logo - die vier Quadrate, in den Originalfarben. */
const MicrosoftMark: React.FC = () => (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 23 23" aria-hidden="true">
<path fill="#f25022" d="M1 1h10v10H1z" />
<path fill="#7fba00" d="M12 1h10v10H12z" />
<path fill="#00a4ef" d="M1 12h10v10H1z" />
<path fill="#ffb900" d="M12 12h10v10H12z" />
</svg>
);
/** /**
* Knoten des dekorativen Netzwerks. Jeder Knoten driftet auf einer eigenen * Knoten des dekorativen Netzwerks. Jeder Knoten driftet auf einer eigenen
@@ -109,15 +120,34 @@ const NetworkPattern: React.FC = () => {
}; };
export const LoginScreen: React.FC = () => { export const LoginScreen: React.FC = () => {
const { login } = useAuth(); const { login, ssoError, clearSsoError } = useAuth();
const [identifier, setIdentifier] = useState(''); const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [ssoEnabled, setSsoEnabled] = useState(false);
// Der Microsoft-Knopf erscheint nur, wenn die Anbindung eingerichtet ist -
// ein Knopf, der auf eine Fehlermeldung fuehrt, waere schlimmer als keiner.
useEffect(() => {
let cancelled = false;
ssoApi
.status()
.then(({ enabled }) => {
if (!cancelled) setSsoEnabled(enabled);
})
.catch(() => {
// Ist der Status nicht erreichbar, bleibt es beim Passwort-Login.
});
return () => {
cancelled = true;
};
}, []);
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(null); setError(null);
clearSsoError();
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await login(identifier, password); await login(identifier, password);
@@ -152,10 +182,30 @@ export const LoginScreen: React.FC = () => {
<h2 className="text-2xl text-ink-900">Anmelden</h2> <h2 className="text-2xl text-ink-900">Anmelden</h2>
<p className="mt-2 text-sm text-ink-500"> <p className="mt-2 text-sm text-ink-500">
Melde dich mit deinem Benutzernamen oder deiner E-Mail-Adresse an. {ssoEnabled
? 'Melde dich mit deinem Microsoft-Konto an oder nutze Benutzername und Passwort.'
: 'Melde dich mit deinem Benutzernamen oder deiner E-Mail-Adresse an.'}
</p> </p>
<form onSubmit={handleSubmit} className="mt-8 space-y-5"> {ssoEnabled && (
<>
<a
href={SSO_START_URL}
className="mt-8 flex w-full items-center justify-center gap-3 rounded-xl border border-ink-200 bg-surface px-4 py-3 text-sm font-bold text-ink-800 shadow-card transition hover:border-brand-500 hover:bg-brand-50"
>
<MicrosoftMark />
Mit Microsoft anmelden
</a>
<div className="my-6 flex items-center gap-3">
<span className="h-px flex-1 bg-ink-100" />
<span className="text-xs font-bold uppercase tracking-wider text-ink-400">oder</span>
<span className="h-px flex-1 bg-ink-100" />
</div>
</>
)}
<form onSubmit={handleSubmit} className={ssoEnabled ? 'space-y-5' : 'mt-8 space-y-5'}>
<div> <div>
<label htmlFor="identifier" className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-ink-500"> <label htmlFor="identifier" className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-ink-500">
Benutzername oder E-Mail Benutzername oder E-Mail
@@ -187,7 +237,9 @@ export const LoginScreen: React.FC = () => {
/> />
</div> </div>
{error && ( {/* Fehler der Passwortanmeldung und der Rueckleitung von Microsoft
teilen sich diesen Kasten - es kann immer nur einer aktuell sein. */}
{(error || ssoError) && (
<div className="flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700"> <div className="flex items-start gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
<svg className="mt-0.5 h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="mt-0.5 h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path <path
@@ -197,7 +249,7 @@ export const LoginScreen: React.FC = () => {
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/> />
</svg> </svg>
<span>{error}</span> <span>{error || ssoError}</span>
</div> </div>
)} )}
+3
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { UserManagement } from './UserManagement'; import { UserManagement } from './UserManagement';
import { MailSettings } from './MailSettings'; import { MailSettings } from './MailSettings';
import { SsoSettings } from './SsoSettings';
import { AcquisitionSettings } from './AcquisitionSettings'; import { AcquisitionSettings } from './AcquisitionSettings';
import { ScannerSettings } from './ScannerSettings'; import { ScannerSettings } from './ScannerSettings';
import { ColorPicker } from './ColorPicker'; import { ColorPicker } from './ColorPicker';
@@ -17,6 +18,7 @@ const TABS: { id: SettingsTab; label: string }[] = [
{ id: 'account', label: 'Mein Konto' }, { id: 'account', label: 'Mein Konto' },
{ id: 'akquise', label: 'Akquise' }, { id: 'akquise', label: 'Akquise' },
{ id: 'mail', label: 'Postfach' }, { id: 'mail', label: 'Postfach' },
{ id: 'sso', label: 'Anmeldung' },
{ id: 'scanner', label: 'Scanner' }, { id: 'scanner', label: 'Scanner' },
{ id: 'whisper', label: 'Whisper' }, { id: 'whisper', label: 'Whisper' },
]; ];
@@ -223,6 +225,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab })
{tab === 'account' && <AccountTab />} {tab === 'account' && <AccountTab />}
{tab === 'akquise' && <AcquisitionSettings />} {tab === 'akquise' && <AcquisitionSettings />}
{tab === 'mail' && isAdmin && <MailSettings />} {tab === 'mail' && isAdmin && <MailSettings />}
{tab === 'sso' && isAdmin && <SsoSettings />}
{tab === 'scanner' && isAdmin && <ScannerSettings />} {tab === 'scanner' && isAdmin && <ScannerSettings />}
{tab === 'whisper' && <WhisperSettings />} {tab === 'whisper' && <WhisperSettings />}
</div> </div>
+110
View File
@@ -0,0 +1,110 @@
import React, { useEffect, useState } from 'react';
import { ssoApi, type SsoConfig } from '../../lib/api';
import { useToast } from '../Toast';
/*
* Anmeldung über Microsoft Entra - Adminsicht.
*
* Hier lässt sich nichts umstellen, und das ist Absicht: Tenant, Client-ID,
* Secret und Umleitungsadresse stehen in server/.env und tauchen damit weder in
* der Oberfläche noch in einem Datenbank-Dump auf. Diese Seite zeigt nur, ob
* die Anbindung steht, und nennt die Werte, die in Entra eingetragen sein
* müssen.
*/
const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => (
<div className="flex justify-between gap-3 text-sm">
<dt className="shrink-0 text-ink-400">{label}</dt>
<dd className="truncate text-right font-semibold text-ink-800">{value}</dd>
</div>
);
export const SsoSettings: React.FC = () => {
const { addToast } = useToast();
const [config, setConfig] = useState<SsoConfig | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
let cancelled = false;
ssoApi
.config()
.then((data) => {
if (!cancelled) setConfig(data);
})
.catch((err) => {
if (!cancelled) {
addToast(err instanceof Error ? err.message : 'Status nicht erreichbar.', 'error');
}
})
.finally(() => {
if (!cancelled) setIsLoading(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (isLoading) return <p className="text-sm text-ink-400">Wird geladen...</p>;
if (!config) return null;
return (
<div className="grid gap-6 lg:grid-cols-2">
<section
className={`rounded-2xl border border-l-4 border-ink-200 bg-surface p-5 ${
config.enabled ? 'border-l-green-500' : 'border-l-amber-500'
}`}
>
<h3 className="text-base text-ink-900">Status</h3>
<p className="mt-1 text-xs text-ink-400">
{config.enabled
? 'Auf der Anmeldemaske erscheint der Knopf „Mit Microsoft anmelden“.'
: 'Die Anmeldung über Microsoft ist aus. Die Anmeldemaske zeigt nur Benutzername und Passwort.'}
</p>
<dl className="mt-4 space-y-2">
<Fact label="Anbindung" value={config.enabled ? 'aktiv' : 'inaktiv'} />
<Fact label="Umleitungsadresse" value={config.redirect_uri || '—'} />
<Fact
label="Erlaubte Domains"
value={config.allowed_domains.length ? config.allowed_domains.join(', ') : 'alle'}
/>
</dl>
{config.missing.length > 0 && (
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800">
Fehlt in <code>server/.env</code>: {config.missing.join(', ')}
<span className="mt-1 block text-xs font-normal">
Die Werte werden beim Start gelesen - nach einer Änderung das Backend neu starten.
</span>
</div>
)}
</section>
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Wie es funktioniert</h3>
<ul className="mt-4 space-y-3 text-sm text-ink-600">
<li>
<span className="font-bold text-ink-800">Kein automatisches Anlegen.</span> Wer sich per
Microsoft anmeldet, braucht ein Konto in der Nutzerverwaltung mit derselben
E-Mail-Adresse. Fehlt es, wird die Anmeldung abgewiesen.
</li>
<li>
<span className="font-bold text-ink-800">Erste Anmeldung verknüpft.</span> Danach hängt
das Konto an der Objekt-ID des Microsoft-Kontos und überlebt einen Adresswechsel.
</li>
<li>
<span className="font-bold text-ink-800">Profilfoto.</span> Wer im CRM noch kein Foto
hat, bekommt bei der ersten Anmeldung das aus Microsoft 365 übernommen. Ein selbst
gesetztes Foto bleibt unangetastet.
</li>
<li>
<span className="font-bold text-ink-800">Passwort bleibt.</span> Die Anmeldung mit
Benutzername und Passwort funktioniert unverändert weiter - auch als Notzugang, falls
Entra einmal nicht erreichbar ist.
</li>
</ul>
</section>
</div>
);
};
+28
View File
@@ -110,6 +110,34 @@ export const authApi = {
}), }),
}; };
// ---------- Anmeldung über Microsoft (SSO) ----------
/**
* Ziel des Microsoft-Knopfes. Bewusst eine echte Umleitung des Browsers und
* kein fetch: die Anmeldung findet bei Microsoft statt, und Tenant- sowie
* Client-ID bleiben dabei im Backend.
*/
export const SSO_START_URL = '/api/auth/sso/start';
export interface SsoConfig {
enabled: boolean;
missing: string[];
redirect_uri: string | null;
allowed_domains: string[];
}
export const ssoApi = {
status: () => request<{ enabled: boolean }>('/auth/sso/status'),
/** Adminsicht fuer die Einstellungen. */
config: () => request<SsoConfig>('/auth/sso/config'),
/** Loest das Einmal-Ticket aus der Rueckleitung gegen das CRM-Token ein. */
redeemTicket: (ticket: string) =>
request<{ token: string; user: AppUser }>('/auth/sso/ticket', {
method: 'POST',
body: JSON.stringify({ ticket }),
}),
};
// ---------- Benutzerverwaltung (nur admin) ---------- // ---------- Benutzerverwaltung (nur admin) ----------
export interface UserInput { export interface UserInput {
+61 -3
View File
@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { AppUser } from '../types'; import type { AppUser } from '../types';
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler } from './api'; import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler, ssoApi } from './api';
interface AuthContextValue { interface AuthContextValue {
user: AppUser | null; user: AppUser | null;
@@ -10,6 +10,32 @@ interface AuthContextValue {
logout: () => void; logout: () => void;
/** Aktualisiert den lokalen Benutzer, z.B. nach Bearbeiten des eigenen Kontos. */ /** Aktualisiert den lokalen Benutzer, z.B. nach Bearbeiten des eigenen Kontos. */
updateUser: (user: AppUser) => void; updateUser: (user: AppUser) => void;
/** Meldung aus einer gescheiterten Microsoft-Anmeldung, sonst null. */
ssoError: string | null;
clearSsoError: () => void;
}
/**
* Liest die Parameter der Rueckleitung von /api/auth/sso/callback und raeumt
* die Adresszeile sofort wieder auf - weder Ticket noch Fehlertext sollen im
* Browserverlauf oder in einem weitergegebenen Link stehen bleiben.
*/
function takeSsoParams(): { ticket: string | null; error: string | null } {
const params = new URLSearchParams(window.location.search);
const ticket = params.get('sso_ticket');
const error = params.get('sso_error');
if (!ticket && !error) return { ticket: null, error: null };
params.delete('sso_ticket');
params.delete('sso_error');
const query = params.toString();
window.history.replaceState(
{},
'',
`${window.location.pathname}${query ? `?${query}` : ''}${window.location.hash}`
);
return { ticket, error };
} }
const AuthContext = createContext<AuthContextValue | null>(null); const AuthContext = createContext<AuthContextValue | null>(null);
@@ -17,6 +43,7 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AppUser | null>(null); const [user, setUser] = useState<AppUser | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [ssoError, setSsoError] = useState<string | null>(null);
const logout = useCallback(() => { const logout = useCallback(() => {
clearToken(); clearToken();
@@ -30,11 +57,37 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
return () => setUnauthorizedHandler(null); return () => setUnauthorizedHandler(null);
}, []); }, []);
// Beim Start pruefen, ob das gespeicherte Token noch gilt. // Beim Start pruefen, ob das gespeicherte Token noch gilt. Kommt der Browser
// gerade von Microsoft zurueck, hat das Ticket Vorrang: es gehoert zu der
// Anmeldung, die der Benutzer eben ausgeloest hat.
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
const restore = async () => { const restore = async () => {
const { ticket, error } = takeSsoParams();
if (error) {
setSsoError(error);
clearToken();
setIsLoading(false);
return;
}
if (ticket) {
try {
const { token, user: loggedIn } = await ssoApi.redeemTicket(ticket);
setToken(token);
if (!cancelled) setUser(loggedIn);
} catch (err) {
if (!cancelled) {
setSsoError(err instanceof Error ? err.message : 'Anmeldung fehlgeschlagen.');
}
} finally {
if (!cancelled) setIsLoading(false);
}
return;
}
if (!getToken()) { if (!getToken()) {
setIsLoading(false); setIsLoading(false);
return; return;
@@ -58,9 +111,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const login = useCallback(async (identifier: string, password: string) => { const login = useCallback(async (identifier: string, password: string) => {
const { token, user: loggedIn } = await authApi.login(identifier, password); const { token, user: loggedIn } = await authApi.login(identifier, password);
setToken(token); setToken(token);
setSsoError(null);
setUser(loggedIn); setUser(loggedIn);
}, []); }, []);
const clearSsoError = useCallback(() => setSsoError(null), []);
const value = useMemo<AuthContextValue>( const value = useMemo<AuthContextValue>(
() => ({ () => ({
user, user,
@@ -69,8 +125,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
login, login,
logout, logout,
updateUser: setUser, updateUser: setUser,
ssoError,
clearSsoError,
}), }),
[user, isLoading, login, logout] [user, isLoading, login, logout, ssoError, clearSsoError]
); );
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+9 -2
View File
@@ -24,9 +24,16 @@ export type ViewState =
* `account` und `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto * `account` und `whisper` stehen jedem offen - jeder pflegt sein eigenes Konto
* und richtet sein eigenes Diktat ein. * und richtet sein eigenes Diktat ein.
*/ */
export type SettingsTab = 'users' | 'account' | 'akquise' | 'mail' | 'scanner' | 'whisper'; export type SettingsTab =
| 'users'
| 'account'
| 'akquise'
| 'mail'
| 'sso'
| 'scanner'
| 'whisper';
export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'scanner']; export const ADMIN_SETTINGS_TABS: SettingsTab[] = ['users', 'mail', 'sso', 'scanner'];
/** /**
* Reiter des Projekt Scanners. "Scanner" selbst ist das Dashboard und damit die * Reiter des Projekt Scanners. "Scanner" selbst ist das Dashboard und damit die