Die Spielerseite zeigt CCs nicht mehr als zwei Listen "aktiv" und "inaktiv", sondern als das, was sie sind: je Platz eine Kette, von unten nach oben gelesen. Unten der erste CC, darueber seine Redesigns. Gefunden wird selten alles auf einmal. Ein Redesign taucht Jahre spaeter auf, weil sein Antrag in einem anderen Board lag oder weil der Spieler es fuer den Beitritt zu einem fremden Gruppen-CC benutzt hat. Dann steht in der Kette schon "3 auf 5", und 4 fehlt. Deshalb laesst sich jeder Eintrag mit der Maus an seinen Platz ziehen - auch mitten in eine Kette und in jede andere Kette desselben Spielers. Das Verschieben ordnet nur ein: es bucht nichts und aendert keinen Status. Dafuer traegt jeder CC jetzt eine Position in seiner Kette. Sie laesst sich verschieben, ohne die ganze Kette neu zu haengen; der Vorgaenger-Verweis, an dem der Rest der Anwendung haengt, wird danach aus den Positionen neu abgeleitet. So koennen beide Sichten nicht auseinanderlaufen. Der Bestand bekommt seine Positionen in der Migration aus der vorhandenen Verkettung. An jedem CC haengen jetzt beliebig viele Verweise: ein Name und eine Adresse, der Name wird zum Link. Nur http und https - ein "javascript:"-Link in einer Oberflaeche, die mehrere Leute bedienen, waere ein offenes Scheunentor. Die SteamID fuehrt auf der Spielerseite zum GExtension-Profil, der Forum-Name zum Forumsprofil. Dessen Adresse kommt aus dem Abgleich: die Profile liegen nicht unter dem Forums-Pfad, sondern eine Ebene darueber, und das laesst sich nicht verlaesslich erraten - also wird uebernommen, worauf das Forum selbst verweist. Zuletzt der Umzug: unter Import gibt es fuer Administratoren den gesamten Bestand als eine Tabelle - Spieler, Guthaben, CCs samt Ketten, frueherer Konten, Mitgliedschaften und Verweise. Eingespielt wird ergaenzend: was da ist, bleibt unangetastet, zweimal dieselbe Datei aendert nichts, und krumme Zeilen werden mit Zeilennummer gemeldet statt den Lauf abzubrechen. Ein Test spielt den vollen Weg durch - exportieren, Bestand loeschen, zurueckholen.
319 lines
10 KiB
TypeScript
319 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 für 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 verrät.
|
|
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 };
|
|
// Die Adressen der fremden Dienste kommen mit, damit die Oberfläche
|
|
// Profil-Links bauen kann, ohne sie ein zweites Mal zu kennen.
|
|
return {
|
|
user: request.user,
|
|
gextensionBaseUrl: env.GEXTENSION_BASE_URL,
|
|
forumBaseUrl: env.FORUM_BASE_URL,
|
|
};
|
|
});
|
|
|
|
// --- 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 bestätigtem 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 };
|
|
});
|
|
}
|