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
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
```mermaid
+1
View File
@@ -31,6 +31,7 @@ erDiagram
int id PK
varchar username UK
varchar email UK
varchar entra_oid UK "Objekt-ID des Microsoft-Kontos, NULL = nie per SSO angemeldet"
varchar name
enum role "admin | user"
varchar color "Terminfarbe, NULL = aus Id abgeleitet"
+4 -3
View File
@@ -33,8 +33,9 @@ flowchart TD
E --> E1["Nutzerverwaltung (admin)"]
E --> E2["Mein Konto"]
E --> E3["Postfach (admin)"]
E --> E4["Scanner (admin)"]
E --> E5["Whisper"]
E --> E4["Anmeldung (admin)"]
E --> E5["Scanner (admin)"]
E --> E6["Whisper"]
```
*Postfach* und *Kontakt-Vorschläge* sind Reiter **innerhalb** der Ansicht
@@ -127,7 +128,7 @@ src/
Vorbereich "Aus Mails" mit Dublettenabgleich
mail/ Postfachliste, Detail, Zuordnung, Kontakt-Vorschläge
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
ui/ Modal, Avatare, Diktatfeld
```
+7
View File
@@ -63,6 +63,9 @@ mitten im Scrapen beenden.
| `MS_MAIL_FOLDER` | Ordner, Standard `inbox` |
| `MAIL_SYNC` | Postfachabgleich, Standard an, nur `false` schaltet ab |
| `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
(`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
> 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` 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
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,
welches von beidem nicht stimmte - so kann über die Anmeldemaske niemand
Benutzernamen durchprobieren.
Es gibt zwei Rollen: **Administrator** sieht zusätzlich die Nutzerverwaltung und
die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
Es gibt zwei Rollen: **Administrator** sieht zusätzlich die Nutzerverwaltung,
die Anmelde- und die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
## 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. |
| **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. |
| **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. |
| **`/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. |
+31 -2
View File
@@ -94,7 +94,7 @@ Alle unter `router.use(requireAuth)`.
| 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 |
| `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 |
@@ -200,6 +200,35 @@ Eigenheiten, die man kennen muss:
* Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
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`
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 |
| --- | --- | --- |
| `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 |
| `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 |
+5
View File
@@ -51,6 +51,11 @@ Genau **eine** Berechtigung, **ein** Zustimmungsklick, **eine**
Zugriffsrichtlinie. Kein Schreibrecht, kein Redirect-URI, kein interaktiver
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
dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und
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 |
| [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 |
| [13 Anmeldung über Microsoft](13-anmeldung-sso.md) | SSO mit Entra, Zuordnung der Konten, Einrichtung |
Die Designentscheidungen und die Messwerte zur Spracherkennung stehen im
[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.
# 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",
"dev": "node --watch src/server.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": {
"bcryptjs": "^2.4.3",
+175 -1
View File
@@ -1,13 +1,31 @@
import express from 'express';
import bcrypt from 'bcryptjs';
import { pool } from '../db.js';
import { signToken, requireAuth } from '../middleware/auth.js';
import { signToken, requireAuth, requireAdmin } from '../middleware/auth.js';
import {
normalizeAvatar,
normalizeColor,
normalizeTheme,
normalizeThemeMode,
} 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();
@@ -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 { 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_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 (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
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 { 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
@@ -109,15 +120,34 @@ const NetworkPattern: React.FC = () => {
};
export const LoginScreen: React.FC = () => {
const { login } = useAuth();
const { login, ssoError, clearSsoError } = useAuth();
const [identifier, setIdentifier] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
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) => {
event.preventDefault();
setError(null);
clearSsoError();
setIsSubmitting(true);
try {
await login(identifier, password);
@@ -152,10 +182,30 @@ export const LoginScreen: React.FC = () => {
<h2 className="text-2xl text-ink-900">Anmelden</h2>
<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>
<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>
<label htmlFor="identifier" className="mb-1.5 block text-xs font-bold uppercase tracking-wider text-ink-500">
Benutzername oder E-Mail
@@ -187,7 +237,9 @@ export const LoginScreen: React.FC = () => {
/>
</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">
<svg className="mt-0.5 h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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"
/>
</svg>
<span>{error}</span>
<span>{error || ssoError}</span>
</div>
)}
+3
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { UserManagement } from './UserManagement';
import { MailSettings } from './MailSettings';
import { SsoSettings } from './SsoSettings';
import { AcquisitionSettings } from './AcquisitionSettings';
import { ScannerSettings } from './ScannerSettings';
import { ColorPicker } from './ColorPicker';
@@ -17,6 +18,7 @@ const TABS: { id: SettingsTab; label: string }[] = [
{ id: 'account', label: 'Mein Konto' },
{ id: 'akquise', label: 'Akquise' },
{ id: 'mail', label: 'Postfach' },
{ id: 'sso', label: 'Anmeldung' },
{ id: 'scanner', label: 'Scanner' },
{ id: 'whisper', label: 'Whisper' },
];
@@ -223,6 +225,7 @@ export const Settings: React.FC<{ initialTab?: SettingsTab }> = ({ initialTab })
{tab === 'account' && <AccountTab />}
{tab === 'akquise' && <AcquisitionSettings />}
{tab === 'mail' && isAdmin && <MailSettings />}
{tab === 'sso' && isAdmin && <SsoSettings />}
{tab === 'scanner' && isAdmin && <ScannerSettings />}
{tab === 'whisper' && <WhisperSettings />}
</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) ----------
export interface UserInput {
+61 -3
View File
@@ -1,6 +1,6 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import type { AppUser } from '../types';
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler } from './api';
import { authApi, clearToken, getToken, setToken, setUnauthorizedHandler, ssoApi } from './api';
interface AuthContextValue {
user: AppUser | null;
@@ -10,6 +10,32 @@ interface AuthContextValue {
logout: () => void;
/** Aktualisiert den lokalen Benutzer, z.B. nach Bearbeiten des eigenen Kontos. */
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);
@@ -17,6 +43,7 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<AppUser | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [ssoError, setSsoError] = useState<string | null>(null);
const logout = useCallback(() => {
clearToken();
@@ -30,11 +57,37 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
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(() => {
let cancelled = false;
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()) {
setIsLoading(false);
return;
@@ -58,9 +111,12 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const login = useCallback(async (identifier: string, password: string) => {
const { token, user: loggedIn } = await authApi.login(identifier, password);
setToken(token);
setSsoError(null);
setUser(loggedIn);
}, []);
const clearSsoError = useCallback(() => setSsoError(null), []);
const value = useMemo<AuthContextValue>(
() => ({
user,
@@ -69,8 +125,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
login,
logout,
updateUser: setUser,
ssoError,
clearSsoError,
}),
[user, isLoading, login, logout]
[user, isLoading, login, logout, ssoError, clearSsoError]
);
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
* 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