From c8eaaa194729cb0bdef1187938c01f0c2cf0e39f Mon Sep 17 00:00:00 2001 From: Sascha Wolf Date: Mon, 21 Sep 2026 08:38:28 +0200 Subject: [PATCH] feat(auth): Client-Secret in den Einstellungen pflegen, Warnung vor Ablauf Das Secret der App-Registrierung laeuft in Entra ab. Bisher hiess das: jemand mit SSH-Zugang aendert die .env und startet den Dienst neu - und niemand sieht vorher, wann es soweit ist. Laeuft es aus, stehen Anmeldung UND Postfachabgleich still, denn beide haengen an derselben Registrierung. Neu unter Einstellungen > Anmeldung: Secret eintragen, Ablaufdatum hinterlegen, Restlaufzeit ablesen. Ab 30 Tagen vor dem Ablauf bekommen Administratoren ueber jeder Ansicht ein Hinweisband, das direkt dorthin fuehrt; nach dem Ablauf wird es rot. Der Hinweis reist mit /api/badges mit, das die App ohnehin bei jedem Laden abruft. Bewusst so entschieden: - Das Secret muss dafuer in die Datenbank, entgegen der bisherigen Regel "keine Geheimnisse in crm_settings". Es liegt deshalb AES-256-GCM- verschluesselt dort; der Schluessel kommt aus CRM_ENCRYPTION_KEY, ersatz- weise JWT_SECRET, und steht damit nie im selben Dump. - Vor dem Speichern holt das CRM mit dem eingegebenen Wert ein Token bei Microsoft. Ein Tippfehler wird gar nicht erst uebernommen. - Gelesen wird Datenbank vor .env; der .env-Wert bleibt Rueckfallebene, und "Hinterlegtes Secret verwerfen" schaltet dorthin zurueck. - Passt der Schluessel nicht mehr, meldet die Oberflaeche das ausdruecklich, statt still auf die .env zurueckzufallen - sonst sucht man den Fehler an der falschen Stelle. - Das Ablaufdatum wird von Hand eingetragen. Aus Entra lesen braeuchte Application.Read.All, die die App bewusst nicht hat. MAIL_CONFIGURED und SSO_CONFIGURED sind dadurch von Konstanten zu den Funktionen mailConfigured() und ssoConfigured() geworden - die Pruefung muss jetzt in die Datenbank sehen. Co-Authored-By: Claude Opus 5 (1M context) --- docs/06-betrieb.md | 6 + docs/07-anleitung-benutzer.md | 6 + docs/09-quellcode.md | 32 ++- docs/11-postfach.md | 5 +- docs/13-anmeldung-sso.md | 80 +++++++- server/.env.example | 9 + server/package.json | 2 +- server/src/mail/config.js | 22 +- server/src/mail/graph.js | 2 +- server/src/mail/scheduler.js | 4 +- server/src/mail/sync.js | 6 +- server/src/msSecret.js | 243 ++++++++++++++++++++++ server/src/routes/auth.js | 106 ++++++++-- server/src/routes/badges.js | 24 +++ server/src/routes/mail.js | 17 +- server/src/sso/config.js | 22 +- server/src/sso/flow.js | 4 +- server/test/msSecret.test.mjs | 76 +++++++ server/test/sso.test.mjs | 9 +- src/App.tsx | 36 ++++ src/components/settings/SsoSettings.tsx | 257 +++++++++++++++++++++++- src/lib/api.ts | 33 +++ 22 files changed, 937 insertions(+), 64 deletions(-) create mode 100644 server/src/msSecret.js create mode 100644 server/test/msSecret.test.mjs diff --git a/docs/06-betrieb.md b/docs/06-betrieb.md index c8164f4..08f3639 100644 --- a/docs/06-betrieb.md +++ b/docs/06-betrieb.md @@ -66,6 +66,7 @@ mitten im Scrapen beenden. | `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 | +| `CRM_ENCRYPTION_KEY` | verschlüsselt das in der Oberfläche hinterlegte Client-Secret; fehlt er, dient `JWT_SECRET` als Schlüssel | Die Scanner-Datenbank wird beim Start automatisch angelegt (`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale @@ -84,6 +85,11 @@ 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). +`MS_CLIENT_SECRET` ist seit dem 21.09.2026 nur noch die Rückfallebene: Vorrang +hat das Secret, das Administratoren unter *Einstellungen → Anmeldung* eintragen. +Es liegt verschlüsselt in `crm_settings`, wird bei jedem Bedarf frisch gelesen +und braucht deshalb **keinen** Neustart. + ## 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 3f3b708..b56323c 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -33,6 +33,12 @@ Sagt die Meldung „Benutzername oder Passwort ist falsch", bleibt bewusst offen welches von beidem nicht stimmte - so kann über die Anmeldemaske niemand Benutzernamen durchprobieren. +Erscheint über der Ansicht ein gelbes oder rotes Band zum *Microsoft-Client-Secret*, +siehst du es als Administrator: der Zugangsschlüssel zu Microsoft läuft ab. Ein +Klick darauf führt nach *Einstellungen → Anmeldung*, dort steht Schritt für +Schritt, wie er erneuert wird. Läuft er aus, stehen Microsoft-Anmeldung und +Postfachabgleich still. + Es gibt zwei Rollen: **Administrator** sieht zusätzlich die Nutzerverwaltung, die Anmelde- und die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 3fbf223..63d40ca 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -101,7 +101,7 @@ Alle unter `router.use(requireAuth)`. | `events.js` | `GET /`, `POST /`, `PATCH /:id`, `DELETE /:id` | anlegen jeder; ändern/löschen nur Ersteller oder Admin | | `opportunities.js` | `GET /?trash=true`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `POST /:id/restore`, `POST /:id/acquisition`, `POST /:id/copy`, `POST /trash/empty` | ändern, löschen und wiederherstellen nur Ersteller, Zuständiger oder Admin; Leeren jeder Angemeldete. `/:id/acquisition` nimmt eine Übernahme zurück: alle lebenden Opportunities mit derselben `acquisition_id` in den Papierkorb, beim Eintrag `converted_at`/`opportunity_id` leeren (Funddatum bleibt), in einer Transaktion. `/restore` hängt den Eintrag wieder an die Opportunity, wenn er frei ist. `/:id/copy` (`{ consultants, stage }`) legt je Berater eine Kopie an - Projektdaten, Ansprechpartner, Zuständiger und `acquisition_id` kommen mit, Interaktion und nächster Schritt nicht; jeder Angemeldete | | `acquisitions.js` | `GET /?view=eingang\|archiv\|papierkorb\|alle`, `POST /`, `PATCH /:id`, `DELETE /:id` (Papierkorb), `DELETE /:id/permanent`, `POST /trash/empty`, `POST /:id/opportunity`, `POST /:id/reopen`, `POST /:id/restore`; Auswahllisten: `GET/POST /options`, `PATCH/DELETE /options/:id` | Einträge ändern, löschen und zurückholen nur Ersteller oder Admin; Auswahllisten und Leeren jeder Angemeldete. Zwei verschiedene Rückwege, nicht zu verwechseln: `/reopen` holt aus dem **Archiv** in den Eingang (setzt `found_at` auf heute und leert `converted_at`/`opportunity_id` - sonst hält die Übernahme den Eintrag im Archiv fest), `/restore` holt aus dem **Papierkorb** zurück (leert nur `deleted_at`/`deleted_by`, das Funddatum bleibt). Beide antworten 409, wenn der Eintrag im falschen Zustand ist | -| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt | +| `badges.js` | `GET /` | Zahlen für die Seitenleiste: Akquise-Archiv und dessen Papierkorb, Papierkorb der Opportunities und der Mails, verworfene Scanner-Treffer. Der Scanner-Zähler ist `null`, wenn dessen Datenbank fehlt. Für Administratoren zusätzlich `ms_secret`, sobald das Client-Secret in weniger als 30 Tagen abläuft | | `acquisitionRules.js` (kein Router) | Fristen und die SQL-Bedingungen `IS_OPEN`, `IN_ARCHIVE`, `NOT_DUE`, `OPPORTUNITY_GONE`, `NOT_DELETED`, `IN_TRASH`. Geteilt von Liste, Zählern, Aufräumlauf und Menüzahl - zwei Kopien derselben Regel sind einmal auseinandergelaufen. Dazu `splitConsultants` / `joinConsultants` / `normalizeConsultants` für das Feld *Berater*, das mehrere Namen kommagetrennt hält (Gegenstück im Frontend: `src/types.ts`) | | `dashboard.js` | `GET /?from=&to=&bucket=tag\|monat\|quartal\|jahr` | drei Reihen über der Zeit plus Bewerbungen je Berater. Liest Akquise **und** `crm_acquisition_history`; Termine und Phasenlisten stehen bewusst nicht drin - die lädt die App ohnehin | | `mail.js` | `GET /status`, `/messages`, `/messages/:id`, `/candidates`; `POST /messages/:id/links`, `/messages/:id/restore`, `/candidates/:id/adopt`, `/candidates/:id/reject`, `/trash/empty`; `DELETE /links/:id`, `/messages/:id`; `requireAdmin`: `GET/POST /settings`, `POST /test`, `POST /sync` | lesen, zuordnen und löschen jeder; einrichten Admin | @@ -200,6 +200,36 @@ 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/msSecret.js` + +Das Client-Secret der Microsoft-App-Registrierung, wenn es nicht aus der `.env` +kommt, sondern in der Oberfläche hinterlegt wurde. + +| Export | Zweck | +| --- | --- | +| `effectiveSecret()` / `requireSecret()` | gültiges Secret, Datenbank vor `.env` | +| `hasSecret()` | für die Konfigurationsprüfung von Postfach und Anmeldung | +| `secretStatus()` | Quelle, Ablaufdatum, Resttage, Warnstufe - ohne das Secret | +| `saveSecret()` / `clearStoredSecret()` | schreiben und zurücknehmen | +| `verifySecret()` | Probe bei Microsoft, bevor gespeichert wird | +| `daysUntil()`, `expiryLevel()`, `normalizeExpiry()` | Ablaufrechnung, in `test/msSecret.test.mjs` geprüft | + +Eigenheiten, die man kennen muss: + +* Verschlüsselt wird mit AES-256-GCM, Schlüssel aus `CRM_ENCRYPTION_KEY` oder + ersatzweise `JWT_SECRET` (`scrypt`). Jeder Aufruf bekommt einen eigenen + Zufallsvektor, das Format trägt das Präfix `v1:`. +* Passt der Schlüssel nicht mehr, ist die Quelle `db-unlesbar` - und es wird + **nicht** still auf die `.env` zurückgefallen. +* Ist die Datenbank nicht erreichbar, gilt das dagegen als „nichts hinterlegt"; + dann greift die `.env`, damit der Postfachabgleich keine irreführende Meldung + liefert. +* Kein Zwischenspeicher: eine Änderung wirkt sofort, ohne Neustart. Das + Graph-Token wird beim Speichern verworfen, es gehört zum alten Secret. +* `MAIL_CONFIGURED` und `SSO_CONFIGURED` waren Konstanten und sind seitdem die + Funktionen `mailConfigured()` und `ssoConfigured()` - die Prüfung muss in die + Datenbank sehen. + ### `server/src/sso/` Anmeldung über Microsoft Entra, fachlich in diff --git a/docs/11-postfach.md b/docs/11-postfach.md index e32fcb5..aa22078 100644 --- a/docs/11-postfach.md +++ b/docs/11-postfach.md @@ -59,7 +59,10 @@ Login. 1. **App-Registrierung** in Entra (Name z. B. `perinco CRM`), Kontotyp „nur dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und Verzeichnis-ID notieren. -2. **Client-Geheimnis** anlegen und den Wert sofort kopieren. +2. **Client-Geheimnis** anlegen und den Wert sofort kopieren. Es ist dasselbe + Geheimnis wie bei der Anmeldung; erneuert wird es seit dem 21.09.2026 unter + *Einstellungen → Anmeldung*, siehe + [13 Anmeldung über Microsoft](13-anmeldung-sso.md#client-secret-pflegen). 3. **API-Berechtigung** Microsoft Graph → *Anwendungsberechtigungen* → `Mail.Read`, danach Administratorzustimmung erteilen. `Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext. diff --git a/docs/13-anmeldung-sso.md b/docs/13-anmeldung-sso.md index ed1c7cb..cc84a5b 100644 --- a/docs/13-anmeldung-sso.md +++ b/docs/13-anmeldung-sso.md @@ -100,11 +100,78 @@ antwortet dann mit 404, das ist eingeplant). | `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 | +| `GET` | `/api/auth/sso/config` | admin | Status, fehlende Variablen, Umleitungsadresse, Zustand des Secrets | +| `PUT` | `/api/auth/microsoft-secret` | admin | Secret und/oder Ablaufdatum setzen | +| `DELETE` | `/api/auth/microsoft-secret` | admin | hinterlegtes Secret verwerfen, `.env` gilt wieder | Scheitert etwas, landet der Browser auf `/?sso_error=`; die Anmeldemaske zeigt den Text im gewohnten roten Kasten. +## Client-Secret pflegen + +Das Secret der App-Registrierung läuft ab - in Entra sind 6 oder 24 Monate +üblich. Läuft es aus, stehen **Anmeldung und Postfachabgleich gleichzeitig** +still, denn beide hängen an derselben Registrierung. Damit dafür niemand auf den +Server muss, lässt es sich unter *Einstellungen → Anmeldung* eintragen. + +```mermaid +flowchart LR + A["Einstellungen →
Anmeldung"] -->|"PUT /microsoft-secret"| B["Probe bei Microsoft:
Client-Credentials-Token"] + B -->|abgelehnt| C["nichts gespeichert,
Meldung von Entra"] + B -->|angenommen| D["AES-256-GCM
verschlüsseln"] + D --> E[("crm_settings")] + E --> F["Graph-Token verwerfen -
der Zwischenspeicher
gehört zum alten Secret"] +``` + +**Reihenfolge beim Lesen:** erst `crm_settings`, dann `MS_CLIENT_SECRET` aus der +`.env`. Wer nichts einträgt, merkt von der Neuerung nichts; der `.env`-Wert +bleibt die Rückfallebene. *Hinterlegtes Secret verwerfen* schaltet bewusst +dorthin zurück. + +**Warum verschlüsselt:** ein Geheimnis im Klartext in der Datenbank stünde in +jedem Dump. Der Schlüssel kommt aus `CRM_ENCRYPTION_KEY`, ersatzweise aus +`JWT_SECRET` - beide stehen in der `.env` und damit nie im selben Dump. Wird +eine dieser Variablen gewechselt, lässt sich das gespeicherte Secret nicht mehr +lesen; die Oberfläche sagt das dann ausdrücklich, und es wird einmal neu +eingetragen. Ein stiller Rückfall auf die `.env` findet in diesem Fall **nicht** +statt - sonst sucht man den Fehler an der falschen Stelle. + +**Probe vor dem Speichern:** das CRM holt mit dem eingegebenen Wert ein +Client-Credentials-Token. Erst wenn Microsoft es ausstellt, wird gespeichert. Ein +Tippfehler legt so nichts lahm. + +### Warnung vor dem Ablauf + +Das Ablaufdatum wird von Hand eingetragen - es aus Entra zu lesen bräuchte die +Berechtigung `Application.Read.All`, die die App bewusst nicht hat. + +Ab **30 Tagen** vor dem Ablauf (`WARN_DAYS` in `msSecret.js`) erscheint für +Administratoren über jeder Ansicht ein Hinweisband, das direkt in diesen +Einstellungsreiter führt; nach dem Ablauf wird es rot. Der Hinweis reist mit +`GET /api/badges` mit, das die App ohnehin bei jedem Laden abruft - ein eigener +Endpunkt wäre eine zweite Anfrage für eine Zahl. Benutzer ohne Adminrolle +bekommen ihn nicht: sie können das Secret nicht erneuern. + +| Zustand | Bedeutung | +| --- | --- | +| `ok` | mehr als 30 Tage übrig | +| `warnung` | 0 bis 30 Tage übrig - Hinweisband in Gelb | +| `abgelaufen` | Datum liegt in der Vergangenheit - Hinweisband in Rot | +| `unbekannt` | kein Datum hinterlegt, keine Warnung möglich | + +### So wird erneuert + +1. Entra → *App-Registrierungen* → **perinco crm** → *Zertifikate & Geheimnisse* + → **Neuer geheimer Clientschlüssel**. +2. Wert sofort kopieren - Entra zeigt ihn nur einmal. +3. Im CRM unter *Einstellungen → Anmeldung* eintragen, Ablaufdatum übernehmen, + speichern. +4. Erst danach das alte Geheimnis in Entra löschen. + +Ein Neustart des Backends ist nicht nötig: das Secret wird bei jedem Bedarf +frisch gelesen, und das zwischengespeicherte Graph-Token wird beim Speichern +verworfen. + ## Einrichtung in Entra Die App-Registrierung **perinco crm** existiert bereits für die @@ -147,7 +214,9 @@ vertraulicher Client mit Secret. | `MS_SSO_ENABLED` | ja | nur `true` schaltet die Anmeldung ein | | `MS_SSO_REDIRECT_URI` | ja | exakt die in Entra eingetragene Adresse | | `MS_SSO_ALLOWED_DOMAINS` | nein | Positivliste von E-Mail-Domains, leer = keine zusätzliche Prüfung. Im Betrieb: `perinco.de,icon-experts.de` | -| `MS_TENANT_ID`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | ja | dieselben Werte wie beim Postfach | +| `MS_TENANT_ID`, `MS_CLIENT_ID` | ja | dieselben Werte wie beim Postfach | +| `MS_CLIENT_SECRET` | nein | Rückfallebene; Vorrang hat das in den Einstellungen hinterlegte Secret | +| `CRM_ENCRYPTION_KEY` | nein | verschlüsselt das hinterlegte Secret; ohne den Wert dient `JWT_SECRET` als Schlüssel | Nicht jedes CRM-Konto trägt eine `@perinco.de`-Adresse - `icon-experts.de` gehört dazu. Wer eine Domain vergisst, sperrt die betroffenen Leute vom @@ -182,6 +251,9 @@ Microsoft-Weg aus; die Anmeldung mit Passwort funktioniert für sie weiterhin. | „Die Anmeldung ist abgelaufen" | älter als 10 Minuten, oder das Backend wurde zwischendurch neu gestartet (der offene Vorgang liegt im Prozessspeicher) | | „Anmeldung kommt aus einem fremden Verzeichnis" | Antwort gehört zu einem anderen Tenant - `MS_TENANT_ID` prüfen | | Profilfoto fehlt | kein Foto in M365, Bild grösser als 200 KB, oder `ProfilePhoto.Read.All` ohne Zustimmung | +| „Microsoft hat dieses Secret abgelehnt" | Wert falsch kopiert oder das Geheimnis in Entra bereits gelöscht - es wurde nichts gespeichert | +| „nicht entschlüsselbar" im Reiter *Anmeldung* | `JWT_SECRET` oder `CRM_ENCRYPTION_KEY` wurde gewechselt; Secret einmal neu eintragen | +| Hinweisband erscheint nicht | kein Ablaufdatum hinterlegt, oder das Konto ist kein Administrator | ## Quellen im Code @@ -193,5 +265,7 @@ Microsoft-Weg aus; die Anmeldung mit Passwort funktioniert für sie weiterhin. | `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 | +| `src/components/settings/SsoSettings.tsx` | Reiter *Anmeldung* samt Pflege des Secrets | +| `server/src/msSecret.js` | Secret verschlüsseln, Ablauf berechnen, bei Microsoft prüfen | +| `src/App.tsx` | Hinweisband für Administratoren vor dem Ablauf | | `server/test/sso.test.mjs` | Prüfungen ohne Netz und Datenbank | diff --git a/server/.env.example b/server/.env.example index 2a93cdd..40590ef 100644 --- a/server/.env.example +++ b/server/.env.example @@ -121,3 +121,12 @@ MS_SSO_REDIRECT_URI=https://crm.perinco.de/api/auth/sso/callback # zusaetzliche Pruefung; Tenant und vorhandenes CRM-Konto begrenzen ohnehin. # icon-experts.de gehoert dazu - Konten dieser Domain arbeiten mit im CRM. MS_SSO_ALLOWED_DOMAINS=perinco.de,icon-experts.de + +# Verschluesselt das Client-Secret, wenn es in der Oberflaeche hinterlegt wird +# (Einstellungen > Anmeldung). Fehlt der Wert, dient JWT_SECRET als Schluessel. +# Wird einer von beiden gewechselt, muss das Secret einmal neu eingetragen +# werden - die Oberflaeche weist darauf hin. +# CRM_ENCRYPTION_KEY= + +# MS_CLIENT_SECRET oben ist seither nur noch die Rueckfallebene: Vorrang hat der +# in den Einstellungen hinterlegte Wert. diff --git a/server/package.json b/server/package.json index 2408b5e..c235604 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 && node test/sso.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 && node test/msSecret.test.mjs" }, "dependencies": { "bcryptjs": "^2.4.3", diff --git a/server/src/mail/config.js b/server/src/mail/config.js index b1cac45..07b2aeb 100644 --- a/server/src/mail/config.js +++ b/server/src/mail/config.js @@ -1,4 +1,5 @@ import { getSettings, asBool, asInt } from '../settings.js'; +import { hasSecret, requireSecret } from '../msSecret.js'; /* * Konfiguration der M365-Anbindung. @@ -14,7 +15,6 @@ import { getSettings, asBool, asInt } from '../settings.js'; 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(); export const MAILBOX = (process.env.MS_MAILBOX || 'crm@perinco.de').trim().toLowerCase(); /** Ordner im Postfach, der abgeglichen wird. `inbox` ist ein Wellknown-Name. */ @@ -23,23 +23,27 @@ export const MAIL_FOLDER = (process.env.MS_MAIL_FOLDER || 'inbox').trim(); /** Harter Aus-Schalter fuer den Hintergrundabgleich, unabhaengig von der DB. */ export const SCHEDULER_ENABLED = process.env.MAIL_SYNC !== 'false'; -/** Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. */ -export const MAIL_CONFIGURED = Boolean(TENANT_ID && CLIENT_ID && CLIENT_SECRET && MAILBOX); - -export function clientSecret() { - if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.'); - return CLIENT_SECRET; +/* + * Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. Die Pruefung ist + * asynchron, seit das Secret auch in der Datenbank liegen darf (siehe + * msSecret.js) - vorher war es eine Konstante aus der .env. + */ +export async function mailConfigured() { + return Boolean(TENANT_ID && CLIENT_ID && MAILBOX && (await hasSecret())); } +/** Das gueltige Client-Secret: Datenbank vor .env. */ +export const clientSecret = requireSecret; + /** * Welche Zugangsdaten fehlen - fuer eine brauchbare Meldung in der Oberflaeche, * ohne Werte zu verraten. */ -export function missingCredentials() { +export async function missingCredentials() { const missing = []; 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 (!(await hasSecret())) missing.push('MS_CLIENT_SECRET'); if (!MAILBOX) missing.push('MS_MAILBOX'); return missing; } diff --git a/server/src/mail/graph.js b/server/src/mail/graph.js index c69b417..937d213 100644 --- a/server/src/mail/graph.js +++ b/server/src/mail/graph.js @@ -41,7 +41,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function fetchAccessToken() { const body = new URLSearchParams({ client_id: CLIENT_ID, - client_secret: clientSecret(), + client_secret: await clientSecret(), scope: 'https://graph.microsoft.com/.default', grant_type: 'client_credentials', }); diff --git a/server/src/mail/scheduler.js b/server/src/mail/scheduler.js index ec2a39a..e1437db 100644 --- a/server/src/mail/scheduler.js +++ b/server/src/mail/scheduler.js @@ -1,5 +1,5 @@ import cron from 'node-cron'; -import { MAIL_CONFIGURED, SCHEDULER_ENABLED, mailSettings } from './config.js'; +import { SCHEDULER_ENABLED, mailConfigured, mailSettings } from './config.js'; import { getSyncState, saveSyncState } from './store.js'; import { isMailSyncRunning, runMailSync } from './sync.js'; @@ -26,7 +26,7 @@ export function isMailSchedulerRunning() { } export async function startMailScheduler() { - if (!MAIL_CONFIGURED) { + if (!(await mailConfigured())) { console.log('[Mail] Zugangsdaten fehlen - Abgleich bleibt aus, die Oberflaeche laeuft.'); return; } diff --git a/server/src/mail/sync.js b/server/src/mail/sync.js index 7d4150b..550b9a1 100644 --- a/server/src/mail/sync.js +++ b/server/src/mail/sync.js @@ -2,7 +2,7 @@ import { createHash } from 'node:crypto'; import { MAILBOX, MAIL_FOLDER, - MAIL_CONFIGURED, + mailConfigured, MAX_PAGES_PER_RUN, MAX_BODY_CHARS, mailSettings, @@ -156,8 +156,8 @@ async function fetchDeltaPage(url, { allowFilterFallback = false } = {}) { * Laeuft immer nur einer; ein zweiter Aufruf kommt sofort mit skipped zurueck. */ export async function runMailSync({ trigger = 'manuell' } = {}) { - if (!MAIL_CONFIGURED) { - return { skipped: 'nicht-konfiguriert', missing: missingCredentials() }; + if (!(await mailConfigured())) { + return { skipped: 'nicht-konfiguriert', missing: await missingCredentials() }; } if (running) return { skipped: 'laeuft-bereits' }; diff --git a/server/src/msSecret.js b/server/src/msSecret.js new file mode 100644 index 0000000..91e4c39 --- /dev/null +++ b/server/src/msSecret.js @@ -0,0 +1,243 @@ +import crypto from 'crypto'; +import { getSettings, setSetting } from './settings.js'; + +/* + * Das Client-Secret der Microsoft-App-Registrierung - hinterlegbar in der + * Oberflaeche, mit Ablaufdatum. + * + * Warum ueberhaupt in der Datenbank: das Secret laeuft in Entra ab (Standard 6 + * oder 24 Monate). Bisher hiess das: jemand mit SSH-Zugang muss die .env + * aendern und den Dienst neu starten - und niemand sieht vorher, wann es soweit + * ist. Beides erledigen jetzt Administratoren in den Einstellungen. + * + * Damit ein Datenbank-Dump trotzdem nichts hergibt, liegt der Wert + * AES-256-GCM-verschluesselt dort. Der Schluessel steht in der .env und kommt + * nie in die Datenbank - Dump und Schluessel sind also getrennt. + * + * Reihenfolge beim Lesen: erst die Datenbank, dann die .env. Der .env-Wert + * bleibt damit die Rueckfallebene, und wer nichts umstellt, merkt von der + * Neuerung nichts. + * + * Dasselbe Secret bedient Anmeldung UND Postfachanbindung - es ist dieselbe + * App-Registrierung. Eine Aenderung wirkt also auf beides. + */ + +const KEY_CIPHER = 'ms_client_secret'; +const KEY_EXPIRES = 'ms_client_secret_expires_at'; +const KEY_SAVED = 'ms_client_secret_saved_at'; + +const DEFAULTS = { + [KEY_CIPHER]: '', + [KEY_EXPIRES]: '', + [KEY_SAVED]: '', +}; + +/** Ab so vielen Tagen vor dem Ablauf warnt das CRM. */ +export const WARN_DAYS = 30; + +/** Kennzeichnet das Format - falls das Verfahren je wechselt. */ +const PREFIX = 'v1:'; + +/** + * Schluesselmaterial: bevorzugt CRM_ENCRYPTION_KEY, sonst JWT_SECRET. Der + * Rueckfall erspart eine zweite Pflichtvariable; wer JWT_SECRET wechselt, muss + * das Secret allerdings neu eingeben (die Oberflaeche sagt das dann auch). + */ +function cipherKey() { + const material = process.env.CRM_ENCRYPTION_KEY || process.env.JWT_SECRET || ''; + if (material.length < 16) { + throw new Error('Kein Schlüssel zum Verschlüsseln: CRM_ENCRYPTION_KEY oder JWT_SECRET setzen.'); + } + return crypto.scryptSync(material, 'perinco-crm-ms-secret', 32); +} + +/** Exportiert nur fuer den Test - im Betrieb geht beides ueber saveSecret(). */ +export function encrypt(text) { + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', cipherKey(), iv); + const data = Buffer.concat([cipher.update(String(text), 'utf8'), cipher.final()]); + return PREFIX + Buffer.concat([iv, cipher.getAuthTag(), data]).toString('base64'); +} + +/** Entschluesselt; leerer String, wenn der Schluessel nicht mehr passt. */ +export function decrypt(value) { + if (!value || !value.startsWith(PREFIX)) return ''; + try { + const raw = Buffer.from(value.slice(PREFIX.length), 'base64'); + const decipher = crypto.createDecipheriv('aes-256-gcm', cipherKey(), raw.subarray(0, 12)); + decipher.setAuthTag(raw.subarray(12, 28)); + return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8'); + } catch { + return ''; + } +} + +const envSecret = () => (process.env.MS_CLIENT_SECRET || '').trim(); + +/** + * Rohwerte aus der Datenbank. + * + * Ist die Datenbank nicht erreichbar, gilt das als "nichts hinterlegt" - dann + * greift der Wert aus der .env. Eine nicht erreichbare Datenbank ist ohnehin + * ein groesseres Problem als ein fehlendes Secret, und der Postfachabgleich + * soll daran nicht mit einer irrefuehrenden Meldung scheitern. + */ +async function stored() { + let raw; + try { + raw = await getSettings(DEFAULTS); + } catch (err) { + console.warn('[Microsoft] Secret konnte nicht gelesen werden, nutze .env:', err.message); + raw = { ...DEFAULTS }; + } + return { + cipher: raw[KEY_CIPHER] || '', + expiresAt: raw[KEY_EXPIRES] || '', + savedAt: raw[KEY_SAVED] || '', + }; +} + +/** + * Das tatsaechlich benutzte Secret samt Herkunft. Absichtlich ohne + * Zwischenspeicher: der Aufruf passiert selten (Tokens werden zwischengespeichert), + * und eine Aenderung soll sofort greifen. + */ +export async function effectiveSecret() { + const { cipher } = await stored(); + if (cipher) { + const value = decrypt(cipher); + if (value) return { value, source: 'db' }; + // Schluessel passt nicht mehr - nicht stillschweigend auf die .env + // zurueckfallen, sonst sucht man den Fehler an der falschen Stelle. + return { value: '', source: 'db-unlesbar' }; + } + const value = envSecret(); + return value ? { value, source: 'env' } : { value: '', source: null }; +} + +/** Fuer die Konfigurationspruefung: gibt es ueberhaupt ein brauchbares Secret? */ +export async function hasSecret() { + const { value } = await effectiveSecret(); + return Boolean(value); +} + +/** Wirft mit klarer Meldung, wenn keins da ist - Gegenstueck zum alten clientSecret(). */ +export async function requireSecret() { + const { value, source } = await effectiveSecret(); + if (value) return value; + if (source === 'db-unlesbar') { + throw new Error( + 'Das hinterlegte Client-Secret lässt sich nicht entschlüsseln. Wurde JWT_SECRET oder ' + + 'CRM_ENCRYPTION_KEY geändert? Dann das Secret in den Einstellungen neu eintragen.' + ); + } + throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.'); +} + +/** Tage bis zum Ablauf, auf Kalendertage gerechnet. Null ohne Datum. */ +export function daysUntil(date, today = new Date()) { + if (!date) return null; + const target = Date.parse(`${date}T00:00:00Z`); + if (!Number.isFinite(target)) return null; + const start = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate()); + return Math.round((target - start) / 86_400_000); +} + +/** `ok`, `warnung`, `abgelaufen` oder `unbekannt` (kein Datum hinterlegt). */ +export function expiryLevel(days) { + if (days === null) return 'unbekannt'; + if (days < 0) return 'abgelaufen'; + if (days <= WARN_DAYS) return 'warnung'; + return 'ok'; +} + +/** Zustand fuer die Oberflaeche - ohne das Secret selbst. */ +export async function secretStatus() { + const { expiresAt, savedAt } = await stored(); + const { value, source } = await effectiveSecret(); + const days = daysUntil(expiresAt); + + return { + configured: Boolean(value), + /** 'db', 'env', 'db-unlesbar' oder null */ + source, + expires_at: expiresAt || null, + saved_at: savedAt || null, + days_left: days, + level: expiryLevel(days), + warn_days: WARN_DAYS, + }; +} + +/** Leeres Datum loescht den Eintrag; ungueltiges Datum wird abgewiesen. */ +export function normalizeExpiry(value) { + const text = String(value ?? '').trim(); + if (text === '') return { value: '' }; + if (!/^\d{4}-\d{2}-\d{2}$/.test(text)) { + return { error: 'Das Ablaufdatum muss im Format JJJJ-MM-TT angegeben werden.' }; + } + const parsed = new Date(`${text}T00:00:00Z`); + if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== text) { + return { error: 'Dieses Ablaufdatum gibt es nicht.' }; + } + return { value: text }; +} + +/** + * Prueft ein Secret bei Microsoft, bevor es gespeichert wird: ein + * Client-Credentials-Token gegen denselben Tenant. Ein falsches Secret faellt so + * sofort auf, statt beim naechsten Postfachabgleich. + */ +export async function verifySecret(candidate) { + const tenant = (process.env.MS_TENANT_ID || '').trim(); + const client = (process.env.MS_CLIENT_ID || '').trim(); + if (!tenant || !client) { + return { ok: false, error: 'MS_TENANT_ID oder MS_CLIENT_ID fehlt in der .env.' }; + } + + let response; + try { + response = await fetch(`https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + client_id: client, + client_secret: candidate, + scope: 'https://graph.microsoft.com/.default', + grant_type: 'client_credentials', + }), + signal: AbortSignal.timeout(20_000), + }); + } catch (err) { + return { ok: false, error: `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}`; + return { ok: false, error: String(detail).split('\n')[0] }; + } + return { ok: true }; +} + +/** + * Speichert Secret und/oder Ablaufdatum. `secret` leer bedeutet: nur das Datum + * anfassen, das gespeicherte Secret bleibt. + */ +export async function saveSecret({ secret = '', expiresAt = null } = {}) { + if (secret) { + await setSetting(KEY_CIPHER, encrypt(secret)); + await setSetting(KEY_SAVED, new Date().toISOString()); + } + if (expiresAt !== null) { + await setSetting(KEY_EXPIRES, expiresAt); + } + return secretStatus(); +} + +/** Nimmt das hinterlegte Secret zurueck - ab dann gilt wieder die .env. */ +export async function clearStoredSecret() { + await setSetting(KEY_CIPHER, ''); + await setSetting(KEY_SAVED, ''); + return secretStatus(); +} diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 21e7da8..ff5aa01 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -11,10 +11,18 @@ import { import { ALLOWED_DOMAINS, REDIRECT_URI, - SSO_CONFIGURED, domainAllowed, missingSsoConfig, + ssoConfigured, } from '../sso/config.js'; +import { + clearStoredSecret, + normalizeExpiry, + saveSecret, + secretStatus, + verifySecret, +} from '../msSecret.js'; +import { resetTokenCache } from '../mail/graph.js'; import { SsoError, beginLogin, @@ -183,31 +191,99 @@ function redirectWithError(res, message) { // 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 }); +router.get('/sso/status', async (req, res, next) => { + try { + res.json({ enabled: await ssoConfigured() }); + } catch (err) { + next(err); + } }); // 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, - }); +router.get('/sso/config', requireAuth, requireAdmin, async (req, res, next) => { + try { + res.json({ + enabled: await ssoConfigured(), + missing: await missingSsoConfig(), + redirect_uri: REDIRECT_URI || null, + allowed_domains: ALLOWED_DOMAINS, + secret: await secretStatus(), + }); + } catch (err) { + next(err); + } +}); + +/* --------------------------------------------------------------------------- + * Client-Secret der App-Registrierung + * + * Dasselbe Secret bedient Anmeldung und Postfachanbindung. Es liegt + * verschluesselt in crm_settings (siehe msSecret.js); der Wert aus der .env + * bleibt Rueckfallebene. Ausgeliefert wird das Secret nie - weder ganz noch + * teilweise, auch nicht an Administratoren. + * ------------------------------------------------------------------------- */ + +// PUT /api/auth/microsoft-secret { secret?, expires_at? } +router.put('/microsoft-secret', requireAuth, requireAdmin, async (req, res, next) => { + const secret = String(req.body?.secret ?? '').trim(); + const hasExpiry = req.body?.expires_at !== undefined; + + const expiry = hasExpiry ? normalizeExpiry(req.body.expires_at) : { value: null }; + if (expiry.error) return res.status(400).json({ error: expiry.error }); + + if (!secret && !hasExpiry) { + return res.status(400).json({ error: 'Kein Secret und kein Ablaufdatum übergeben.' }); + } + + try { + // Erst bei Microsoft ausprobieren, dann speichern: ein vertipptes Secret + // legt sonst still Anmeldung und Postfachabgleich lahm. + if (secret) { + const check = await verifySecret(secret); + if (!check.ok) { + return res.status(400).json({ + error: `Microsoft hat dieses Secret abgelehnt: ${check.error}`, + }); + } + } + + const status = await saveSecret({ secret, expiresAt: expiry.value }); + + // Das zwischengespeicherte Graph-Token stammt noch vom alten Secret. + if (secret) resetTokenCache(); + + res.json({ ok: true, verified: Boolean(secret), secret: status }); + } catch (err) { + next(err); + } +}); + +// DELETE /api/auth/microsoft-secret - hinterlegtes Secret verwerfen, .env gilt wieder +router.delete('/microsoft-secret', requireAuth, requireAdmin, async (req, res, next) => { + try { + const status = await clearStoredSecret(); + resetTokenCache(); + res.json({ ok: true, secret: status }); + } catch (err) { + next(err); + } }); // 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.' }); +router.get('/sso/start', async (req, res, next) => { + try { + if (!(await ssoConfigured())) { + return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' }); + } + res.redirect(302, beginLogin()); + } catch (err) { + next(err); } - res.redirect(302, beginLogin()); }); // GET /api/auth/sso/callback - Rueckleitung von Microsoft. router.get('/sso/callback', async (req, res, next) => { - if (!SSO_CONFIGURED) { + if (!(await ssoConfigured())) { return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' }); } diff --git a/server/src/routes/badges.js b/server/src/routes/badges.js index eeb5e6b..fc05b15 100644 --- a/server/src/routes/badges.js +++ b/server/src/routes/badges.js @@ -2,6 +2,7 @@ import express from 'express'; import { pool } from '../db.js'; import { requireAuth } from '../middleware/auth.js'; import { Match } from '../scanner/models/index.js'; +import { secretStatus } from '../msSecret.js'; import { ARCHIVE_AFTER_DAYS, DELETE_AFTER_DAYS, @@ -54,12 +55,35 @@ router.get('/', async (req, res, next) => { // Scanner-Datenbank nicht erreichbar - dann eben keine Zahl. } + /* + * Hinweis auf das bald ablaufende Client-Secret. Er reist mit den Zaehlern + * mit, weil die ohnehin bei jedem Laden der App geholt werden - ein eigener + * Endpunkt waere eine zweite Anfrage fuer eine Zahl. Nur Administratoren + * bekommen ihn: nur sie koennen das Secret erneuern. + */ + let msSecret = null; + if (req.user.role === 'admin') { + try { + const status = await secretStatus(); + if (status.level === 'warnung' || status.level === 'abgelaufen') { + msSecret = { + level: status.level, + days_left: status.days_left, + expires_at: status.expires_at, + }; + } + } catch { + // Kein Grund, die Zaehler scheitern zu lassen. + } + } + res.json({ acquisition_archive: Number(counts.acquisition_archive), acquisition_trash: Number(counts.acquisition_trash), opportunity_trash: Number(counts.opportunity_trash), mail_trash: Number(counts.mail_trash), scanner_rejected: scannerRejected, + ms_secret: msSecret, }); } catch (err) { next(err); diff --git a/server/src/routes/mail.js b/server/src/routes/mail.js index 5b30d08..cd936dc 100644 --- a/server/src/routes/mail.js +++ b/server/src/routes/mail.js @@ -4,7 +4,7 @@ import { requireAdmin } from '../middleware/auth.js'; import { setSetting } from '../settings.js'; import { MAILBOX, - MAIL_CONFIGURED, + mailConfigured, MAIL_FOLDER, MAIL_SETTING_DEFAULTS, SCHEDULER_ENABLED, @@ -70,6 +70,7 @@ router.get('/status', async (req, res, next) => { try { const settings = await mailSettings(); const state = await getSyncState(); + const configured = await mailConfigured(); const [[counts]] = await pool.query( `SELECT @@ -86,8 +87,8 @@ router.get('/status', async (req, res, next) => { ); res.json({ - configured: MAIL_CONFIGURED, - missing: MAIL_CONFIGURED ? [] : missingCredentials(), + configured, + missing: configured ? [] : await missingCredentials(), mailbox: MAILBOX, folder: MAIL_FOLDER, scheduler_enabled: SCHEDULER_ENABLED, @@ -167,11 +168,11 @@ router.post('/settings', requireAdmin, async (req, res, next) => { // POST /api/mail/test - Token holen und wirklich auf das Postfach zugreifen router.post('/test', requireAdmin, async (req, res, next) => { try { - if (!MAIL_CONFIGURED) { + if (!(await mailConfigured())) { return res.status(400).json({ ok: false, stage: 'konfiguration', - error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}`, + error: `Zugangsdaten fehlen: ${(await missingCredentials()).join(', ')}`, hint: 'Werte in server/.env setzen und den Dienst neu starten.', }); } @@ -184,8 +185,10 @@ router.post('/test', requireAdmin, async (req, res, next) => { // POST /api/mail/sync - Abgleich sofort anstossen router.post('/sync', requireAdmin, async (req, res, next) => { try { - if (!MAIL_CONFIGURED) { - return res.status(409).json({ error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}` }); + if (!(await mailConfigured())) { + return res.status(409).json({ + error: `Zugangsdaten fehlen: ${(await missingCredentials()).join(', ')}`, + }); } if (isMailSyncRunning()) { return res.status(409).json({ error: 'Ein Abgleich läuft bereits. Bitte kurz warten.' }); diff --git a/server/src/sso/config.js b/server/src/sso/config.js index 442e505..1a99517 100644 --- a/server/src/sso/config.js +++ b/server/src/sso/config.js @@ -12,9 +12,10 @@ * Client-ID zu sehen; er kennt nur /api/auth/sso/start. */ +import { hasSecret, requireSecret } from '../msSecret.js'; + 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'; @@ -40,24 +41,27 @@ 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; +/* + * Ohne diese Werte ist die Anmeldung nicht betriebsbereit. Asynchron, weil das + * Secret auch aus der Datenbank kommen darf (msSecret.js). + */ +export async function ssoConfigured() { + return Boolean(ENABLED && TENANT_ID && CLIENT_ID && REDIRECT_URI && (await hasSecret())); } +/** Das gueltige Client-Secret: Datenbank vor .env. */ +export const clientSecret = requireSecret; + /** * Was zum Betrieb fehlt - fuer eine brauchbare Meldung in den Einstellungen, * ohne Werte zu verraten. */ -export function missingSsoConfig() { +export async 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 (!(await hasSecret())) missing.push('MS_CLIENT_SECRET'); if (!REDIRECT_URI) missing.push('MS_SSO_REDIRECT_URI'); return missing; } diff --git a/server/src/sso/flow.js b/server/src/sso/flow.js index 47cda55..040853d 100644 --- a/server/src/sso/flow.js +++ b/server/src/sso/flow.js @@ -123,10 +123,10 @@ async function postToken(body) { } /** Tauscht den Code gegen Tokens. Der Verifier weist uns als Starter des Vorgangs aus. */ -export function exchangeCode(code, verifier) { +export async function exchangeCode(code, verifier) { return postToken({ client_id: CLIENT_ID, - client_secret: clientSecret(), + client_secret: await clientSecret(), grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, diff --git a/server/test/msSecret.test.mjs b/server/test/msSecret.test.mjs new file mode 100644 index 0000000..c386eee --- /dev/null +++ b/server/test/msSecret.test.mjs @@ -0,0 +1,76 @@ +/* + * Prueft das hinterlegte Client-Secret, soweit es ohne Datenbank geht: + * Verschluesselung, Ablaufrechnung und die Pruefung des Datums. + * + * Der Schluessel muss vor dem Import stehen - msSecret.js liest ihn beim + * Verschluesseln aus der Umgebung, genau wie im Betrieb. + */ +process.env.JWT_SECRET = 'test-schluessel-lang-genug-fuer-die-pruefung'; +delete process.env.CRM_ENCRYPTION_KEY; + +const { WARN_DAYS, daysUntil, decrypt, encrypt, expiryLevel, normalizeExpiry } = await import( + '../src/msSecret.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; + } +} + +console.log('\nClient-Secret'); + +console.log('\n Verschluesselung'); +const klartext = 'Abc8Q~geheimes-client-secret-aus-entra'; +const cipher = encrypt(klartext); +check('Ergebnis ist nicht der Klartext', !cipher.includes('geheimes-client-secret')); +check('Format ist gekennzeichnet', cipher.startsWith('v1:')); +check('Entschluesseln ergibt das Original', decrypt(cipher) === klartext); +check( + 'zweimal verschluesselt ergibt zwei verschiedene Werte', + encrypt(klartext) !== encrypt(klartext), + 'eigener Zufallsvektor je Aufruf' +); +check('veraenderter Text wird nicht entschluesselt', decrypt(`${cipher}xx`) === '', 'GCM erkennt es'); +check('fremdes Format wird abgelehnt', decrypt('irgendwas') === ''); +check('leerer Wert bleibt leer', decrypt('') === ''); + +// Ein anderer Schluessel darf nicht passen - genau das passiert, wenn jemand +// JWT_SECRET wechselt. Die Oberflaeche zeigt dann "nicht entschluesselbar". +process.env.CRM_ENCRYPTION_KEY = 'ein-ganz-anderer-schluessel-fuer-den-test'; +check('anderer Schluessel entschluesselt nicht', decrypt(cipher) === ''); +delete process.env.CRM_ENCRYPTION_KEY; +check('mit dem alten Schluessel geht es wieder', decrypt(cipher) === klartext); + +console.log('\n Restlaufzeit'); +const heute = new Date('2026-09-21T08:00:00Z'); +check('heute sind es 0 Tage', daysUntil('2026-09-21', heute) === 0); +check('morgen ist 1 Tag', daysUntil('2026-09-22', heute) === 1); +check('gestern sind -1 Tage', daysUntil('2026-09-20', heute) === -1); +check('ohne Datum null', daysUntil('', heute) === null); +check('unlesbares Datum ergibt null', daysUntil('irgendwann', heute) === null); +check('Jahreswechsel zaehlt richtig', daysUntil('2027-09-21', heute) === 365); + +console.log('\n Warnstufe'); +check('kein Datum: unbekannt', expiryLevel(null) === 'unbekannt'); +check('abgelaufen bei -1', expiryLevel(-1) === 'abgelaufen'); +check('Warnung am Tag des Ablaufs', expiryLevel(0) === 'warnung'); +check(`Warnung genau ${WARN_DAYS} Tage vorher`, expiryLevel(WARN_DAYS) === 'warnung'); +check('einen Tag frueher noch ok', expiryLevel(WARN_DAYS + 1) === 'ok'); + +console.log('\n Ablaufdatum pruefen'); +check('gueltiges Datum wird uebernommen', normalizeExpiry('2027-03-01').value === '2027-03-01'); +check('leer loescht den Eintrag', normalizeExpiry('').value === ''); +check('deutsches Format wird abgelehnt', Boolean(normalizeExpiry('01.03.2027').error)); +check('31. Februar wird abgelehnt', Boolean(normalizeExpiry('2027-02-31').error)); +check('Monat 13 wird abgelehnt', Boolean(normalizeExpiry('2027-13-01').error)); + +console.log( + failures === 0 ? '\nAlle Pruefungen bestanden.\n' : `\n${failures} Pruefung(en) fehlgeschlagen.\n` +); +process.exit(failures === 0 ? 0 : 1); diff --git a/server/test/sso.test.mjs b/server/test/sso.test.mjs index 0f767e0..541a699 100644 --- a/server/test/sso.test.mjs +++ b/server/test/sso.test.mjs @@ -15,7 +15,7 @@ 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 { domainAllowed, missingSsoConfig, ssoConfigured } = await import('../src/sso/config.js'); const { SsoError, beginLogin, @@ -62,8 +62,11 @@ const validClaims = { 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(', ')); +// Ohne erreichbare Datenbank faellt das Secret auf die .env zurueck - genau +// dieser Weg wird hier mitgeprueft. +const missing = await missingSsoConfig(); +check('vollstaendige Konfiguration gilt als betriebsbereit', (await ssoConfigured()) === true); +check('nichts fehlt', missing.length === 0, missing.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); diff --git a/src/App.tsx b/src/App.tsx index 8f6a303..047ba7c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -228,6 +228,42 @@ const App: React.FC = () => { onToggleSidebar={() => setIsSidebarOpen((open) => !open)} /> + {/* + * Hinweis auf das ablaufende Client-Secret. Er steht ueber allen + * Ansichten, weil er niemanden bei der Arbeit unterbricht, aber auch + * nicht zu uebersehen sein soll - ein Toast waere beim Wegklicken + * vergessen. Der Server schickt ihn nur Administratoren und nur ab 30 + * Tagen vor dem Ablauf. + */} + {badges?.ms_secret && ( + + )} +
{currentView === 'Dashboard' && ( diff --git a/src/components/settings/SsoSettings.tsx b/src/components/settings/SsoSettings.tsx index 6685c56..8f7d6e3 100644 --- a/src/components/settings/SsoSettings.tsx +++ b/src/components/settings/SsoSettings.tsx @@ -1,15 +1,16 @@ import React, { useEffect, useState } from 'react'; -import { ssoApi, type SsoConfig } from '../../lib/api'; +import { msSecretApi, ssoApi, type MsSecretStatus, type SsoConfig } from '../../lib/api'; +import { inputClass, labelClass } from '../leads/leadUi'; 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. + * Tenant-ID, Client-ID und Umleitungsadresse stehen in server/.env und sind + * hier nur ablesbar. Das Client-Secret ist die Ausnahme: es läuft in Entra ab + * und muss dann erneuert werden, ohne dass jemand auf den Server muss. Es geht + * deshalb hier hinein - verschlüsselt in die Datenbank - kommt aber nie wieder + * heraus. Angezeigt wird nur, woher es stammt und wie lange es noch gilt. */ const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( @@ -19,6 +20,220 @@ const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, valu
); +/** Datum als 21.09.2026; leere Eingabe bleibt leer. */ +function formatDate(value: string | null): string { + if (!value) return '—'; + const parsed = new Date(`${value}T00:00:00`); + if (Number.isNaN(parsed.getTime())) return value; + return parsed.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); +} + +/** Farbe und Text zum Ablaufzustand - einmal definiert, dreimal gebraucht. */ +function expiryTone(secret: MsSecretStatus): { accent: string; text: string; note: string } { + if (secret.level === 'abgelaufen') { + return { + accent: 'border-l-red-500', + text: 'text-red-700', + note: 'Das Client-Secret ist abgelaufen. Anmeldung über Microsoft und Postfachabgleich stehen still, bis ein neues eingetragen ist.', + }; + } + if (secret.level === 'warnung') { + return { + accent: 'border-l-amber-500', + text: 'text-amber-700', + note: `Das Client-Secret läuft in ${secret.days_left} ${ + secret.days_left === 1 ? 'Tag' : 'Tagen' + } ab. In Entra ein neues erzeugen und hier eintragen.`, + }; + } + if (secret.level === 'unbekannt') { + return { + accent: 'border-l-ink-300', + text: 'text-ink-500', + note: 'Kein Ablaufdatum hinterlegt. Trage es ein, dann warnt das CRM rechtzeitig.', + }; + } + return { accent: 'border-l-green-500', text: 'text-green-700', note: '' }; +} + +const SecretSection: React.FC<{ secret: MsSecretStatus; onChange: (next: MsSecretStatus) => void }> = ({ + secret, + onChange, +}) => { + const { addToast } = useToast(); + const [value, setValue] = useState(''); + const [expiresAt, setExpiresAt] = useState(secret.expires_at ?? ''); + const [isSaving, setIsSaving] = useState(false); + const [isClearing, setIsClearing] = useState(false); + + // Nach dem Speichern kommt der neue Zustand von aussen - das Datumsfeld soll + // ihm folgen, damit es nicht den alten Wert weiterzeigt. + useEffect(() => { + setExpiresAt(secret.expires_at ?? ''); + }, [secret.expires_at]); + + const tone = expiryTone(secret); + const expiryChanged = (secret.expires_at ?? '') !== expiresAt; + const canSave = Boolean(value.trim()) || expiryChanged; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + if (!canSave) return; + + setIsSaving(true); + try { + const payload: { secret?: string; expires_at?: string } = {}; + if (value.trim()) payload.secret = value.trim(); + if (expiryChanged) payload.expires_at = expiresAt; + + const result = await msSecretApi.save(payload); + onChange(result.secret); + setValue(''); + addToast( + result.verified + ? 'Secret gespeichert - Microsoft hat es akzeptiert.' + : 'Ablaufdatum gespeichert.', + 'success' + ); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Speichern fehlgeschlagen.', 'error'); + } finally { + setIsSaving(false); + } + }; + + const handleClear = async () => { + setIsClearing(true); + try { + const result = await msSecretApi.clear(); + onChange(result.secret); + addToast('Hinterlegtes Secret verworfen - es gilt wieder der Wert aus server/.env.', 'success'); + } catch (err) { + addToast(err instanceof Error ? err.message : 'Verwerfen fehlgeschlagen.', 'error'); + } finally { + setIsClearing(false); + } + }; + + return ( +
+

Client-Secret

+

+ Gilt für Anmeldung und Postfachanbindung - es ist dieselbe App-Registrierung. Das + Secret wird verschlüsselt gespeichert und nie wieder angezeigt. +

+ +
+ + + + {secret.days_left < 0 + ? `seit ${Math.abs(secret.days_left)} Tagen abgelaufen` + : `${secret.days_left} ${secret.days_left === 1 ? 'Tag' : 'Tage'}`} + + ) + } + /> + +
+ + {tone.note && ( +

+ {tone.note} +

+ )} + + {secret.source === 'db-unlesbar' && ( +

+ Das gespeicherte Secret lässt sich nicht entschlüsseln. Das passiert, wenn + JWT_SECRET bzw. CRM_ENCRYPTION_KEY + gewechselt wurde. Einfach unten neu eintragen. +

+ )} + +
+
+ + setValue(event.target.value)} + placeholder="Wert aus Entra → Zertifikate & Geheimnisse" + className={inputClass} + /> +

+ Leer lassen, wenn nur das Ablaufdatum geändert werden soll. Beim Speichern holt das CRM + testweise ein Token - ein vertipptes Secret wird gar nicht erst übernommen. +

+
+ +
+ + setExpiresAt(event.target.value)} + className={inputClass} + /> +

+ Steht in Entra neben dem Geheimnis. Ab {secret.warn_days} Tagen vorher weist das CRM alle + Administratoren darauf hin. +

+
+ +
+ + + {secret.source === 'db' || secret.source === 'db-unlesbar' ? ( + + ) : null} +
+
+
+ ); +}; + export const SsoSettings: React.FC = () => { const { addToast } = useToast(); const [config, setConfig] = useState(null); @@ -75,12 +290,18 @@ export const SsoSettings: React.FC = () => {
Fehlt in server/.env: {config.missing.join(', ')} - Die Werte werden beim Start gelesen - nach einer Änderung das Backend neu starten. + Die Werte werden beim Start gelesen - nach einer Änderung das Backend neu starten. Das + Client-Secret ist die Ausnahme: das lässt sich hier eintragen.
)} + setConfig((prev) => (prev ? { ...prev, secret } : prev))} + /> +

Wie es funktioniert

    @@ -105,6 +326,28 @@ export const SsoSettings: React.FC = () => {
+ +
+

Secret in Entra erneuern

+
    +
  1. + 1. Entra → App-Registrierungen → perinco + crm → Zertifikate & Geheimnisse → Neuer geheimer Clientschlüssel. +
  2. +
  3. + 2. Wert sofort kopieren - Entra zeigt ihn + nur einmal an. +
  4. +
  5. + 3. Hier eintragen, Ablaufdatum aus Entra + übernehmen, speichern. Das CRM prüft das Secret sofort bei Microsoft. +
  6. +
  7. + 4. Erst danach das alte Geheimnis in + Entra löschen. +
  8. +
+
); }; diff --git a/src/lib/api.ts b/src/lib/api.ts index ca6b3c5..4bab665 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -119,11 +119,24 @@ export const authApi = { */ export const SSO_START_URL = '/api/auth/sso/start'; +/** Zustand des Client-Secrets - ohne das Secret selbst. */ +export interface MsSecretStatus { + configured: boolean; + /** 'db' = in den Einstellungen hinterlegt, 'env' = aus server/.env */ + source: 'db' | 'env' | 'db-unlesbar' | null; + expires_at: string | null; + saved_at: string | null; + days_left: number | null; + level: 'ok' | 'warnung' | 'abgelaufen' | 'unbekannt'; + warn_days: number; +} + export interface SsoConfig { enabled: boolean; missing: string[]; redirect_uri: string | null; allowed_domains: string[]; + secret: MsSecretStatus; } export const ssoApi = { @@ -138,6 +151,21 @@ export const ssoApi = { }), }; +/** + * Client-Secret der Microsoft-App-Registrierung (nur admin). Es geht nur + * hinein, nie heraus - gelesen wird ausschliesslich der Zustand. + */ +export const msSecretApi = { + save: (input: { secret?: string; expires_at?: string }) => + request<{ ok: true; verified: boolean; secret: MsSecretStatus }>('/auth/microsoft-secret', { + method: 'PUT', + body: JSON.stringify(input), + }), + /** Verwirft das hinterlegte Secret; ab dann gilt wieder server/.env. */ + clear: () => + request<{ ok: true; secret: MsSecretStatus }>('/auth/microsoft-secret', { method: 'DELETE' }), +}; + // ---------- Benutzerverwaltung (nur admin) ---------- export interface UserInput { @@ -312,6 +340,11 @@ export interface SidebarBadges { opportunity_trash: number; mail_trash: number; scanner_rejected: number | null; + /** + * Nur fuer Administratoren und nur, wenn das Client-Secret bald ablaeuft oder + * schon abgelaufen ist - sonst null. + */ + ms_secret: { level: 'warnung' | 'abgelaufen'; days_left: number | null; expires_at: string | null } | null; } export const badgesApi = {