HTTP-Endpunkte und Anwendungsaufbau
Eingaben werden mit zod validiert, jede aendernde Aktion schreibt ins Audit-Log. Beim ersten Start entsteht ein Administrator samt Startwerten fuer die Produkterkennung; solange ein Erstpasswort gilt, bleiben schreibende Wege bis auf den Passwortwechsel gesperrt. Nicht-API-Pfade fallen auf die SPA zurueck, damit Deep-Links funktionieren.
This commit is contained in:
@@ -0,0 +1,304 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import type { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { prisma } from '../db.js';
|
||||
import { env } from '../env.js';
|
||||
import { badRequest, unauthorized } from '../errors.js';
|
||||
import { hashPassword, validatePasswordStrength, verifyPassword } from '../auth/password.js';
|
||||
import {
|
||||
CSRF_COOKIE,
|
||||
SESSION_COOKIE,
|
||||
createSession,
|
||||
csrfTokenFor,
|
||||
destroyAllSessionsFor,
|
||||
destroyOtherSessionsFor,
|
||||
destroySession,
|
||||
} from '../auth/session.js';
|
||||
import { requireAuth } from '../auth/guard.js';
|
||||
import { generateSecret, otpauthUrl, qrDataUrl, verifyTotp } from '../auth/totp.js';
|
||||
import { recordAudit } from '../services/audit.js';
|
||||
import { contextOf, parseWith } from './helpers.js';
|
||||
|
||||
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
// Zwischenschritt zwischen Passwort und TOTP-Code. Signiert statt gespeichert,
|
||||
// damit kein halb angemeldeter Zustand in der Datenbank liegt.
|
||||
function signChallenge(userId: number): string {
|
||||
const expiresAt = Date.now() + CHALLENGE_TTL_MS;
|
||||
const payload = `${userId}.${expiresAt}`;
|
||||
const signature = createHmac('sha256', env.SESSION_SECRET).update(payload).digest('base64url');
|
||||
return `${payload}.${signature}`;
|
||||
}
|
||||
|
||||
function verifyChallenge(token: string): number | null {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
const [rawUserId, rawExpiry, signature] = parts as [string, string, string];
|
||||
|
||||
const expected = createHmac('sha256', env.SESSION_SECRET)
|
||||
.update(`${rawUserId}.${rawExpiry}`)
|
||||
.digest('base64url');
|
||||
const a = Buffer.from(expected);
|
||||
const b = Buffer.from(signature);
|
||||
if (a.length !== b.length || !timingSafeEqual(a, b)) return null;
|
||||
|
||||
const expiresAt = Number(rawExpiry);
|
||||
if (!Number.isFinite(expiresAt) || expiresAt < Date.now()) return null;
|
||||
|
||||
const userId = Number(rawUserId);
|
||||
return Number.isInteger(userId) ? userId : null;
|
||||
}
|
||||
|
||||
const loginSchema = z.object({
|
||||
username: z.string().trim().min(1).max(64),
|
||||
password: z.string().min(1).max(256),
|
||||
});
|
||||
|
||||
const totpSchema = z.object({
|
||||
challenge: z.string().min(1),
|
||||
code: z.string().trim().min(6).max(10),
|
||||
});
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
const loginLimit = {
|
||||
config: {
|
||||
rateLimit: {
|
||||
max: 10,
|
||||
timeWindow: '5 minutes',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function issueSession(reply: FastifyReply, token: string, expiresAt: Date): void {
|
||||
reply.setCookie(SESSION_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: env.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt,
|
||||
});
|
||||
// Muss fuer das Double-Submit-Verfahren lesbar sein.
|
||||
reply.setCookie(CSRF_COOKIE, csrfTokenFor(token), {
|
||||
path: '/',
|
||||
httpOnly: false,
|
||||
secure: env.cookieSecure,
|
||||
sameSite: 'lax',
|
||||
expires: expiresAt,
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/api/auth/login', loginLimit, async (request, reply) => {
|
||||
const body = parseWith(loginSchema, request.body);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { username: body.username } });
|
||||
// Auch ohne Treffer einmal hashen, damit die Antwortzeit nichts verraet.
|
||||
const digest = user?.passwordHash ?? '$argon2id$v=19$m=19456,t=2,p=1$aaaaaaaaaaaaaaaa$aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const passwordOk = await verifyPassword(digest, body.password);
|
||||
|
||||
if (!user || !passwordOk || !user.isActive) {
|
||||
await recordAudit(prisma, { actorId: null, actorName: body.username, ip: request.ip }, {
|
||||
action: 'auth.login_failed',
|
||||
targetType: 'user',
|
||||
targetId: user?.id ?? null,
|
||||
}).catch(() => undefined);
|
||||
throw unauthorized('Benutzername oder Passwort ist falsch');
|
||||
}
|
||||
|
||||
if (user.totpEnabled && user.totpSecret) {
|
||||
return { totpRequired: true, challenge: signChallenge(user.id) };
|
||||
}
|
||||
|
||||
const { token, expiresAt } = await createSession(
|
||||
user.id,
|
||||
request.ip,
|
||||
request.headers['user-agent'],
|
||||
);
|
||||
issueSession(reply, token, expiresAt);
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
||||
await recordAudit(
|
||||
{ auditLog: prisma.auditLog },
|
||||
{ actorId: user.id, actorName: user.username, ip: request.ip },
|
||||
{ action: 'auth.login', targetType: 'user', targetId: user.id },
|
||||
);
|
||||
|
||||
return {
|
||||
totpRequired: false,
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
totpEnabled: user.totpEnabled,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/auth/login/totp', loginLimit, async (request, reply) => {
|
||||
const body = parseWith(totpSchema, request.body);
|
||||
|
||||
const userId = verifyChallenge(body.challenge);
|
||||
if (userId === null) {
|
||||
throw unauthorized('Die Anmeldung ist abgelaufen, bitte erneut starten');
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user || !user.isActive || !user.totpSecret) throw unauthorized();
|
||||
|
||||
if (!(await verifyTotp(user.totpSecret, body.code))) {
|
||||
await recordAudit(prisma, { actorId: user.id, actorName: user.username, ip: request.ip }, {
|
||||
action: 'auth.totp_failed',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
}).catch(() => undefined);
|
||||
throw unauthorized('Der Code stimmt nicht');
|
||||
}
|
||||
|
||||
const { token, expiresAt } = await createSession(
|
||||
user.id,
|
||||
request.ip,
|
||||
request.headers['user-agent'],
|
||||
);
|
||||
issueSession(reply, token, expiresAt);
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date() } });
|
||||
await recordAudit(
|
||||
{ auditLog: prisma.auditLog },
|
||||
{ actorId: user.id, actorName: user.username, ip: request.ip },
|
||||
{ action: 'auth.login', targetType: 'user', targetId: user.id },
|
||||
);
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
role: user.role,
|
||||
mustChangePassword: user.mustChangePassword,
|
||||
totpEnabled: user.totpEnabled,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (request, reply) => {
|
||||
if (request.sessionToken) {
|
||||
await destroySession(request.sessionToken);
|
||||
}
|
||||
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
reply.clearCookie(CSRF_COOKIE, { path: '/' });
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', async (request) => {
|
||||
if (!request.user) return { user: null };
|
||||
return { user: request.user };
|
||||
});
|
||||
|
||||
// --- Eigener Account ---
|
||||
|
||||
app.post(
|
||||
'/api/account/password',
|
||||
{ preHandler: requireAuth },
|
||||
async (request) => {
|
||||
const body = parseWith(
|
||||
z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(1),
|
||||
}),
|
||||
request.body,
|
||||
);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
|
||||
if (!user) throw unauthorized();
|
||||
|
||||
if (!(await verifyPassword(user.passwordHash, body.currentPassword))) {
|
||||
throw badRequest('WRONG_PASSWORD', 'Das aktuelle Passwort ist falsch');
|
||||
}
|
||||
|
||||
const weak = validatePasswordStrength(body.newPassword);
|
||||
if (weak) throw badRequest('WEAK_PASSWORD', weak);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
passwordHash: await hashPassword(body.newPassword),
|
||||
mustChangePassword: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Andere Sitzungen beenden, die eigene bleibt bestehen.
|
||||
if (request.sessionToken) {
|
||||
await destroyOtherSessionsFor(user.id, request.sessionToken);
|
||||
}
|
||||
|
||||
await recordAudit(prisma, contextOf(request), {
|
||||
action: 'account.password_changed',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
app.post('/api/account/totp/setup', { preHandler: requireAuth }, async (request) => {
|
||||
const secret = generateSecret();
|
||||
// Erst nach bestaetigtem Code aktivieren, sonst sperrt sich der Nutzer aus.
|
||||
await prisma.user.update({
|
||||
where: { id: request.user!.id },
|
||||
data: { totpSecret: secret, totpEnabled: false },
|
||||
});
|
||||
|
||||
const url = otpauthUrl(request.user!.username, secret);
|
||||
return { secret, otpauth: url, qr: await qrDataUrl(url) };
|
||||
});
|
||||
|
||||
app.post('/api/account/totp/enable', { preHandler: requireAuth }, async (request) => {
|
||||
const body = parseWith(z.object({ code: z.string().trim().min(6).max(10) }), request.body);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
|
||||
if (!user?.totpSecret) {
|
||||
throw badRequest('NO_SECRET', 'Bitte zuerst die Einrichtung starten');
|
||||
}
|
||||
if (!(await verifyTotp(user.totpSecret, body.code))) {
|
||||
throw badRequest('WRONG_CODE', 'Der Code stimmt nicht');
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id: user.id }, data: { totpEnabled: true } });
|
||||
await recordAudit(prisma, contextOf(request), {
|
||||
action: 'account.totp_enabled',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/account/totp/disable', { preHandler: requireAuth }, async (request) => {
|
||||
const body = parseWith(z.object({ password: z.string().min(1) }), request.body);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: request.user!.id } });
|
||||
if (!user) throw unauthorized();
|
||||
if (!(await verifyPassword(user.passwordHash, body.password))) {
|
||||
throw badRequest('WRONG_PASSWORD', 'Das Passwort ist falsch');
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { totpEnabled: false, totpSecret: null },
|
||||
});
|
||||
await recordAudit(prisma, contextOf(request), {
|
||||
action: 'account.totp_disabled',
|
||||
targetType: 'user',
|
||||
targetId: user.id,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.post('/api/account/sessions/revoke', { preHandler: requireAuth }, async (request, reply) => {
|
||||
await destroyAllSessionsFor(request.user!.id);
|
||||
reply.clearCookie(SESSION_COOKIE, { path: '/' });
|
||||
reply.clearCookie(CSRF_COOKIE, { path: '/' });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user