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) <noreply@anthropic.com>
This commit is contained in:
Sascha Wolf
2026-09-21 08:38:28 +02:00
co-authored by Claude Opus 5
parent e271f5c1e8
commit c8eaaa1947
22 changed files with 937 additions and 64 deletions
+6
View File
@@ -66,6 +66,7 @@ mitten im Scrapen beenden.
| `MS_SSO_ENABLED` | Anmeldung über Microsoft, Standard aus, nur `true` schaltet ein | | `MS_SSO_ENABLED` | Anmeldung über Microsoft, Standard aus, nur `true` schaltet ein |
| `MS_SSO_REDIRECT_URI` | Umleitungsadresse, zeichengenau wie in Entra eingetragen | | `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 | | `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 Die Scanner-Datenbank wird beim Start automatisch angelegt
(`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale (`CREATE DATABASE IF NOT EXISTS`), Tabellen kommen aus `sequelize.sync()`, Portale
@@ -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 App-Registrierung wie das Postfach. Ohne sie bleibt es bei Benutzername und
Passwort. Einrichtung: [13 Anmeldung über Microsoft](13-anmeldung-sso.md). 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
`playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build `playwright` ist auf **exakt 1.60.0** gepinnt. Der passende Chromium-Build
+6
View File
@@ -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 welches von beidem nicht stimmte - so kann über die Anmeldemaske niemand
Benutzernamen durchprobieren. 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, Es gibt zwei Rollen: **Administrator** sieht zusätzlich die Nutzerverwaltung,
die Anmelde- und die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich. die Anmelde- und die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
+31 -1
View File
@@ -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 | | `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 | | `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 | | `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`) | | `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 | | `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 | | `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 * Der Lauf ist **einfach gesperrt** (`running` in `sync.js`) - ein zweiter Aufruf
kommt sofort mit `skipped` zurück, statt parallel zu laufen. kommt sofort mit `skipped` zurück, statt parallel zu laufen.
### `server/src/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/` ### `server/src/sso/`
Anmeldung über Microsoft Entra, fachlich in Anmeldung über Microsoft Entra, fachlich in
+4 -1
View File
@@ -59,7 +59,10 @@ Login.
1. **App-Registrierung** in Entra (Name z. B. `perinco CRM`), Kontotyp „nur 1. **App-Registrierung** in Entra (Name z. B. `perinco CRM`), Kontotyp „nur
dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und dieses Verzeichnis", Redirect-URI leer lassen. Anwendungs-ID und
Verzeichnis-ID notieren. Verzeichnis-ID notieren.
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 &rarr; *Anwendungsberechtigungen* &rarr; 3. **API-Berechtigung** Microsoft Graph &rarr; *Anwendungsberechtigungen* &rarr;
`Mail.Read`, danach Administratorzustimmung erteilen. `Mail.Read`, danach Administratorzustimmung erteilen.
`Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext. `Mail.ReadBasic` genügt nicht, es liefert keinen Mailtext.
+77 -3
View File
@@ -100,11 +100,78 @@ antwortet dann mit 404, das ist eingeplant).
| `GET` | `/api/auth/sso/start` | offen | Umleitung zu Entra | | `GET` | `/api/auth/sso/start` | offen | Umleitung zu Entra |
| `GET` | `/api/auth/sso/callback` | offen | Rückleitung von Entra, endet in `/?sso_ticket=...` | | `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 | | `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=<Meldung>`; die Scheitert etwas, landet der Browser auf `/?sso_error=<Meldung>`; die
Anmeldemaske zeigt den Text im gewohnten roten Kasten. 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 →<br/>Anmeldung"] -->|"PUT /microsoft-secret"| B["Probe bei Microsoft:<br/>Client-Credentials-Token"]
B -->|abgelehnt| C["nichts gespeichert,<br/>Meldung von Entra"]
B -->|angenommen| D["AES-256-GCM<br/>verschlüsseln"]
D --> E[("crm_settings")]
E --> F["Graph-Token verwerfen -<br/>der Zwischenspeicher<br/>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 ## Einrichtung in Entra
Die App-Registrierung **perinco crm** existiert bereits für die 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_ENABLED` | ja | nur `true` schaltet die Anmeldung ein |
| `MS_SSO_REDIRECT_URI` | ja | exakt die in Entra eingetragene Adresse | | `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_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` 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 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) | | „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 | | „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 | | 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 ## 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 | | `server/src/schema.sql` | Spalte `crm_users.entra_oid` samt Unique-Index |
| `src/lib/auth.tsx` | Ticket einlösen, Adresszeile aufräumen, `ssoError` | | `src/lib/auth.tsx` | Ticket einlösen, Adresszeile aufräumen, `ssoError` |
| `src/components/LoginScreen.tsx` | Knopf „Mit Microsoft anmelden" | | `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 | | `server/test/sso.test.mjs` | Prüfungen ohne Netz und Datenbank |
+9
View File
@@ -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. # zusaetzliche Pruefung; Tenant und vorhandenes CRM-Konto begrenzen ohnehin.
# icon-experts.de gehoert dazu - Konten dieser Domain arbeiten mit im CRM. # icon-experts.de gehoert dazu - Konten dieser Domain arbeiten mit im CRM.
MS_SSO_ALLOWED_DOMAINS=perinco.de,icon-experts.de 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.
+1 -1
View File
@@ -12,7 +12,7 @@
"start": "node src/server.js", "start": "node src/server.js",
"dev": "node --watch src/server.js", "dev": "node --watch src/server.js",
"migrate": "node src/migrate.js", "migrate": "node src/migrate.js",
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs && 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": { "dependencies": {
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
+13 -9
View File
@@ -1,4 +1,5 @@
import { getSettings, asBool, asInt } from '../settings.js'; import { getSettings, asBool, asInt } from '../settings.js';
import { hasSecret, requireSecret } from '../msSecret.js';
/* /*
* Konfiguration der M365-Anbindung. * 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 TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
export const CLIENT_ID = (process.env.MS_CLIENT_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(); export const MAILBOX = (process.env.MS_MAILBOX || 'crm@perinco.de').trim().toLowerCase();
/** Ordner im Postfach, der abgeglichen wird. `inbox` ist ein Wellknown-Name. */ /** 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. */ /** Harter Aus-Schalter fuer den Hintergrundabgleich, unabhaengig von der DB. */
export const SCHEDULER_ENABLED = process.env.MAIL_SYNC !== 'false'; 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); * Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. Die Pruefung ist
* asynchron, seit das Secret auch in der Datenbank liegen darf (siehe
export function clientSecret() { * msSecret.js) - vorher war es eine Konstante aus der .env.
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.'); */
return CLIENT_SECRET; 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, * Welche Zugangsdaten fehlen - fuer eine brauchbare Meldung in der Oberflaeche,
* ohne Werte zu verraten. * ohne Werte zu verraten.
*/ */
export function missingCredentials() { export async function missingCredentials() {
const missing = []; const missing = [];
if (!TENANT_ID) missing.push('MS_TENANT_ID'); if (!TENANT_ID) missing.push('MS_TENANT_ID');
if (!CLIENT_ID) missing.push('MS_CLIENT_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'); if (!MAILBOX) missing.push('MS_MAILBOX');
return missing; return missing;
} }
+1 -1
View File
@@ -41,7 +41,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function fetchAccessToken() { async function fetchAccessToken() {
const body = new URLSearchParams({ const body = new URLSearchParams({
client_id: CLIENT_ID, client_id: CLIENT_ID,
client_secret: clientSecret(), client_secret: await clientSecret(),
scope: 'https://graph.microsoft.com/.default', scope: 'https://graph.microsoft.com/.default',
grant_type: 'client_credentials', grant_type: 'client_credentials',
}); });
+2 -2
View File
@@ -1,5 +1,5 @@
import cron from 'node-cron'; 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 { getSyncState, saveSyncState } from './store.js';
import { isMailSyncRunning, runMailSync } from './sync.js'; import { isMailSyncRunning, runMailSync } from './sync.js';
@@ -26,7 +26,7 @@ export function isMailSchedulerRunning() {
} }
export async function startMailScheduler() { export async function startMailScheduler() {
if (!MAIL_CONFIGURED) { if (!(await mailConfigured())) {
console.log('[Mail] Zugangsdaten fehlen - Abgleich bleibt aus, die Oberflaeche laeuft.'); console.log('[Mail] Zugangsdaten fehlen - Abgleich bleibt aus, die Oberflaeche laeuft.');
return; return;
} }
+3 -3
View File
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
import { import {
MAILBOX, MAILBOX,
MAIL_FOLDER, MAIL_FOLDER,
MAIL_CONFIGURED, mailConfigured,
MAX_PAGES_PER_RUN, MAX_PAGES_PER_RUN,
MAX_BODY_CHARS, MAX_BODY_CHARS,
mailSettings, mailSettings,
@@ -156,8 +156,8 @@ async function fetchDeltaPage(url, { allowFilterFallback = false } = {}) {
* Laeuft immer nur einer; ein zweiter Aufruf kommt sofort mit skipped zurueck. * Laeuft immer nur einer; ein zweiter Aufruf kommt sofort mit skipped zurueck.
*/ */
export async function runMailSync({ trigger = 'manuell' } = {}) { export async function runMailSync({ trigger = 'manuell' } = {}) {
if (!MAIL_CONFIGURED) { if (!(await mailConfigured())) {
return { skipped: 'nicht-konfiguriert', missing: missingCredentials() }; return { skipped: 'nicht-konfiguriert', missing: await missingCredentials() };
} }
if (running) return { skipped: 'laeuft-bereits' }; if (running) return { skipped: 'laeuft-bereits' };
+243
View File
@@ -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();
}
+91 -15
View File
@@ -11,10 +11,18 @@ import {
import { import {
ALLOWED_DOMAINS, ALLOWED_DOMAINS,
REDIRECT_URI, REDIRECT_URI,
SSO_CONFIGURED,
domainAllowed, domainAllowed,
missingSsoConfig, missingSsoConfig,
ssoConfigured,
} from '../sso/config.js'; } from '../sso/config.js';
import {
clearStoredSecret,
normalizeExpiry,
saveSecret,
secretStatus,
verifySecret,
} from '../msSecret.js';
import { resetTokenCache } from '../mail/graph.js';
import { import {
SsoError, SsoError,
beginLogin, beginLogin,
@@ -183,31 +191,99 @@ function redirectWithError(res, message) {
// GET /api/auth/sso/status - oeffentlich, damit die Anmeldemaske weiss, ob sie // GET /api/auth/sso/status - oeffentlich, damit die Anmeldemaske weiss, ob sie
// den Microsoft-Knopf zeigen soll. Verraet nur ja/nein. // den Microsoft-Knopf zeigen soll. Verraet nur ja/nein.
router.get('/sso/status', (req, res) => { router.get('/sso/status', async (req, res, next) => {
res.json({ enabled: SSO_CONFIGURED }); try {
res.json({ enabled: await ssoConfigured() });
} catch (err) {
next(err);
}
}); });
// GET /api/auth/sso/config - Adminsicht fuer die Einstellungen. // GET /api/auth/sso/config - Adminsicht fuer die Einstellungen.
router.get('/sso/config', requireAuth, requireAdmin, (req, res) => { router.get('/sso/config', requireAuth, requireAdmin, async (req, res, next) => {
res.json({ try {
enabled: SSO_CONFIGURED, res.json({
missing: missingSsoConfig(), enabled: await ssoConfigured(),
redirect_uri: REDIRECT_URI || null, missing: await missingSsoConfig(),
allowed_domains: ALLOWED_DOMAINS, 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. // GET /api/auth/sso/start - Beginn der Anmeldung, leitet zu Microsoft weiter.
router.get('/sso/start', (req, res) => { router.get('/sso/start', async (req, res, next) => {
if (!SSO_CONFIGURED) { try {
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' }); 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. // GET /api/auth/sso/callback - Rueckleitung von Microsoft.
router.get('/sso/callback', async (req, res, next) => { 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.' }); return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
} }
+24
View File
@@ -2,6 +2,7 @@ import express from 'express';
import { pool } from '../db.js'; import { pool } from '../db.js';
import { requireAuth } from '../middleware/auth.js'; import { requireAuth } from '../middleware/auth.js';
import { Match } from '../scanner/models/index.js'; import { Match } from '../scanner/models/index.js';
import { secretStatus } from '../msSecret.js';
import { import {
ARCHIVE_AFTER_DAYS, ARCHIVE_AFTER_DAYS,
DELETE_AFTER_DAYS, DELETE_AFTER_DAYS,
@@ -54,12 +55,35 @@ router.get('/', async (req, res, next) => {
// Scanner-Datenbank nicht erreichbar - dann eben keine Zahl. // 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({ res.json({
acquisition_archive: Number(counts.acquisition_archive), acquisition_archive: Number(counts.acquisition_archive),
acquisition_trash: Number(counts.acquisition_trash), acquisition_trash: Number(counts.acquisition_trash),
opportunity_trash: Number(counts.opportunity_trash), opportunity_trash: Number(counts.opportunity_trash),
mail_trash: Number(counts.mail_trash), mail_trash: Number(counts.mail_trash),
scanner_rejected: scannerRejected, scanner_rejected: scannerRejected,
ms_secret: msSecret,
}); });
} catch (err) { } catch (err) {
next(err); next(err);
+10 -7
View File
@@ -4,7 +4,7 @@ import { requireAdmin } from '../middleware/auth.js';
import { setSetting } from '../settings.js'; import { setSetting } from '../settings.js';
import { import {
MAILBOX, MAILBOX,
MAIL_CONFIGURED, mailConfigured,
MAIL_FOLDER, MAIL_FOLDER,
MAIL_SETTING_DEFAULTS, MAIL_SETTING_DEFAULTS,
SCHEDULER_ENABLED, SCHEDULER_ENABLED,
@@ -70,6 +70,7 @@ router.get('/status', async (req, res, next) => {
try { try {
const settings = await mailSettings(); const settings = await mailSettings();
const state = await getSyncState(); const state = await getSyncState();
const configured = await mailConfigured();
const [[counts]] = await pool.query( const [[counts]] = await pool.query(
`SELECT `SELECT
@@ -86,8 +87,8 @@ router.get('/status', async (req, res, next) => {
); );
res.json({ res.json({
configured: MAIL_CONFIGURED, configured,
missing: MAIL_CONFIGURED ? [] : missingCredentials(), missing: configured ? [] : await missingCredentials(),
mailbox: MAILBOX, mailbox: MAILBOX,
folder: MAIL_FOLDER, folder: MAIL_FOLDER,
scheduler_enabled: SCHEDULER_ENABLED, 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 // POST /api/mail/test - Token holen und wirklich auf das Postfach zugreifen
router.post('/test', requireAdmin, async (req, res, next) => { router.post('/test', requireAdmin, async (req, res, next) => {
try { try {
if (!MAIL_CONFIGURED) { if (!(await mailConfigured())) {
return res.status(400).json({ return res.status(400).json({
ok: false, ok: false,
stage: 'konfiguration', stage: 'konfiguration',
error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}`, error: `Zugangsdaten fehlen: ${(await missingCredentials()).join(', ')}`,
hint: 'Werte in server/.env setzen und den Dienst neu starten.', 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 // POST /api/mail/sync - Abgleich sofort anstossen
router.post('/sync', requireAdmin, async (req, res, next) => { router.post('/sync', requireAdmin, async (req, res, next) => {
try { try {
if (!MAIL_CONFIGURED) { if (!(await mailConfigured())) {
return res.status(409).json({ error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}` }); return res.status(409).json({
error: `Zugangsdaten fehlen: ${(await missingCredentials()).join(', ')}`,
});
} }
if (isMailSyncRunning()) { if (isMailSyncRunning()) {
return res.status(409).json({ error: 'Ein Abgleich läuft bereits. Bitte kurz warten.' }); return res.status(409).json({ error: 'Ein Abgleich läuft bereits. Bitte kurz warten.' });
+13 -9
View File
@@ -12,9 +12,10 @@
* Client-ID zu sehen; er kennt nur /api/auth/sso/start. * 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 TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
export const CLIENT_ID = (process.env.MS_CLIENT_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. */ /** Hauptschalter. Ohne ausdrueckliches "true" bleibt die Anmeldung per Microsoft aus. */
const ENABLED = process.env.MS_SSO_ENABLED === 'true'; 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. */ /** Angefragte delegierte Rechte. Alle vier sind in Entra bereits zugestimmt. */
export const SCOPES = ['openid', 'profile', 'email', 'offline_access', 'User.Read']; 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); * Ohne diese Werte ist die Anmeldung nicht betriebsbereit. Asynchron, weil das
* Secret auch aus der Datenbank kommen darf (msSecret.js).
export function clientSecret() { */
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.'); export async function ssoConfigured() {
return CLIENT_SECRET; 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, * Was zum Betrieb fehlt - fuer eine brauchbare Meldung in den Einstellungen,
* ohne Werte zu verraten. * ohne Werte zu verraten.
*/ */
export function missingSsoConfig() { export async function missingSsoConfig() {
const missing = []; const missing = [];
if (!ENABLED) missing.push('MS_SSO_ENABLED'); if (!ENABLED) missing.push('MS_SSO_ENABLED');
if (!TENANT_ID) missing.push('MS_TENANT_ID'); if (!TENANT_ID) missing.push('MS_TENANT_ID');
if (!CLIENT_ID) missing.push('MS_CLIENT_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'); if (!REDIRECT_URI) missing.push('MS_SSO_REDIRECT_URI');
return missing; return missing;
} }
+2 -2
View File
@@ -123,10 +123,10 @@ async function postToken(body) {
} }
/** Tauscht den Code gegen Tokens. Der Verifier weist uns als Starter des Vorgangs aus. */ /** 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({ return postToken({
client_id: CLIENT_ID, client_id: CLIENT_ID,
client_secret: clientSecret(), client_secret: await clientSecret(),
grant_type: 'authorization_code', grant_type: 'authorization_code',
code, code,
redirect_uri: REDIRECT_URI, redirect_uri: REDIRECT_URI,
+76
View File
@@ -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);
+6 -3
View File
@@ -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_REDIRECT_URI = 'https://crm.perinco.de/api/auth/sso/callback';
process.env.MS_SSO_ALLOWED_DOMAINS = 'perinco.de'; 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 { const {
SsoError, SsoError,
beginLogin, beginLogin,
@@ -62,8 +62,11 @@ const validClaims = {
console.log('\nAnmeldung ueber Microsoft (SSO)'); console.log('\nAnmeldung ueber Microsoft (SSO)');
console.log('\n Konfiguration'); console.log('\n Konfiguration');
check('vollstaendige Konfiguration gilt als betriebsbereit', SSO_CONFIGURED === true); // Ohne erreichbare Datenbank faellt das Secret auf die .env zurueck - genau
check('nichts fehlt', missingSsoConfig().length === 0, missingSsoConfig().join(', ')); // 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('eigene Domain erlaubt', domainAllowed('sascha.wolf@perinco.de') === true);
check('fremde Domain abgewiesen', domainAllowed('jemand@example.com') === false); check('fremde Domain abgewiesen', domainAllowed('jemand@example.com') === false);
check('Adresse ohne Domain abgewiesen', domainAllowed('kaputt') === false); check('Adresse ohne Domain abgewiesen', domainAllowed('kaputt') === false);
+36
View File
@@ -228,6 +228,42 @@ const App: React.FC = () => {
onToggleSidebar={() => setIsSidebarOpen((open) => !open)} 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 && (
<button
type="button"
onClick={() => goTo('Einstellungen', { tab: 'sso' })}
className={`flex w-full items-center gap-3 border-b px-4 py-3 text-left text-sm font-semibold transition md:px-6 ${
badges.ms_secret.level === 'abgelaufen'
? 'border-red-200 bg-red-50 text-red-800 hover:bg-red-100'
: 'border-amber-200 bg-amber-50 text-amber-900 hover:bg-amber-100'
}`}
>
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"
/>
</svg>
<span>
{badges.ms_secret.level === 'abgelaufen'
? 'Das Microsoft-Client-Secret ist abgelaufen - Anmeldung über Microsoft und Postfachabgleich stehen still.'
: `Das Microsoft-Client-Secret läuft in ${badges.ms_secret.days_left} ${
badges.ms_secret.days_left === 1 ? 'Tag' : 'Tagen'
} ab und muss erneuert werden.`}
</span>
<span className="ml-auto shrink-0 whitespace-nowrap underline">Einstellungen öffnen</span>
</button>
)}
<div className="scrollbar-slim flex-1 overflow-y-auto p-4 md:p-6"> <div className="scrollbar-slim flex-1 overflow-y-auto p-4 md:p-6">
{currentView === 'Dashboard' && ( {currentView === 'Dashboard' && (
<Dashboard opportunities={opportunities} events={events} isLoading={isDataLoading} /> <Dashboard opportunities={opportunities} events={events} isLoading={isDataLoading} />
+250 -7
View File
@@ -1,15 +1,16 @@
import React, { useEffect, useState } from 'react'; 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'; import { useToast } from '../Toast';
/* /*
* Anmeldung über Microsoft Entra - Adminsicht. * Anmeldung über Microsoft Entra - Adminsicht.
* *
* Hier lässt sich nichts umstellen, und das ist Absicht: Tenant, Client-ID, * Tenant-ID, Client-ID und Umleitungsadresse stehen in server/.env und sind
* Secret und Umleitungsadresse stehen in server/.env und tauchen damit weder in * hier nur ablesbar. Das Client-Secret ist die Ausnahme: es läuft in Entra ab
* der Oberfläche noch in einem Datenbank-Dump auf. Diese Seite zeigt nur, ob * und muss dann erneuert werden, ohne dass jemand auf den Server muss. Es geht
* die Anbindung steht, und nennt die Werte, die in Entra eingetragen sein * deshalb hier hinein - verschlüsselt in die Datenbank - kommt aber nie wieder
* müssen. * heraus. Angezeigt wird nur, woher es stammt und wie lange es noch gilt.
*/ */
const Fact: React.FC<{ label: string; value: React.ReactNode }> = ({ label, value }) => ( 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
</div> </div>
); );
/** 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 (
<section className={`rounded-2xl border border-l-4 border-ink-200 bg-surface p-5 ${tone.accent}`}>
<h3 className="text-base text-ink-900">Client-Secret</h3>
<p className="mt-1 text-xs text-ink-400">
Gilt für Anmeldung <em>und</em> Postfachanbindung - es ist dieselbe App-Registrierung. Das
Secret wird verschlüsselt gespeichert und nie wieder angezeigt.
</p>
<dl className="mt-4 space-y-2">
<Fact
label="Quelle"
value={
secret.source === 'db'
? 'hier hinterlegt'
: secret.source === 'env'
? 'server/.env'
: secret.source === 'db-unlesbar'
? 'hinterlegt, aber nicht entschlüsselbar'
: 'fehlt'
}
/>
<Fact label="Läuft ab am" value={formatDate(secret.expires_at)} />
<Fact
label="Verbleibend"
value={
secret.days_left === null ? (
'—'
) : (
<span className={tone.text}>
{secret.days_left < 0
? `seit ${Math.abs(secret.days_left)} Tagen abgelaufen`
: `${secret.days_left} ${secret.days_left === 1 ? 'Tag' : 'Tage'}`}
</span>
)
}
/>
<Fact
label="Zuletzt geändert"
value={secret.saved_at ? new Date(secret.saved_at).toLocaleString('de-DE') : '—'}
/>
</dl>
{tone.note && (
<p className={`mt-4 rounded-xl border border-current/20 bg-current/5 px-4 py-3 text-sm font-semibold ${tone.text}`}>
{tone.note}
</p>
)}
{secret.source === 'db-unlesbar' && (
<p className="mt-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm font-semibold text-red-700">
Das gespeicherte Secret lässt sich nicht entschlüsseln. Das passiert, wenn
<code className="mx-1">JWT_SECRET</code> bzw. <code className="mx-1">CRM_ENCRYPTION_KEY</code>
gewechselt wurde. Einfach unten neu eintragen.
</p>
)}
<form onSubmit={handleSubmit} className="mt-5 space-y-4">
<div>
<label className={labelClass} htmlFor="ms-secret">
Neues Secret
</label>
<input
id="ms-secret"
type="password"
autoComplete="off"
spellCheck={false}
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="Wert aus Entra → Zertifikate & Geheimnisse"
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
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.
</p>
</div>
<div>
<label className={labelClass} htmlFor="ms-secret-expires">
Ablaufdatum
</label>
<input
id="ms-secret-expires"
type="date"
value={expiresAt}
onChange={(event) => setExpiresAt(event.target.value)}
className={inputClass}
/>
<p className="mt-1 text-xs text-ink-400">
Steht in Entra neben dem Geheimnis. Ab {secret.warn_days} Tagen vorher weist das CRM alle
Administratoren darauf hin.
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
type="submit"
disabled={!canSave || isSaving}
className="rounded-xl bg-brand-500 px-4 py-2.5 text-sm font-bold text-white transition hover:bg-brand-600 disabled:opacity-50"
>
{isSaving ? 'Speichere...' : 'Speichern'}
</button>
{secret.source === 'db' || secret.source === 'db-unlesbar' ? (
<button
type="button"
onClick={handleClear}
disabled={isClearing}
className="rounded-xl border border-ink-200 px-4 py-2.5 text-sm font-bold text-ink-600 transition hover:border-red-300 hover:text-red-700 disabled:opacity-50"
>
{isClearing ? 'Verwerfe...' : 'Hinterlegtes Secret verwerfen'}
</button>
) : null}
</div>
</form>
</section>
);
};
export const SsoSettings: React.FC = () => { export const SsoSettings: React.FC = () => {
const { addToast } = useToast(); const { addToast } = useToast();
const [config, setConfig] = useState<SsoConfig | null>(null); const [config, setConfig] = useState<SsoConfig | null>(null);
@@ -75,12 +290,18 @@ export const SsoSettings: React.FC = () => {
<div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800"> <div className="mt-4 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-800">
Fehlt in <code>server/.env</code>: {config.missing.join(', ')} Fehlt in <code>server/.env</code>: {config.missing.join(', ')}
<span className="mt-1 block text-xs font-normal"> <span className="mt-1 block text-xs font-normal">
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.
</span> </span>
</div> </div>
)} )}
</section> </section>
<SecretSection
secret={config.secret}
onChange={(secret) => setConfig((prev) => (prev ? { ...prev, secret } : prev))}
/>
<section className="rounded-2xl border border-ink-200 bg-surface p-5"> <section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Wie es funktioniert</h3> <h3 className="text-base text-ink-900">Wie es funktioniert</h3>
<ul className="mt-4 space-y-3 text-sm text-ink-600"> <ul className="mt-4 space-y-3 text-sm text-ink-600">
@@ -105,6 +326,28 @@ export const SsoSettings: React.FC = () => {
</li> </li>
</ul> </ul>
</section> </section>
<section className="rounded-2xl border border-ink-200 bg-surface p-5">
<h3 className="text-base text-ink-900">Secret in Entra erneuern</h3>
<ol className="mt-4 space-y-3 text-sm text-ink-600">
<li>
<span className="font-bold text-ink-800">1.</span> Entra → App-Registrierungen → perinco
crm → <em>Zertifikate &amp; Geheimnisse</em> → <em>Neuer geheimer Clientschlüssel</em>.
</li>
<li>
<span className="font-bold text-ink-800">2.</span> Wert sofort kopieren - Entra zeigt ihn
nur einmal an.
</li>
<li>
<span className="font-bold text-ink-800">3.</span> Hier eintragen, Ablaufdatum aus Entra
übernehmen, speichern. Das CRM prüft das Secret sofort bei Microsoft.
</li>
<li>
<span className="font-bold text-ink-800">4.</span> Erst danach das alte Geheimnis in
Entra löschen.
</li>
</ol>
</section>
</div> </div>
); );
}; };
+33
View File
@@ -119,11 +119,24 @@ export const authApi = {
*/ */
export const SSO_START_URL = '/api/auth/sso/start'; 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 { export interface SsoConfig {
enabled: boolean; enabled: boolean;
missing: string[]; missing: string[];
redirect_uri: string | null; redirect_uri: string | null;
allowed_domains: string[]; allowed_domains: string[];
secret: MsSecretStatus;
} }
export const ssoApi = { 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) ---------- // ---------- Benutzerverwaltung (nur admin) ----------
export interface UserInput { export interface UserInput {
@@ -312,6 +340,11 @@ export interface SidebarBadges {
opportunity_trash: number; opportunity_trash: number;
mail_trash: number; mail_trash: number;
scanner_rejected: number | null; 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 = { export const badgesApi = {