feat(auth): Anmeldung ueber Microsoft Entra (SSO)
OpenID Connect mit Authorization Code Flow und PKCE, vollstaendig
serverseitig - der Browser sieht weder Tenant- noch Client-ID. Genutzt wird
dieselbe App-Registrierung wie die Postfachanbindung, also kein zweites
Secret; delegiert statt app-only.
Nach dem Token-Tausch beweist ein Aufruf von /me bei Graph, dass das
Zugriffstoken gilt, und liefert die Objekt-ID. Sie landet in der neuen Spalte
crm_users.entra_oid und ist danach die stabile Verknuepfung - die E-Mail-
Adresse eines Menschen kann sich aendern, die Objekt-ID nicht.
Bewusst so entschieden:
- Es werden keine Konten angelegt. Wer kein CRM-Konto mit derselben Adresse
hat, wird abgewiesen; Zugriff vergibt weiter die Nutzerverwaltung.
- Die Anmeldung mit Benutzername und Passwort bleibt fuer alle bestehen -
Notzugang, falls Entra oder das Secret ausfaellt.
- Das CRM-Token kommt als Einmal-Ticket zurueck, nicht in der Adresszeile:
die Rueckleitung ist ein GET und stuende sonst in Browserverlauf und
Apache-Log.
- Keine Signaturpruefung des ID-Tokens und damit keine JWKS-Bibliothek. Es
kommt ueber TLS direkt vom Token-Endpunkt (OIDC Core 3.1.3.7); geprueft
werden aud, tid, nonce und exp, und der Graph-Aufruf belegt den Rest.
Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
bisher.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2d84bac91d
commit
419e57b55d
@@ -94,3 +94,29 @@ SCANNER_SCHEDULER_OFFSET=0
|
||||
|
||||
# Fallback fuer den OpenAI-Key; bevorzugt wird der Wert aus den Einstellungen.
|
||||
# OPENAI_API_KEY=
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Anmeldung ueber Microsoft Entra (SSO)
|
||||
# ---------------------------------------------------------------------------
|
||||
# Nutzt dieselbe App-Registrierung wie die Postfachanbindung - also dieselben
|
||||
# MS_TENANT_ID / MS_CLIENT_ID / MS_CLIENT_SECRET, kein zweites Secret.
|
||||
#
|
||||
# In Entra zusaetzlich noetig:
|
||||
# - Authentifizierung > Plattform hinzufuegen > Web
|
||||
# Umleitungs-URI: der Wert von MS_SSO_REDIRECT_URI, zeichengenau
|
||||
# Implizite Genehmigung: beide Haken aus
|
||||
# - Delegierte Berechtigungen mit Administratorzustimmung:
|
||||
# openid, profile, User.Read (Foto zusaetzlich: ProfilePhoto.Read.All)
|
||||
#
|
||||
# Es werden keine Konten angelegt: wer sich per Microsoft anmeldet, braucht ein
|
||||
# bestehendes CRM-Konto mit derselben E-Mail-Adresse. Die Anmeldung mit
|
||||
# Benutzername und Passwort bleibt parallel bestehen.
|
||||
#
|
||||
# Ohne MS_SSO_ENABLED=true bleibt alles aus und die Anmeldemaske sieht aus wie
|
||||
# bisher.
|
||||
MS_SSO_ENABLED=false
|
||||
MS_SSO_REDIRECT_URI=https://crm.perinco.de/api/auth/sso/callback
|
||||
|
||||
# Optionale Einschraenkung auf E-Mail-Domains, kommagetrennt. Leer = keine
|
||||
# zusaetzliche Pruefung; Tenant und vorhandenes CRM-Konto begrenzen ohnehin.
|
||||
MS_SSO_ALLOWED_DOMAINS=perinco.de
|
||||
|
||||
+1
-1
@@ -12,7 +12,7 @@
|
||||
"start": "node src/server.js",
|
||||
"dev": "node --watch src/server.js",
|
||||
"migrate": "node src/migrate.js",
|
||||
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs && node test/sessionUser.test.mjs"
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
+175
-1
@@ -1,13 +1,31 @@
|
||||
import express from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { pool } from '../db.js';
|
||||
import { signToken, requireAuth } from '../middleware/auth.js';
|
||||
import { signToken, requireAuth, requireAdmin } from '../middleware/auth.js';
|
||||
import {
|
||||
normalizeAvatar,
|
||||
normalizeColor,
|
||||
normalizeTheme,
|
||||
normalizeThemeMode,
|
||||
} from '../userFields.js';
|
||||
import {
|
||||
ALLOWED_DOMAINS,
|
||||
REDIRECT_URI,
|
||||
SSO_CONFIGURED,
|
||||
domainAllowed,
|
||||
missingSsoConfig,
|
||||
} from '../sso/config.js';
|
||||
import {
|
||||
SsoError,
|
||||
beginLogin,
|
||||
createTicket,
|
||||
exchangeCode,
|
||||
fetchPhoto,
|
||||
fetchProfile,
|
||||
redeemTicket,
|
||||
takePendingLogin,
|
||||
verifyIdToken,
|
||||
} from '../sso/flow.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -135,5 +153,161 @@ router.post('/change-password', requireAuth, async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
* Anmeldung ueber Microsoft Entra (OpenID Connect)
|
||||
*
|
||||
* Der Browser sieht von der Anbindung nur drei Endpunkte: /sso/status fuer den
|
||||
* Knopf, /sso/start fuer die Umleitung und /sso/ticket zum Abholen des
|
||||
* CRM-Tokens. Tenant, Client-ID, Secret und die Antwort von Microsoft bleiben
|
||||
* beim Backend.
|
||||
*
|
||||
* Es werden keine Konten angelegt: wer sich anmeldet, braucht ein bestehendes
|
||||
* crm_users-Konto mit derselben E-Mail-Adresse. Wer Zugriff bekommt, entscheidet
|
||||
* damit weiterhin allein die Nutzerverwaltung.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** Basisadresse der Oberflaeche - dieselbe Herkunft wie der Redirect-URI. */
|
||||
function appOrigin() {
|
||||
try {
|
||||
return new URL(REDIRECT_URI).origin;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Zurueck zur Anmeldemaske mit einer Meldung, die das Frontend anzeigt. */
|
||||
function redirectWithError(res, message) {
|
||||
const target = `${appOrigin()}/?sso_error=${encodeURIComponent(message)}`;
|
||||
res.redirect(302, target);
|
||||
}
|
||||
|
||||
// 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 });
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
});
|
||||
|
||||
// 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.' });
|
||||
}
|
||||
res.redirect(302, beginLogin());
|
||||
});
|
||||
|
||||
// GET /api/auth/sso/callback - Rueckleitung von Microsoft.
|
||||
router.get('/sso/callback', async (req, res, next) => {
|
||||
if (!SSO_CONFIGURED) {
|
||||
return res.status(503).json({ error: 'Die Anmeldung über Microsoft ist nicht eingerichtet.' });
|
||||
}
|
||||
|
||||
// Abbruch oder Fehler auf der Microsoft-Seite (z.B. Zustimmung verweigert).
|
||||
if (req.query.error) {
|
||||
const detail = String(req.query.error_description || req.query.error).split('\n')[0];
|
||||
return redirectWithError(res, `Microsoft hat die Anmeldung abgelehnt: ${detail}`);
|
||||
}
|
||||
|
||||
const pending = takePendingLogin(String(req.query.state || ''));
|
||||
if (!pending) {
|
||||
return redirectWithError(res, 'Die Anmeldung ist abgelaufen. Bitte erneut versuchen.');
|
||||
}
|
||||
|
||||
const code = String(req.query.code || '');
|
||||
if (!code) return redirectWithError(res, 'Microsoft hat keinen Anmeldecode geschickt.');
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCode(code, pending.verifier);
|
||||
verifyIdToken(tokens.id_token, pending.nonce);
|
||||
|
||||
const profile = await fetchProfile(tokens.access_token);
|
||||
if (!profile.email) {
|
||||
return redirectWithError(res, 'Das Microsoft-Konto hat keine E-Mail-Adresse.');
|
||||
}
|
||||
if (!domainAllowed(profile.email)) {
|
||||
return redirectWithError(res, 'Diese E-Mail-Domain ist für das CRM nicht freigegeben.');
|
||||
}
|
||||
|
||||
const user = await findSsoUser(profile);
|
||||
if (!user) {
|
||||
return redirectWithError(
|
||||
res,
|
||||
`Für ${profile.email} gibt es kein CRM-Konto. Bitte einen Administrator fragen.`
|
||||
);
|
||||
}
|
||||
if (!user.is_active) {
|
||||
return redirectWithError(res, 'Dieses Benutzerkonto ist deaktiviert.');
|
||||
}
|
||||
|
||||
// Foto nur einmal holen: wer selbst eines gesetzt hat, behaelt es.
|
||||
if (!user.avatar) {
|
||||
const photo = await fetchPhoto(tokens.access_token);
|
||||
if (photo) {
|
||||
await pool.query('UPDATE crm_users SET avatar = ? WHERE id = ?', [photo, user.id]);
|
||||
user.avatar = photo;
|
||||
}
|
||||
}
|
||||
|
||||
const ticket = createTicket({ token: signToken(user), user: publicUser(user) });
|
||||
res.redirect(302, `${appOrigin()}/?sso_ticket=${encodeURIComponent(ticket)}`);
|
||||
} catch (err) {
|
||||
if (err instanceof SsoError) {
|
||||
console.warn('[SSO] Anmeldung fehlgeschlagen:', err.message);
|
||||
return redirectWithError(res, err.message);
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Sucht das CRM-Konto zum angemeldeten Microsoft-Konto.
|
||||
*
|
||||
* Zuerst ueber die Objekt-ID: sie bleibt gleich, auch wenn jemand heiratet und
|
||||
* die Adresse wechselt. Beim ersten Mal gibt es sie noch nicht - dann greift
|
||||
* die E-Mail-Adresse, und die Objekt-ID wird festgeschrieben. Konten, die schon
|
||||
* einer anderen Objekt-ID gehoeren, werden dabei nicht angefasst.
|
||||
*/
|
||||
async function findSsoUser(profile) {
|
||||
const columns = `id, username, email, name, role, color, avatar, theme, theme_mode,
|
||||
is_active, created_at, updated_at`;
|
||||
|
||||
const [byOid] = await pool.query(
|
||||
`SELECT ${columns} FROM crm_users WHERE entra_oid = ? LIMIT 1`,
|
||||
[profile.oid]
|
||||
);
|
||||
if (byOid[0]) return { ...byOid[0], is_active: Boolean(byOid[0].is_active) };
|
||||
|
||||
const candidates = [profile.email, profile.upn].filter(Boolean);
|
||||
const [byMail] = await pool.query(
|
||||
`SELECT ${columns} FROM crm_users
|
||||
WHERE entra_oid IS NULL AND email IN (?) LIMIT 1`,
|
||||
[candidates]
|
||||
);
|
||||
const user = byMail[0];
|
||||
if (!user) return null;
|
||||
|
||||
await pool.query('UPDATE crm_users SET entra_oid = ? WHERE id = ?', [profile.oid, user.id]);
|
||||
return { ...user, is_active: Boolean(user.is_active) };
|
||||
}
|
||||
|
||||
// POST /api/auth/sso/ticket { ticket }
|
||||
// Loest das Einmal-Ticket gegen das CRM-Token ein.
|
||||
router.post('/sso/ticket', (req, res) => {
|
||||
const payload = redeemTicket(String(req.body?.ticket || ''));
|
||||
if (!payload) {
|
||||
return res.status(400).json({ error: 'Die Anmeldung ist abgelaufen. Bitte erneut versuchen.' });
|
||||
}
|
||||
res.json(payload);
|
||||
});
|
||||
|
||||
export default router;
|
||||
export { publicUser };
|
||||
|
||||
@@ -34,6 +34,14 @@ ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color;
|
||||
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme VARCHAR(32) NULL AFTER avatar;
|
||||
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme_mode VARCHAR(16) NULL AFTER theme;
|
||||
|
||||
-- Objekt-ID des Microsoft-Kontos (Entra), gesetzt bei der ersten Anmeldung per
|
||||
-- SSO. Sie ist die stabile Zuordnung: die E-Mail-Adresse eines Menschen kann
|
||||
-- sich aendern, die Objekt-ID nicht. NULL = noch nie per Microsoft angemeldet;
|
||||
-- dann greift der Abgleich ueber die Adresse. Unique, damit nicht zwei
|
||||
-- CRM-Konten an demselben Microsoft-Konto haengen.
|
||||
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS entra_oid VARCHAR(64) NULL AFTER email;
|
||||
ALTER TABLE crm_users ADD UNIQUE INDEX IF NOT EXISTS uq_crm_users_entra_oid (entra_oid);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS crm_leads (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
salutation VARCHAR(32) NULL,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Konfiguration der Anmeldung ueber Microsoft Entra (OpenID Connect).
|
||||
*
|
||||
* Es ist dieselbe App-Registrierung wie bei der Postfachanbindung, aber ein
|
||||
* anderer Zugriffsweg: das Postfach laeuft app-only (Client Credentials), die
|
||||
* Anmeldung delegiert im Namen des Benutzers (Authorization Code + PKCE).
|
||||
* Deshalb werden die Werte hier eigenstaendig gelesen und nicht aus mail/
|
||||
* importiert - die Anmeldung soll auch dann funktionieren, wenn die
|
||||
* Postfachanbindung abgeschaltet ist.
|
||||
*
|
||||
* Zugangsdaten bleiben serverseitig. Der Browser bekommt weder Tenant- noch
|
||||
* Client-ID zu sehen; er kennt nur /api/auth/sso/start.
|
||||
*/
|
||||
|
||||
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';
|
||||
|
||||
/**
|
||||
* Muss in Entra unter "Authentifizierung" als Plattform "Web" eingetragen sein -
|
||||
* zeichengenau, sonst antwortet Entra mit AADSTS50011.
|
||||
*/
|
||||
export const REDIRECT_URI = (process.env.MS_SSO_REDIRECT_URI || '').trim();
|
||||
|
||||
/**
|
||||
* Zusaetzliche Einschraenkung auf E-Mail-Domains. Leer = keine Pruefung; der
|
||||
* Tenant und das vorhandene CRM-Konto begrenzen den Zugang ohnehin.
|
||||
*/
|
||||
export const ALLOWED_DOMAINS = String(process.env.MS_SSO_ALLOWED_DOMAINS || '')
|
||||
.split(',')
|
||||
.map((entry) => entry.trim().toLowerCase().replace(/^@/, ''))
|
||||
.filter(Boolean);
|
||||
|
||||
export const AUTHORITY = `https://login.microsoftonline.com/${TENANT_ID}`;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Was zum Betrieb fehlt - fuer eine brauchbare Meldung in den Einstellungen,
|
||||
* ohne Werte zu verraten.
|
||||
*/
|
||||
export 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 (!REDIRECT_URI) missing.push('MS_SSO_REDIRECT_URI');
|
||||
return missing;
|
||||
}
|
||||
|
||||
/** Erlaubt die Domain-Liste diese Adresse? Leere Liste heisst: alles erlaubt. */
|
||||
export function domainAllowed(email) {
|
||||
if (ALLOWED_DOMAINS.length === 0) return true;
|
||||
const domain = String(email || '').toLowerCase().split('@')[1] || '';
|
||||
return ALLOWED_DOMAINS.includes(domain);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import crypto from 'crypto';
|
||||
import {
|
||||
AUTHORITY,
|
||||
CLIENT_ID,
|
||||
GRAPH_ROOT,
|
||||
REDIRECT_URI,
|
||||
SCOPES,
|
||||
TENANT_ID,
|
||||
clientSecret,
|
||||
} from './config.js';
|
||||
|
||||
/*
|
||||
* Authorization Code Flow mit PKCE, vollstaendig serverseitig.
|
||||
*
|
||||
* Ablauf:
|
||||
* 1. beginLogin() - state, nonce und PKCE-Verifier merken, Browser zu Entra
|
||||
* 2. Entra - Anmeldung beim Benutzer, Rueckleitung mit code + state
|
||||
* 3. exchangeCode()- code gegen Token tauschen (Backchannel mit Client-Secret)
|
||||
* 4. fetchProfile()- /me bei Graph; das ist der Identitaetsnachweis
|
||||
*
|
||||
* Warum der Graph-Aufruf und nicht nur das ID-Token: das ID-Token kommt ueber
|
||||
* TLS direkt vom Token-Endpunkt und ist damit vertrauenswuerdig (OIDC Core
|
||||
* 3.1.3.7 erlaubt in diesem Fall den Verzicht auf die Signaturpruefung), aber
|
||||
* /me beweist zusaetzlich, dass das Zugriffstoken wirklich gilt, und liefert
|
||||
* die Objekt-ID, an der das CRM-Konto haengt.
|
||||
*/
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** Wie lange eine angefangene Anmeldung gueltig bleibt. */
|
||||
const STATE_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
/** Wie lange das Ticket gilt, mit dem das Frontend das CRM-Token abholt. */
|
||||
const TICKET_TTL_MS = 60 * 1000;
|
||||
|
||||
/*
|
||||
* Beide Speicher liegen im Prozess, nicht in der Datenbank: es sind Werte mit
|
||||
* Sekunden- bis Minutenlebensdauer, und das Backend laeuft als einzelne
|
||||
* Instanz. Ein Neustart verwirft hoechstens gerade laufende Anmeldungen - der
|
||||
* Benutzer klickt dann noch einmal.
|
||||
*/
|
||||
const pendingLogins = new Map();
|
||||
const tickets = new Map();
|
||||
|
||||
function prune(store, ttl) {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of store) {
|
||||
if (now - entry.createdAt > ttl) store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
const base64url = (buffer) => buffer.toString('base64url');
|
||||
const randomToken = () => base64url(crypto.randomBytes(32));
|
||||
|
||||
export class SsoError extends Error {
|
||||
constructor(message, { code = null } = {}) {
|
||||
super(message);
|
||||
this.name = 'SsoError';
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Beginnt eine Anmeldung und liefert die URL, auf die der Browser umgeleitet
|
||||
* wird. prompt=select_account, damit an einem geteilten Rechner nicht
|
||||
* stillschweigend das zuletzt benutzte Konto genommen wird.
|
||||
*/
|
||||
export function beginLogin() {
|
||||
prune(pendingLogins, STATE_TTL_MS);
|
||||
|
||||
const state = randomToken();
|
||||
const nonce = randomToken();
|
||||
const verifier = randomToken();
|
||||
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
|
||||
|
||||
pendingLogins.set(state, { nonce, verifier, createdAt: Date.now() });
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: CLIENT_ID,
|
||||
response_type: 'code',
|
||||
redirect_uri: REDIRECT_URI,
|
||||
response_mode: 'query',
|
||||
scope: SCOPES.join(' '),
|
||||
state,
|
||||
nonce,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
prompt: 'select_account',
|
||||
});
|
||||
|
||||
return `${AUTHORITY}/oauth2/v2.0/authorize?${params.toString()}`;
|
||||
}
|
||||
|
||||
/** Holt den gemerkten Vorgang und verbraucht ihn - jeder state gilt genau einmal. */
|
||||
export function takePendingLogin(state) {
|
||||
prune(pendingLogins, STATE_TTL_MS);
|
||||
if (!state) return null;
|
||||
const entry = pendingLogins.get(state);
|
||||
if (!entry) return null;
|
||||
pendingLogins.delete(state);
|
||||
return entry;
|
||||
}
|
||||
|
||||
async function postToken(body) {
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(`${AUTHORITY}/oauth2/v2.0/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams(body),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SsoError(`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}`;
|
||||
throw new SsoError(String(detail).split('\n')[0], { code: data?.error ?? null });
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Tauscht den Code gegen Tokens. Der Verifier weist uns als Starter des Vorgangs aus. */
|
||||
export function exchangeCode(code, verifier) {
|
||||
return postToken({
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: clientSecret(),
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
code_verifier: verifier,
|
||||
scope: SCOPES.join(' '),
|
||||
});
|
||||
}
|
||||
|
||||
/** Nutzlast eines JWT ohne Signaturpruefung - siehe Begruendung oben im Modul. */
|
||||
function decodePayload(token) {
|
||||
const part = String(token || '').split('.')[1];
|
||||
if (!part) return null;
|
||||
try {
|
||||
return JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft das ID-Token gegen den erwarteten Tenant, die eigene Client-ID, die
|
||||
* Laufzeit und die zu diesem Vorgang gehoerende nonce. Damit ist eine
|
||||
* untergeschobene Antwort aus einem fremden Verzeichnis ausgeschlossen.
|
||||
*/
|
||||
export function verifyIdToken(idToken, expectedNonce) {
|
||||
const claims = decodePayload(idToken);
|
||||
if (!claims) throw new SsoError('Antwort von Microsoft war unlesbar.');
|
||||
|
||||
if (claims.aud !== CLIENT_ID) throw new SsoError('Antwort gehört zu einer anderen Anwendung.');
|
||||
if (claims.tid !== TENANT_ID) throw new SsoError('Anmeldung kommt aus einem fremden Verzeichnis.');
|
||||
if (claims.nonce !== expectedNonce) throw new SsoError('Antwort passt nicht zur Anfrage.');
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// 120 s Spielraum fuer auseinanderlaufende Uhren.
|
||||
if (typeof claims.exp === 'number' && claims.exp + 120 < now) {
|
||||
throw new SsoError('Antwort von Microsoft war bereits abgelaufen.');
|
||||
}
|
||||
|
||||
return claims;
|
||||
}
|
||||
|
||||
async function graphGet(path, accessToken, { accept = 'application/json' } = {}) {
|
||||
try {
|
||||
return await fetch(`${GRAPH_ROOT}${path}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}`, Accept: accept },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new SsoError(`Microsoft Graph ist nicht erreichbar: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Das angemeldete Konto: Objekt-ID, Anzeigename, Adresse. */
|
||||
export async function fetchProfile(accessToken) {
|
||||
const select = 'id,displayName,givenName,surname,mail,userPrincipalName';
|
||||
const response = await graphGet(`/me?$select=${select}`, accessToken);
|
||||
if (!response.ok) {
|
||||
throw new SsoError(`Profil konnte nicht gelesen werden (HTTP ${response.status}).`);
|
||||
}
|
||||
const data = await response.json();
|
||||
return {
|
||||
oid: String(data.id || ''),
|
||||
name: String(data.displayName || '').trim(),
|
||||
email: String(data.mail || data.userPrincipalName || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
upn: String(data.userPrincipalName || '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Profilfoto in 96x96 als Data-URL, oder null. Fehler sind hier nie fatal: wer
|
||||
* kein Foto hinterlegt hat, bekommt 404, und ohne Foto laesst sich trotzdem
|
||||
* anmelden.
|
||||
*/
|
||||
export async function fetchPhoto(accessToken) {
|
||||
try {
|
||||
const response = await graphGet('/me/photos/96x96/$value', accessToken, { accept: 'image/jpeg' });
|
||||
if (!response.ok) return null;
|
||||
const type = (response.headers.get('content-type') || 'image/jpeg').split(';')[0];
|
||||
if (!type.startsWith('image/')) return null;
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
// Sicherheitsnetz gegen unerwartet grosse Bilder - avatar ist zwar LONGTEXT,
|
||||
// soll aber keine halbe Megabyte pro Benutzer aufnehmen.
|
||||
if (buffer.length === 0 || buffer.length > 200_000) return null;
|
||||
return `data:${type};base64,${buffer.toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Legt ein Einmal-Ticket an. Das CRM-Token wandert damit nicht als
|
||||
* Adressparameter durch Browserverlauf und Server-Logs - der Browser holt es
|
||||
* mit einem POST ab, und das Ticket verfaellt beim ersten Gebrauch.
|
||||
*/
|
||||
export function createTicket(payload) {
|
||||
prune(tickets, TICKET_TTL_MS);
|
||||
const ticket = randomToken();
|
||||
tickets.set(ticket, { payload, createdAt: Date.now() });
|
||||
return ticket;
|
||||
}
|
||||
|
||||
export function redeemTicket(ticket) {
|
||||
prune(tickets, TICKET_TTL_MS);
|
||||
if (!ticket) return null;
|
||||
const entry = tickets.get(ticket);
|
||||
if (!entry) return null;
|
||||
tickets.delete(ticket);
|
||||
return entry.payload;
|
||||
}
|
||||
|
||||
/** Nur fuer Tests - leert beide Speicher. */
|
||||
export function resetSsoState() {
|
||||
pendingLogins.clear();
|
||||
tickets.clear();
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Prueft die Anmeldung ueber Microsoft Entra, soweit sie ohne Netz und ohne
|
||||
* Datenbank pruefbar ist: Konfiguration, Aufbau der Authorize-URL, Einmaligkeit
|
||||
* von state und Ticket sowie die Abwehr eines untergeschobenen ID-Tokens.
|
||||
*
|
||||
* Die Umgebungsvariablen muessen vor dem Import stehen - sso/config.js liest
|
||||
* sie beim Laden des Moduls, genau wie im Betrieb beim Start des Backends.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
process.env.MS_SSO_ENABLED = 'true';
|
||||
process.env.MS_TENANT_ID = 'tenant-fuer-den-test';
|
||||
process.env.MS_CLIENT_ID = 'client-fuer-den-test';
|
||||
process.env.MS_CLIENT_SECRET = 'geheim-fuer-den-test';
|
||||
process.env.MS_SSO_REDIRECT_URI = 'https://crm.perinco.de/api/auth/sso/callback';
|
||||
process.env.MS_SSO_ALLOWED_DOMAINS = 'perinco.de';
|
||||
|
||||
const { SSO_CONFIGURED, domainAllowed, missingSsoConfig } = await import('../src/sso/config.js');
|
||||
const {
|
||||
SsoError,
|
||||
beginLogin,
|
||||
createTicket,
|
||||
redeemTicket,
|
||||
resetSsoState,
|
||||
takePendingLogin,
|
||||
verifyIdToken,
|
||||
} = await import('../src/sso/flow.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;
|
||||
}
|
||||
}
|
||||
|
||||
function throws(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
check(name, false, 'kein Fehler geworfen');
|
||||
} catch (err) {
|
||||
check(name, err instanceof SsoError, err instanceof SsoError ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** Baut ein ID-Token, wie Entra es liefert - Signatur ist hier ohne Belang. */
|
||||
function idToken(claims) {
|
||||
const part = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
return `${part({ alg: 'RS256' })}.${part(claims)}.signatur`;
|
||||
}
|
||||
|
||||
const validClaims = {
|
||||
aud: 'client-fuer-den-test',
|
||||
tid: 'tenant-fuer-den-test',
|
||||
nonce: 'nonce-1',
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
|
||||
console.log('\nAnmeldung ueber Microsoft (SSO)');
|
||||
|
||||
console.log('\n Konfiguration');
|
||||
check('vollstaendige Konfiguration gilt als betriebsbereit', SSO_CONFIGURED === true);
|
||||
check('nichts fehlt', missingSsoConfig().length === 0, missingSsoConfig().join(', '));
|
||||
check('eigene Domain erlaubt', domainAllowed('sascha.wolf@perinco.de') === true);
|
||||
check('fremde Domain abgewiesen', domainAllowed('jemand@example.com') === false);
|
||||
check('Adresse ohne Domain abgewiesen', domainAllowed('kaputt') === false);
|
||||
|
||||
console.log('\n Start der Anmeldung');
|
||||
resetSsoState();
|
||||
const url = new URL(beginLogin());
|
||||
const params = url.searchParams;
|
||||
check('Ziel ist der Tenant', url.origin === 'https://login.microsoftonline.com');
|
||||
check('Pfad enthaelt die Tenant-ID', url.pathname.includes('tenant-fuer-den-test'), url.pathname);
|
||||
check('Code Flow', params.get('response_type') === 'code');
|
||||
check('PKCE mit S256', params.get('code_challenge_method') === 'S256');
|
||||
check('Code-Challenge gesetzt', (params.get('code_challenge') || '').length >= 43);
|
||||
check('Umleitungsadresse unveraendert', params.get('redirect_uri') === process.env.MS_SSO_REDIRECT_URI);
|
||||
check('state und nonce gesetzt', Boolean(params.get('state')) && Boolean(params.get('nonce')));
|
||||
check('Kontoauswahl erzwungen', params.get('prompt') === 'select_account');
|
||||
check('kein Secret in der URL', !url.toString().includes('geheim-fuer-den-test'));
|
||||
|
||||
console.log('\n state gilt genau einmal');
|
||||
const state = params.get('state');
|
||||
const pending = takePendingLogin(state);
|
||||
check('Vorgang gefunden', Boolean(pending?.verifier && pending?.nonce));
|
||||
check('nonce passt zur URL', pending?.nonce === params.get('nonce'));
|
||||
check('zweiter Versuch mit demselben state scheitert', takePendingLogin(state) === null);
|
||||
check('unbekannter state scheitert', takePendingLogin('erfunden') === null);
|
||||
|
||||
console.log('\n Pruefung des ID-Tokens');
|
||||
check(
|
||||
'gueltiges Token wird angenommen',
|
||||
verifyIdToken(idToken(validClaims), 'nonce-1').aud === 'client-fuer-den-test'
|
||||
);
|
||||
throws('fremdes Verzeichnis abgewiesen', () =>
|
||||
verifyIdToken(idToken({ ...validClaims, tid: 'fremder-tenant' }), 'nonce-1')
|
||||
);
|
||||
throws('fremde Anwendung abgewiesen', () =>
|
||||
verifyIdToken(idToken({ ...validClaims, aud: 'andere-app' }), 'nonce-1')
|
||||
);
|
||||
throws('falsche nonce abgewiesen', () => verifyIdToken(idToken(validClaims), 'nonce-2'));
|
||||
throws('abgelaufenes Token abgewiesen', () =>
|
||||
verifyIdToken(idToken({ ...validClaims, exp: Math.floor(Date.now() / 1000) - 3600 }), 'nonce-1')
|
||||
);
|
||||
throws('unlesbares Token abgewiesen', () => verifyIdToken('kein-jwt', 'nonce-1'));
|
||||
|
||||
console.log('\n Einmal-Ticket');
|
||||
const ticket = createTicket({ token: 'crm-token', user: { id: 1 } });
|
||||
check('Ticket ist kein Klartext des Tokens', !ticket.includes('crm-token'));
|
||||
check('Einloesen liefert die Nutzlast', redeemTicket(ticket)?.token === 'crm-token');
|
||||
check('zweites Einloesen scheitert', redeemTicket(ticket) === null);
|
||||
check('erfundenes Ticket scheitert', redeemTicket('erfunden') === null);
|
||||
|
||||
console.log('\n Konten werden nicht angelegt');
|
||||
const routes = readFileSync(new URL('../src/routes/auth.js', import.meta.url), 'utf8');
|
||||
check(
|
||||
'routes/auth.js legt keinen Benutzer an',
|
||||
!/INSERT\s+INTO\s+crm_users/i.test(routes),
|
||||
'die Anmeldung setzt ein bestehendes Konto voraus'
|
||||
);
|
||||
check(
|
||||
'Zuordnung ueber entra_oid vorhanden',
|
||||
routes.includes('entra_oid = ?'),
|
||||
'stabile Verknuepfung statt nur E-Mail'
|
||||
);
|
||||
|
||||
resetSsoState();
|
||||
|
||||
console.log(failures === 0 ? '\nAlle Pruefungen bestanden.\n' : `\n${failures} Pruefung(en) fehlgeschlagen.\n`);
|
||||
process.exit(failures === 0 ? 0 : 1);
|
||||
Reference in New Issue
Block a user