Add local username/password login as fallback when Microsoft login isn't available
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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
7bdf4f2897
commit
97c4aa1c68
@@ -12,12 +12,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^5.20.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.21.0",
|
||||
"jwks-rsa": "^3.1.0",
|
||||
"jsonwebtoken": "^9.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"username" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "users_username_key" ON "users"("username");
|
||||
@@ -43,3 +43,15 @@ model Material {
|
||||
@@index([projektId])
|
||||
@@map("material")
|
||||
}
|
||||
|
||||
// Lokales Konto als Fallback, falls Microsoft-Login nicht verfügbar ist.
|
||||
// passwordHash ist ein bcrypt-Hash, das Klartext-Passwort wird nie gespeichert.
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
username String @unique
|
||||
passwordHash String
|
||||
name String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
+23
-2
@@ -4,6 +4,7 @@ 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`,
|
||||
@@ -23,8 +24,10 @@ export interface AuthedRequest extends Request {
|
||||
user?: { email: string; name: string; oid: string };
|
||||
}
|
||||
|
||||
// Verifiziert das MSAL-Access-/ID-Token (Entra ID) im Authorization-Header.
|
||||
// Prüft Signatur, Tenant (tid) und dass das Token für unsere App ausgestellt wurde (aud).
|
||||
// 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 ')) {
|
||||
@@ -32,6 +35,24 @@ export function requireAuth(req: AuthedRequest, res: Response, next: NextFunctio
|
||||
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') {
|
||||
|
||||
+25
-3
@@ -2,9 +2,12 @@ import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { requireAuth } from './auth.js';
|
||||
import { authRouter } from './routes/auth.js';
|
||||
import { projekteRouter } from './routes/projekte.js';
|
||||
import { materialRouter } from './routes/material.js';
|
||||
import { prisma } from './prisma.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = Number(process.env.PORT ?? 80);
|
||||
@@ -16,6 +19,7 @@ app.use(express.json());
|
||||
|
||||
app.get('/api/health', (_req, res) => res.json({ ok: true }));
|
||||
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/projekte', requireAuth, projekteRouter);
|
||||
app.use('/api/material', requireAuth, materialRouter);
|
||||
|
||||
@@ -27,6 +31,24 @@ app.get(/^(?!\/api\/).*/, (_req, res) => {
|
||||
res.sendFile(path.join(PUBLIC_DIR, 'index.html'));
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Materialschein-Server läuft auf Port ${PORT}`);
|
||||
});
|
||||
// Legt beim ersten Start ein lokales Fallback-Konto an, falls SEED_ADMIN_USERNAME/
|
||||
// SEED_ADMIN_PASSWORD gesetzt sind und noch kein Konto mit diesem Namen existiert.
|
||||
// Idempotent – kann bei jedem Neustart stehen bleiben, legt nichts doppelt an.
|
||||
async function seedAdminIfConfigured() {
|
||||
const username = process.env.SEED_ADMIN_USERNAME;
|
||||
const password = process.env.SEED_ADMIN_PASSWORD;
|
||||
if (!username || !password) return;
|
||||
const existing = await prisma.user.findUnique({ where: { username } });
|
||||
if (existing) return;
|
||||
const passwordHash = await bcrypt.hash(password, 12);
|
||||
await prisma.user.create({ data: { username, passwordHash, name: username } });
|
||||
console.log(`Lokales Konto "${username}" angelegt.`);
|
||||
}
|
||||
|
||||
seedAdminIfConfigured()
|
||||
.catch(err => console.error('Seed des lokalen Kontos fehlgeschlagen:', err))
|
||||
.finally(() => {
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Materialschein-Server läuft auf Port ${PORT}`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../prisma.js';
|
||||
import { JWT_SECRET } from '../auth.js';
|
||||
|
||||
export const authRouter = Router();
|
||||
|
||||
authRouter.post('/login', async (req, res) => {
|
||||
const { username, password } = req.body ?? {};
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Benutzername und Passwort erforderlich.' });
|
||||
return;
|
||||
}
|
||||
if (!JWT_SECRET) {
|
||||
res.status(500).json({ error: 'JWT_SECRET ist serverseitig nicht konfiguriert.' });
|
||||
return;
|
||||
}
|
||||
const user = await prisma.user.findUnique({ where: { username } });
|
||||
if (!user) {
|
||||
res.status(401).json({ error: 'Benutzername oder Passwort falsch.' });
|
||||
return;
|
||||
}
|
||||
const ok = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!ok) {
|
||||
res.status(401).json({ error: 'Benutzername oder Passwort falsch.' });
|
||||
return;
|
||||
}
|
||||
const token = jwt.sign(
|
||||
{ username: user.username, name: user.name ?? user.username },
|
||||
JWT_SECRET,
|
||||
{ algorithm: 'HS256', expiresIn: '30d' }
|
||||
);
|
||||
res.json({ token, name: user.name ?? user.username });
|
||||
});
|
||||
Reference in New Issue
Block a user