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:
co-authored by
Claude Opus 5
parent
2d84bac91d
commit
419e57b55d
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user