diff --git a/docs/01-architektur.md b/docs/01-architektur.md index 426590e..44b93db 100644 --- a/docs/01-architektur.md +++ b/docs/01-architektur.md @@ -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 diff --git a/docs/02-datenmodell.md b/docs/02-datenmodell.md index 41b2bfd..cff223e 100644 --- a/docs/02-datenmodell.md +++ b/docs/02-datenmodell.md @@ -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" diff --git a/docs/05-frontend.md b/docs/05-frontend.md index 6d05cac..00dcf72 100644 --- a/docs/05-frontend.md +++ b/docs/05-frontend.md @@ -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 ``` diff --git a/docs/06-betrieb.md b/docs/06-betrieb.md index 34d0223..c8164f4 100644 --- a/docs/06-betrieb.md +++ b/docs/06-betrieb.md @@ -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 diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index fbfdf83..3f3b708 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -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 diff --git a/docs/08-anleitung-entwickler.md b/docs/08-anleitung-entwickler.md index 5d901d9..db798bd 100644 --- a/docs/08-anleitung-entwickler.md +++ b/docs/08-anleitung-entwickler.md @@ -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. | diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index a8aced6..3fbf223 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -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 | diff --git a/docs/11-postfach.md b/docs/11-postfach.md index 17baaed..e32fcb5 100644 --- a/docs/11-postfach.md +++ b/docs/11-postfach.md @@ -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. diff --git a/docs/13-anmeldung-sso.md b/docs/13-anmeldung-sso.md new file mode 100644 index 0000000..3db8dd1 --- /dev/null +++ b/docs/13-anmeldung-sso.md @@ -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,
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:
Objekt-ID, E-Mail"] --> B{"crm_users.entra_oid
= Objekt-ID?"} + B -->|ja| Z["anmelden"] + B -->|nein| C{"crm_users.email = Adresse
und entra_oid leer?"} + C -->|ja| D["entra_oid festschreiben"] --> Z + C -->|nein| E["abweisen:
kein CRM-Konto"] + Z --> F{"is_active?"} + F -->|nein| G["abweisen:
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=`; 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 | diff --git a/docs/README.md b/docs/README.md index 156c935..bb5d0a0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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). diff --git a/server/.env.example b/server/.env.example index be461b6..f380eb0 100644 --- a/server/.env.example +++ b/server/.env.example @@ -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 diff --git a/server/package.json b/server/package.json index 2895190..2408b5e 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index f2bf9cd..21e7da8 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -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 }; diff --git a/server/src/schema.sql b/server/src/schema.sql index 3661b64..f34f03d 100644 --- a/server/src/schema.sql +++ b/server/src/schema.sql @@ -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, diff --git a/server/src/sso/config.js b/server/src/sso/config.js new file mode 100644 index 0000000..442e505 --- /dev/null +++ b/server/src/sso/config.js @@ -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); +} diff --git a/server/src/sso/flow.js b/server/src/sso/flow.js new file mode 100644 index 0000000..47cda55 --- /dev/null +++ b/server/src/sso/flow.js @@ -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(); +} diff --git a/server/test/sso.test.mjs b/server/test/sso.test.mjs new file mode 100644 index 0000000..0f767e0 --- /dev/null +++ b/server/test/sso.test.mjs @@ -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); diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index ad78ef9..0b44190 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -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 = () => ( + +); /** * 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(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 = () => {

Anmelden

- 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.'}

-
+ {ssoEnabled && ( + <> + + + Mit Microsoft anmelden + + +
+ + oder + +
+ + )} + +
- {error && ( + {/* Fehler der Passwortanmeldung und der Rueckleitung von Microsoft + teilen sich diesen Kasten - es kann immer nur einer aktuell sein. */} + {(error || ssoError) && (
{ 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" /> - {error} + {error || ssoError}
)} diff --git a/src/components/settings/Settings.tsx b/src/components/settings/Settings.tsx index a70b740..77b71b6 100644 --- a/src/components/settings/Settings.tsx +++ b/src/components/settings/Settings.tsx @@ -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' && } {tab === 'akquise' && } {tab === 'mail' && isAdmin && } + {tab === 'sso' && isAdmin && } {tab === 'scanner' && isAdmin && } {tab === 'whisper' && } diff --git a/src/components/settings/SsoSettings.tsx b/src/components/settings/SsoSettings.tsx new file mode 100644 index 0000000..6685c56 --- /dev/null +++ b/src/components/settings/SsoSettings.tsx @@ -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 }) => ( +
+
{label}
+
{value}
+
+); + +export const SsoSettings: React.FC = () => { + const { addToast } = useToast(); + const [config, setConfig] = useState(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

Wird geladen...

; + if (!config) return null; + + return ( +
+
+

Status

+

+ {config.enabled + ? 'Auf der Anmeldemaske erscheint der Knopf „Mit Microsoft anmelden“.' + : 'Die Anmeldung über Microsoft ist aus. Die Anmeldemaske zeigt nur Benutzername und Passwort.'} +

+ +
+ + + +
+ + {config.missing.length > 0 && ( +
+ Fehlt in server/.env: {config.missing.join(', ')} + + Die Werte werden beim Start gelesen - nach einer Änderung das Backend neu starten. + +
+ )} +
+ +
+

Wie es funktioniert

+
    +
  • + Kein automatisches Anlegen. Wer sich per + Microsoft anmeldet, braucht ein Konto in der Nutzerverwaltung mit derselben + E-Mail-Adresse. Fehlt es, wird die Anmeldung abgewiesen. +
  • +
  • + Erste Anmeldung verknüpft. Danach hängt + das Konto an der Objekt-ID des Microsoft-Kontos und überlebt einen Adresswechsel. +
  • +
  • + Profilfoto. Wer im CRM noch kein Foto + hat, bekommt bei der ersten Anmeldung das aus Microsoft 365 übernommen. Ein selbst + gesetztes Foto bleibt unangetastet. +
  • +
  • + Passwort bleibt. Die Anmeldung mit + Benutzername und Passwort funktioniert unverändert weiter - auch als Notzugang, falls + Entra einmal nicht erreichbar ist. +
  • +
+
+
+ ); +}; diff --git a/src/lib/api.ts b/src/lib/api.ts index 42b0a87..ca6b3c5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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('/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 { diff --git a/src/lib/auth.tsx b/src/lib/auth.tsx index 18d96e2..c872710 100644 --- a/src/lib/auth.tsx +++ b/src/lib/auth.tsx @@ -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(null); @@ -17,6 +43,7 @@ const AuthContext = createContext(null); export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [ssoError, setSsoError] = useState(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( () => ({ 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 {children}; diff --git a/src/types.ts b/src/types.ts index bd5e202..979ab66 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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