Neues "users"-Table (bcrypt-Hash, nie Klartext), POST /api/auth/login gibt ein selbst-signiertes JWT (HS256) aus. requireAuth unterscheidet MSAL- (RS256) und lokale Tokens (HS256) anhand des alg-Headers. Server legt beim Start optional ein erstes lokales Konto an, wenn SEED_ADMIN_USERNAME/SEED_ADMIN_PASSWORD als Stack-Env gesetzt sind (idempotent, Klartext-Passwort landet nie im Repo). Frontend: Login-Screen hat jetzt einen Alternativ-Link zu Benutzername/Passwort, App.tsx kombiniert MSAL- und lokalen Auth-Status. Temporärer test-build-only.yml Workflow zur Docker-Build-Verifikation ohne Registry-Push (wird nach dem Test wieder entfernt). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
76 lines
3.0 KiB
TypeScript
76 lines
3.0 KiB
TypeScript
import type { NextFunction, Request, Response } from 'express';
|
|
import jwt from 'jsonwebtoken';
|
|
import jwksClient from 'jwks-rsa';
|
|
|
|
const CLIENT_ID = process.env.MSAL_CLIENT_ID ?? '';
|
|
const TENANT_ID = process.env.MSAL_TENANT_ID ?? '';
|
|
export const JWT_SECRET = process.env.JWT_SECRET ?? '';
|
|
|
|
const jwks = jwksClient({
|
|
jwksUri: `https://login.microsoftonline.com/${TENANT_ID}/discovery/v2.0/keys`,
|
|
cache: true,
|
|
cacheMaxAge: 12 * 60 * 60 * 1000
|
|
});
|
|
|
|
function getKey(header: jwt.JwtHeader, callback: (err: Error | null, key?: string) => void) {
|
|
if (!header.kid) { callback(new Error('Kein kid im Token-Header')); return; }
|
|
jwks.getSigningKey(header.kid, (err, key) => {
|
|
if (err || !key) { callback(err ?? new Error('Kein Signing-Key gefunden')); return; }
|
|
callback(null, key.getPublicKey());
|
|
});
|
|
}
|
|
|
|
export interface AuthedRequest extends Request {
|
|
user?: { email: string; name: string; oid: string };
|
|
}
|
|
|
|
// Zwei Anmeldearten werden akzeptiert:
|
|
// - Microsoft Entra ID (RS256, per JWKS von login.microsoftonline.com verifiziert)
|
|
// - Lokales Konto (HS256, mit unserem eigenen JWT_SECRET signiert, siehe routes/auth.ts)
|
|
// Unterschieden wird anhand des "alg" im (unverifizierten) Token-Header.
|
|
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction) {
|
|
const header = req.headers.authorization;
|
|
if (!header?.startsWith('Bearer ')) {
|
|
res.status(401).json({ error: 'Kein Token übermittelt.' });
|
|
return;
|
|
}
|
|
const token = header.slice('Bearer '.length);
|
|
const unverifiedHeader = jwt.decode(token, { complete: true })?.header;
|
|
|
|
if (unverifiedHeader?.alg === 'HS256') {
|
|
if (!JWT_SECRET) {
|
|
res.status(500).json({ error: 'JWT_SECRET ist serverseitig nicht konfiguriert.' });
|
|
return;
|
|
}
|
|
jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }, (err, decoded) => {
|
|
if (err || !decoded || typeof decoded === 'string') {
|
|
res.status(401).json({ error: 'Token ungültig: ' + (err?.message ?? 'unbekannt') });
|
|
return;
|
|
}
|
|
const claims = decoded as jwt.JwtPayload & { username?: string; name?: string; oid?: string };
|
|
req.user = { email: claims.username ?? '', name: claims.name ?? claims.username ?? '', oid: claims.oid ?? '' };
|
|
next();
|
|
});
|
|
return;
|
|
}
|
|
|
|
jwt.verify(token, getKey, { algorithms: ['RS256'] }, (err, decoded) => {
|
|
if (err || !decoded || typeof decoded === 'string') {
|
|
res.status(401).json({ error: 'Token ungültig: ' + (err?.message ?? 'unbekannt') });
|
|
return;
|
|
}
|
|
const claims = decoded as jwt.JwtPayload & { tid?: string; aud?: string; preferred_username?: string; upn?: string; name?: string; oid?: string };
|
|
if (claims.tid !== TENANT_ID) {
|
|
res.status(401).json({ error: 'Falscher Tenant.' });
|
|
return;
|
|
}
|
|
if (claims.aud !== CLIENT_ID) {
|
|
res.status(401).json({ error: 'Token nicht für diese App ausgestellt.' });
|
|
return;
|
|
}
|
|
const email = claims.preferred_username ?? claims.upn ?? '';
|
|
req.user = { email, name: claims.name ?? email, oid: claims.oid ?? '' };
|
|
next();
|
|
});
|
|
}
|