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:
co-authored by
Claude Opus 5
parent
e271f5c1e8
commit
c8eaaa1947
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+31
-1
@@ -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
|
||||
|
||||
+4
-1
@@ -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.
|
||||
|
||||
@@ -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=<Meldung>`; 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 →<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
|
||||
|
||||
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 |
|
||||
|
||||
Reference in New Issue
Block a user