Files
CC-Manager/server/src/routes/auth.routes.ts
T
Harlekin bc86e2df26 Datenminimierung: Rohdaten, Protokoll und Loeschung
Drei Befunde aus einer Durchsicht der gespeicherten personenbezogenen Daten:

Der Import legte bisher jede Spalte der hochgeladenen Datei dauerhaft ab,
auch Mailadressen oder Rechnungsdaten, die nie zugeordnet wurden. Nach dem
Buchen oder Verwerfen bleiben jetzt nur noch die gemappten Spalten stehen.

Ein fehlgeschlagener Login protokollierte den eingetippten Benutzernamen.
Wer sein Passwort ins Namensfeld tippt, hatte es damit im Klartext im
Audit-Log. Der Name wird nur noch gespeichert, wenn das Konto existiert.

Das Loeschen eines Spielers liess Spuren in den Import-Zeilen und in den
Details frueherer Audit-Eintraege zurueck. Beides wird jetzt mit abgeraeumt;
die Vorgaenge bleiben als Eintrag bestehen, nur ohne Inhalt.

Dazu eine Aufbewahrungsfrist fuer das Audit-Log (Standard 12 Monate, ueber
AUDIT_RETENTION_DAYS einstellbar). Die Guthaben-Historie ist davon nicht
betroffen - sie bleibt die Quelle der Wahrheit fuer bezahlte Ansprueche.
2026-09-18 15:22:02 +02:00

313 lines
10 KiB
TypeScript

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) {
// Der eingegebene Name wird nur protokolliert, wenn es das Konto wirklich
// gibt. Sonst landet ein versehentlich ins Namensfeld getipptes Passwort
// im Klartext im Audit-Log.
await recordAudit(
prisma,
{ actorId: null, actorName: user ? user.username : null, ip: request.ip },
{
action: 'auth.login_failed',
targetType: 'user',
targetId: user?.id ?? null,
details: user ? { reason: user.isActive ? 'wrong_password' : 'account_disabled' } : { reason: 'unknown_account' },
},
).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 };
});
}