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
@@ -1,4 +1,5 @@
|
||||
import { getSettings, asBool, asInt } from '../settings.js';
|
||||
import { hasSecret, requireSecret } from '../msSecret.js';
|
||||
|
||||
/*
|
||||
* Konfiguration der M365-Anbindung.
|
||||
@@ -14,7 +15,6 @@ import { getSettings, asBool, asInt } from '../settings.js';
|
||||
|
||||
export const TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
|
||||
export const CLIENT_ID = (process.env.MS_CLIENT_ID || '').trim();
|
||||
const CLIENT_SECRET = (process.env.MS_CLIENT_SECRET || '').trim();
|
||||
export const MAILBOX = (process.env.MS_MAILBOX || 'crm@perinco.de').trim().toLowerCase();
|
||||
|
||||
/** Ordner im Postfach, der abgeglichen wird. `inbox` ist ein Wellknown-Name. */
|
||||
@@ -23,23 +23,27 @@ export const MAIL_FOLDER = (process.env.MS_MAIL_FOLDER || 'inbox').trim();
|
||||
/** Harter Aus-Schalter fuer den Hintergrundabgleich, unabhaengig von der DB. */
|
||||
export const SCHEDULER_ENABLED = process.env.MAIL_SYNC !== 'false';
|
||||
|
||||
/** Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. */
|
||||
export const MAIL_CONFIGURED = Boolean(TENANT_ID && CLIENT_ID && CLIENT_SECRET && MAILBOX);
|
||||
|
||||
export function clientSecret() {
|
||||
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
|
||||
return CLIENT_SECRET;
|
||||
/*
|
||||
* Ohne diese vier Werte ist die Anbindung nicht betriebsbereit. Die Pruefung ist
|
||||
* asynchron, seit das Secret auch in der Datenbank liegen darf (siehe
|
||||
* msSecret.js) - vorher war es eine Konstante aus der .env.
|
||||
*/
|
||||
export async function mailConfigured() {
|
||||
return Boolean(TENANT_ID && CLIENT_ID && MAILBOX && (await hasSecret()));
|
||||
}
|
||||
|
||||
/** Das gueltige Client-Secret: Datenbank vor .env. */
|
||||
export const clientSecret = requireSecret;
|
||||
|
||||
/**
|
||||
* Welche Zugangsdaten fehlen - fuer eine brauchbare Meldung in der Oberflaeche,
|
||||
* ohne Werte zu verraten.
|
||||
*/
|
||||
export function missingCredentials() {
|
||||
export async function missingCredentials() {
|
||||
const missing = [];
|
||||
if (!TENANT_ID) missing.push('MS_TENANT_ID');
|
||||
if (!CLIENT_ID) missing.push('MS_CLIENT_ID');
|
||||
if (!CLIENT_SECRET) missing.push('MS_CLIENT_SECRET');
|
||||
if (!(await hasSecret())) missing.push('MS_CLIENT_SECRET');
|
||||
if (!MAILBOX) missing.push('MS_MAILBOX');
|
||||
return missing;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
async function fetchAccessToken() {
|
||||
const body = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret(),
|
||||
client_secret: await clientSecret(),
|
||||
scope: 'https://graph.microsoft.com/.default',
|
||||
grant_type: 'client_credentials',
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import cron from 'node-cron';
|
||||
import { MAIL_CONFIGURED, SCHEDULER_ENABLED, mailSettings } from './config.js';
|
||||
import { SCHEDULER_ENABLED, mailConfigured, mailSettings } from './config.js';
|
||||
import { getSyncState, saveSyncState } from './store.js';
|
||||
import { isMailSyncRunning, runMailSync } from './sync.js';
|
||||
|
||||
@@ -26,7 +26,7 @@ export function isMailSchedulerRunning() {
|
||||
}
|
||||
|
||||
export async function startMailScheduler() {
|
||||
if (!MAIL_CONFIGURED) {
|
||||
if (!(await mailConfigured())) {
|
||||
console.log('[Mail] Zugangsdaten fehlen - Abgleich bleibt aus, die Oberflaeche laeuft.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
||||
import {
|
||||
MAILBOX,
|
||||
MAIL_FOLDER,
|
||||
MAIL_CONFIGURED,
|
||||
mailConfigured,
|
||||
MAX_PAGES_PER_RUN,
|
||||
MAX_BODY_CHARS,
|
||||
mailSettings,
|
||||
@@ -156,8 +156,8 @@ async function fetchDeltaPage(url, { allowFilterFallback = false } = {}) {
|
||||
* Laeuft immer nur einer; ein zweiter Aufruf kommt sofort mit skipped zurueck.
|
||||
*/
|
||||
export async function runMailSync({ trigger = 'manuell' } = {}) {
|
||||
if (!MAIL_CONFIGURED) {
|
||||
return { skipped: 'nicht-konfiguriert', missing: missingCredentials() };
|
||||
if (!(await mailConfigured())) {
|
||||
return { skipped: 'nicht-konfiguriert', missing: await missingCredentials() };
|
||||
}
|
||||
if (running) return { skipped: 'laeuft-bereits' };
|
||||
|
||||
|
||||
@@ -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
@@ -11,10 +11,18 @@ import {
|
||||
import {
|
||||
ALLOWED_DOMAINS,
|
||||
REDIRECT_URI,
|
||||
SSO_CONFIGURED,
|
||||
domainAllowed,
|
||||
missingSsoConfig,
|
||||
ssoConfigured,
|
||||
} from '../sso/config.js';
|
||||
import {
|
||||
clearStoredSecret,
|
||||
normalizeExpiry,
|
||||
saveSecret,
|
||||
secretStatus,
|
||||
verifySecret,
|
||||
} from '../msSecret.js';
|
||||
import { resetTokenCache } from '../mail/graph.js';
|
||||
import {
|
||||
SsoError,
|
||||
beginLogin,
|
||||
@@ -183,31 +191,99 @@ function redirectWithError(res, message) {
|
||||
|
||||
// GET /api/auth/sso/status - oeffentlich, damit die Anmeldemaske weiss, ob sie
|
||||
// den Microsoft-Knopf zeigen soll. Verraet nur ja/nein.
|
||||
router.get('/sso/status', (req, res) => {
|
||||
res.json({ enabled: SSO_CONFIGURED });
|
||||
router.get('/sso/status', async (req, res, next) => {
|
||||
try {
|
||||
res.json({ enabled: await ssoConfigured() });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/sso/config - Adminsicht fuer die Einstellungen.
|
||||
router.get('/sso/config', requireAuth, requireAdmin, (req, res) => {
|
||||
res.json({
|
||||
enabled: SSO_CONFIGURED,
|
||||
missing: missingSsoConfig(),
|
||||
redirect_uri: REDIRECT_URI || null,
|
||||
allowed_domains: ALLOWED_DOMAINS,
|
||||
});
|
||||
router.get('/sso/config', requireAuth, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
res.json({
|
||||
enabled: await ssoConfigured(),
|
||||
missing: await missingSsoConfig(),
|
||||
redirect_uri: REDIRECT_URI || null,
|
||||
allowed_domains: ALLOWED_DOMAINS,
|
||||
secret: await secretStatus(),
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Client-Secret der App-Registrierung
|
||||
*
|
||||
* Dasselbe Secret bedient Anmeldung und Postfachanbindung. Es liegt
|
||||
* verschluesselt in crm_settings (siehe msSecret.js); der Wert aus der .env
|
||||
* bleibt Rueckfallebene. Ausgeliefert wird das Secret nie - weder ganz noch
|
||||
* teilweise, auch nicht an Administratoren.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
// PUT /api/auth/microsoft-secret { secret?, expires_at? }
|
||||
router.put('/microsoft-secret', requireAuth, requireAdmin, async (req, res, next) => {
|
||||
const secret = String(req.body?.secret ?? '').trim();
|
||||
const hasExpiry = req.body?.expires_at !== undefined;
|
||||
|
||||
const expiry = hasExpiry ? normalizeExpiry(req.body.expires_at) : { value: null };
|
||||
if (expiry.error) return res.status(400).json({ error: expiry.error });
|
||||
|
||||
if (!secret && !hasExpiry) {
|
||||
return res.status(400).json({ error: 'Kein Secret und kein Ablaufdatum übergeben.' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Erst bei Microsoft ausprobieren, dann speichern: ein vertipptes Secret
|
||||
// legt sonst still Anmeldung und Postfachabgleich lahm.
|
||||
if (secret) {
|
||||
const check = await verifySecret(secret);
|
||||
if (!check.ok) {
|
||||
return res.status(400).json({
|
||||
error: `Microsoft hat dieses Secret abgelehnt: ${check.error}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const status = await saveSecret({ secret, expiresAt: expiry.value });
|
||||
|
||||
// Das zwischengespeicherte Graph-Token stammt noch vom alten Secret.
|
||||
if (secret) resetTokenCache();
|
||||
|
||||
res.json({ ok: true, verified: Boolean(secret), secret: status });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE /api/auth/microsoft-secret - hinterlegtes Secret verwerfen, .env gilt wieder
|
||||
router.delete('/microsoft-secret', requireAuth, requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
const status = await clearStoredSecret();
|
||||
resetTokenCache();
|
||||
res.json({ ok: true, secret: status });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/auth/sso/start - Beginn der Anmeldung, leitet zu Microsoft weiter.
|
||||
router.get('/sso/start', (req, res) => {
|
||||
if (!SSO_CONFIGURED) {
|
||||
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
|
||||
router.get('/sso/start', async (req, res, next) => {
|
||||
try {
|
||||
if (!(await ssoConfigured())) {
|
||||
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
|
||||
}
|
||||
res.redirect(302, beginLogin());
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
res.redirect(302, beginLogin());
|
||||
});
|
||||
|
||||
// GET /api/auth/sso/callback - Rueckleitung von Microsoft.
|
||||
router.get('/sso/callback', async (req, res, next) => {
|
||||
if (!SSO_CONFIGURED) {
|
||||
if (!(await ssoConfigured())) {
|
||||
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import { pool } from '../db.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
import { Match } from '../scanner/models/index.js';
|
||||
import { secretStatus } from '../msSecret.js';
|
||||
import {
|
||||
ARCHIVE_AFTER_DAYS,
|
||||
DELETE_AFTER_DAYS,
|
||||
@@ -54,12 +55,35 @@ router.get('/', async (req, res, next) => {
|
||||
// Scanner-Datenbank nicht erreichbar - dann eben keine Zahl.
|
||||
}
|
||||
|
||||
/*
|
||||
* Hinweis auf das bald ablaufende Client-Secret. Er reist mit den Zaehlern
|
||||
* mit, weil die ohnehin bei jedem Laden der App geholt werden - ein eigener
|
||||
* Endpunkt waere eine zweite Anfrage fuer eine Zahl. Nur Administratoren
|
||||
* bekommen ihn: nur sie koennen das Secret erneuern.
|
||||
*/
|
||||
let msSecret = null;
|
||||
if (req.user.role === 'admin') {
|
||||
try {
|
||||
const status = await secretStatus();
|
||||
if (status.level === 'warnung' || status.level === 'abgelaufen') {
|
||||
msSecret = {
|
||||
level: status.level,
|
||||
days_left: status.days_left,
|
||||
expires_at: status.expires_at,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Kein Grund, die Zaehler scheitern zu lassen.
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
acquisition_archive: Number(counts.acquisition_archive),
|
||||
acquisition_trash: Number(counts.acquisition_trash),
|
||||
opportunity_trash: Number(counts.opportunity_trash),
|
||||
mail_trash: Number(counts.mail_trash),
|
||||
scanner_rejected: scannerRejected,
|
||||
ms_secret: msSecret,
|
||||
});
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { requireAdmin } from '../middleware/auth.js';
|
||||
import { setSetting } from '../settings.js';
|
||||
import {
|
||||
MAILBOX,
|
||||
MAIL_CONFIGURED,
|
||||
mailConfigured,
|
||||
MAIL_FOLDER,
|
||||
MAIL_SETTING_DEFAULTS,
|
||||
SCHEDULER_ENABLED,
|
||||
@@ -70,6 +70,7 @@ router.get('/status', async (req, res, next) => {
|
||||
try {
|
||||
const settings = await mailSettings();
|
||||
const state = await getSyncState();
|
||||
const configured = await mailConfigured();
|
||||
|
||||
const [[counts]] = await pool.query(
|
||||
`SELECT
|
||||
@@ -86,8 +87,8 @@ router.get('/status', async (req, res, next) => {
|
||||
);
|
||||
|
||||
res.json({
|
||||
configured: MAIL_CONFIGURED,
|
||||
missing: MAIL_CONFIGURED ? [] : missingCredentials(),
|
||||
configured,
|
||||
missing: configured ? [] : await missingCredentials(),
|
||||
mailbox: MAILBOX,
|
||||
folder: MAIL_FOLDER,
|
||||
scheduler_enabled: SCHEDULER_ENABLED,
|
||||
@@ -167,11 +168,11 @@ router.post('/settings', requireAdmin, async (req, res, next) => {
|
||||
// POST /api/mail/test - Token holen und wirklich auf das Postfach zugreifen
|
||||
router.post('/test', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!MAIL_CONFIGURED) {
|
||||
if (!(await mailConfigured())) {
|
||||
return res.status(400).json({
|
||||
ok: false,
|
||||
stage: 'konfiguration',
|
||||
error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}`,
|
||||
error: `Zugangsdaten fehlen: ${(await missingCredentials()).join(', ')}`,
|
||||
hint: 'Werte in server/.env setzen und den Dienst neu starten.',
|
||||
});
|
||||
}
|
||||
@@ -184,8 +185,10 @@ router.post('/test', requireAdmin, async (req, res, next) => {
|
||||
// POST /api/mail/sync - Abgleich sofort anstossen
|
||||
router.post('/sync', requireAdmin, async (req, res, next) => {
|
||||
try {
|
||||
if (!MAIL_CONFIGURED) {
|
||||
return res.status(409).json({ error: `Zugangsdaten fehlen: ${missingCredentials().join(', ')}` });
|
||||
if (!(await mailConfigured())) {
|
||||
return res.status(409).json({
|
||||
error: `Zugangsdaten fehlen: ${(await missingCredentials()).join(', ')}`,
|
||||
});
|
||||
}
|
||||
if (isMailSyncRunning()) {
|
||||
return res.status(409).json({ error: 'Ein Abgleich läuft bereits. Bitte kurz warten.' });
|
||||
|
||||
@@ -12,9 +12,10 @@
|
||||
* Client-ID zu sehen; er kennt nur /api/auth/sso/start.
|
||||
*/
|
||||
|
||||
import { hasSecret, requireSecret } from '../msSecret.js';
|
||||
|
||||
export const TENANT_ID = (process.env.MS_TENANT_ID || '').trim();
|
||||
export const CLIENT_ID = (process.env.MS_CLIENT_ID || '').trim();
|
||||
const CLIENT_SECRET = (process.env.MS_CLIENT_SECRET || '').trim();
|
||||
|
||||
/** Hauptschalter. Ohne ausdrueckliches "true" bleibt die Anmeldung per Microsoft aus. */
|
||||
const ENABLED = process.env.MS_SSO_ENABLED === 'true';
|
||||
@@ -40,24 +41,27 @@ export const GRAPH_ROOT = 'https://graph.microsoft.com/v1.0';
|
||||
/** Angefragte delegierte Rechte. Alle vier sind in Entra bereits zugestimmt. */
|
||||
export const SCOPES = ['openid', 'profile', 'email', 'offline_access', 'User.Read'];
|
||||
|
||||
/** Ohne diese Werte ist die Anmeldung nicht betriebsbereit. */
|
||||
export const SSO_CONFIGURED = Boolean(ENABLED && TENANT_ID && CLIENT_ID && CLIENT_SECRET && REDIRECT_URI);
|
||||
|
||||
export function clientSecret() {
|
||||
if (!CLIENT_SECRET) throw new Error('MS_CLIENT_SECRET ist nicht gesetzt.');
|
||||
return CLIENT_SECRET;
|
||||
/*
|
||||
* Ohne diese Werte ist die Anmeldung nicht betriebsbereit. Asynchron, weil das
|
||||
* Secret auch aus der Datenbank kommen darf (msSecret.js).
|
||||
*/
|
||||
export async function ssoConfigured() {
|
||||
return Boolean(ENABLED && TENANT_ID && CLIENT_ID && REDIRECT_URI && (await hasSecret()));
|
||||
}
|
||||
|
||||
/** Das gueltige Client-Secret: Datenbank vor .env. */
|
||||
export const clientSecret = requireSecret;
|
||||
|
||||
/**
|
||||
* Was zum Betrieb fehlt - fuer eine brauchbare Meldung in den Einstellungen,
|
||||
* ohne Werte zu verraten.
|
||||
*/
|
||||
export function missingSsoConfig() {
|
||||
export async function missingSsoConfig() {
|
||||
const missing = [];
|
||||
if (!ENABLED) missing.push('MS_SSO_ENABLED');
|
||||
if (!TENANT_ID) missing.push('MS_TENANT_ID');
|
||||
if (!CLIENT_ID) missing.push('MS_CLIENT_ID');
|
||||
if (!CLIENT_SECRET) missing.push('MS_CLIENT_SECRET');
|
||||
if (!(await hasSecret())) missing.push('MS_CLIENT_SECRET');
|
||||
if (!REDIRECT_URI) missing.push('MS_SSO_REDIRECT_URI');
|
||||
return missing;
|
||||
}
|
||||
|
||||
@@ -123,10 +123,10 @@ async function postToken(body) {
|
||||
}
|
||||
|
||||
/** Tauscht den Code gegen Tokens. Der Verifier weist uns als Starter des Vorgangs aus. */
|
||||
export function exchangeCode(code, verifier) {
|
||||
export async function exchangeCode(code, verifier) {
|
||||
return postToken({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret(),
|
||||
client_secret: await clientSecret(),
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
|
||||
Reference in New Issue
Block a user