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>
244 lines
8.6 KiB
JavaScript
244 lines
8.6 KiB
JavaScript
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();
|
|
}
|