diff --git a/server/src/env.ts b/server/src/env.ts index 8b4318e..d49579f 100644 --- a/server/src/env.ts +++ b/server/src/env.ts @@ -27,6 +27,10 @@ const schema = z.object({ .optional() .transform((v) => (v === undefined ? undefined : v === 'true' || v === '1')), + // Aufbewahrung der Audit-Eintraege in Tagen. 0 schaltet das Aufraeumen ab. + // Die Guthaben-Historie ist davon nicht betroffen, die bleibt dauerhaft. + AUDIT_RETENTION_DAYS: z.coerce.number().int().min(0).default(365), + LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace']).default('info'), }); diff --git a/server/src/index.ts b/server/src/index.ts index f3151df..7274972 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,10 +1,11 @@ import { buildApp } from './app.js'; import { bootstrap } from './bootstrap.js'; import { cleanupExpiredSessions } from './auth/session.js'; +import { cleanupOldAuditEntries } from './services/audit.js'; import { prisma } from './db.js'; import { env } from './env.js'; -const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; +const CLEANUP_INTERVAL_MS = 60 * 60 * 1000; async function main(): Promise { const app = await buildApp(); @@ -27,7 +28,13 @@ async function main(): Promise { if (count > 0) app.log.debug(`${count} abgelaufene Sitzungen entfernt`); }) .catch((error) => app.log.warn({ err: error }, 'Aufraeumen der Sitzungen fehlgeschlagen')); - }, SESSION_CLEANUP_INTERVAL_MS); + + cleanupOldAuditEntries() + .then((count) => { + if (count > 0) app.log.info(`${count} abgelaufene Audit-Eintraege entfernt`); + }) + .catch((error) => app.log.warn({ err: error }, 'Aufraeumen des Audit-Logs fehlgeschlagen')); + }, CLEANUP_INTERVAL_MS); cleanup.unref(); const shutdown = async (signal: string): Promise => { diff --git a/server/src/lib/steamid.ts b/server/src/lib/steamid.ts new file mode 100644 index 0000000..fe2c19e --- /dev/null +++ b/server/src/lib/steamid.ts @@ -0,0 +1,61 @@ +/** + * Steam kennt dieselbe Person in mehreren Schreibweisen. Damit ein Spieler + * nicht doppelt angelegt wird, sobald eine Quelle das andere Format liefert, + * wird intern immer die 64-Bit-Form gespeichert. + * + * STEAM_0:1:12345678 (Legacy, was Garry's Mod anzeigt) + * [U:1:24691357] (SteamID3) + * 76561198024691357 (SteamID64, die kanonische Form) + */ + +// Offset zwischen Account-ID und SteamID64. +const BASE = 76561197960265728n; + +const LEGACY = /^STEAM_[0-5]:([01]):(\d+)$/i; +const STEAM3 = /^\[?U:1:(\d+)\]?$/i; +const STEAM64 = /^\d{17}$/; + +/** + * Bringt eine Eingabe auf die 64-Bit-Form. Gibt null zurueck, wenn der Wert + * nach keiner bekannten Steam-Schreibweise aussieht. + */ +export function normalizeSteamId(input: string): string | null { + const value = input.trim(); + if (value.length === 0) return null; + + const legacy = LEGACY.exec(value); + if (legacy) { + const universe = BigInt(legacy[1] as string); + const accountNumber = BigInt(legacy[2] as string); + return (BASE + accountNumber * 2n + universe).toString(); + } + + const modern = STEAM3.exec(value); + if (modern) { + return (BASE + BigInt(modern[1] as string)).toString(); + } + + if (STEAM64.test(value)) { + const numeric = BigInt(value); + // Alles unterhalb des Offsets waere keine Nutzer-ID. + return numeric > BASE ? numeric.toString() : null; + } + + return null; +} + +/** Legacy-Form zur Anzeige, etwa fuer Server-Konsolen. */ +export function toLegacySteamId(steamId64: string): string | null { + if (!STEAM64.test(steamId64)) return null; + + const numeric = BigInt(steamId64); + if (numeric <= BASE) return null; + + const offset = numeric - BASE; + return `STEAM_0:${offset % 2n}:${offset / 2n}`; +} + +export function looksLikeSteamId(input: string): boolean { + const value = input.trim(); + return LEGACY.test(value) || STEAM3.test(value) || STEAM64.test(value); +} diff --git a/server/src/routes/auth.routes.ts b/server/src/routes/auth.routes.ts index 4611d14..dec7dd6 100644 --- a/server/src/routes/auth.routes.ts +++ b/server/src/routes/auth.routes.ts @@ -96,11 +96,19 @@ export async function authRoutes(app: FastifyInstance): Promise { 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); + // 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'); } diff --git a/server/src/services/audit.ts b/server/src/services/audit.ts index 190fc66..eebe6c7 100644 --- a/server/src/services/audit.ts +++ b/server/src/services/audit.ts @@ -1,10 +1,12 @@ import type { Prisma } from '@prisma/client'; import { prisma } from '../db.js'; +import { env } from '../env.js'; export interface ActionContext { - // null fuer Vorgaenge ohne angemeldeten Account, etwa ein fehlgeschlagener Login. + // Beide null fuer Vorgaenge ohne bekanntes Konto, etwa einen fehlgeschlagenen + // Login auf einen Benutzernamen, den es gar nicht gibt. actorId: number | null; - actorName: string; + actorName: string | null; ip?: string | undefined; } @@ -25,7 +27,7 @@ export async function recordAudit( await client.auditLog.create({ data: { actorUserId: ctx.actorId, - actorName: ctx.actorName, + actorName: ctx.actorName ?? null, action: entry.action, targetType: entry.targetType, targetId: entry.targetId == null ? null : String(entry.targetId), @@ -34,3 +36,16 @@ export async function recordAudit( }, }); } + +/** + * Entfernt Audit-Eintraege, die aelter sind als die eingestellte Frist. Die + * Guthaben-Historie bleibt unangetastet - sie ist die Quelle der Wahrheit fuer + * bezahlte Ansprueche und hat eine andere Aufbewahrung als das Protokoll. + */ +export async function cleanupOldAuditEntries(): Promise { + if (env.AUDIT_RETENTION_DAYS === 0) return 0; + + const cutoff = new Date(Date.now() - env.AUDIT_RETENTION_DAYS * 24 * 3600 * 1000); + const result = await prisma.auditLog.deleteMany({ where: { createdAt: { lt: cutoff } } }); + return result.count; +} diff --git a/server/src/services/imports.ts b/server/src/services/imports.ts index f82635d..28224e0 100644 --- a/server/src/services/imports.ts +++ b/server/src/services/imports.ts @@ -163,6 +163,45 @@ function parseDate(value: string | undefined): Date | null { return Number.isNaN(parsed.getTime()) ? null : parsed; } +/** + * Dampft die gespeicherten Rohzeilen auf die Spalten ein, die tatsaechlich + * zugeordnet wurden. Ein Shop-Export enthaelt oft deutlich mehr - Mailadresse, + * Rechnungsanschrift, Zahlungsart -, und davon braucht die App nichts. Laeuft, + * sobald ein Import abgeschlossen oder verworfen ist. + */ +async function pruneRawRows( + client: Pick, + importId: number, + mapping: ColumnMapping | null, +): Promise { + const keep = new Set( + [mapping?.playerKey, mapping?.playerName, mapping?.product, mapping?.orderId, mapping?.purchasedAt].filter( + (column): column is string => Boolean(column), + ), + ); + + const rows = await client.importRow.findMany({ + where: { importId }, + select: { id: true, raw: true }, + }); + + for (const row of rows) { + const raw = row.raw as Record | null; + if (!raw) continue; + + const reduced: Record = {}; + for (const column of keep) { + const value = raw[column]; + if (value !== undefined) reduced[column] = value; + } + + await client.importRow.update({ + where: { id: row.id }, + data: { raw: reduced as Prisma.InputJsonValue }, + }); + } +} + /** * Rechnet fuer jede Zeile aus, was beim Buchen passieren wuerde. Wird vor der * Bestaetigung angezeigt und beim Commit unveraendert ausgefuehrt. @@ -402,6 +441,9 @@ export async function commitImport(importId: number, ctx: ActionContext) { return { booked, createdPlayers: createdPlayers.size, import: updated }; }); + // Nach dem Buchen werden die ungenutzten Spalten der Datei nicht mehr gebraucht. + await pruneRawRows(prisma, importId, (record.mapping as ColumnMapping | null) ?? null); + return result; } @@ -417,6 +459,9 @@ export async function discardImport(importId: number, ctx: ActionContext): Promi data: { status: ImportStatus.DISCARDED }, }); + // Ein verworfener Import braucht die Rohdaten erst recht nicht mehr. + await pruneRawRows(prisma, importId, (record.mapping as ColumnMapping | null) ?? null); + await recordAudit(prisma, ctx, { action: 'import.discard', targetType: 'import', diff --git a/server/src/services/players.ts b/server/src/services/players.ts index 8da850c..677adb5 100644 --- a/server/src/services/players.ts +++ b/server/src/services/players.ts @@ -132,6 +132,12 @@ export async function updatePlayer(playerId: number, input: Partial }); } +/** + * Loescht einen Spieler samt allem, was an ihm haengt. Spuren in den + * Import-Zeilen und in frueheren Audit-Eintraegen werden dabei mit entfernt, + * damit nach der Loeschung wirklich nichts Personenbezogenes uebrig bleibt. + * Die Audit-Eintraege selbst bleiben als Vorgang bestehen, nur ohne Inhalt. + */ export async function deletePlayer(playerId: number, ctx: ActionContext): Promise { const existing = await prisma.player.findUnique({ where: { id: playerId }, @@ -140,15 +146,37 @@ export async function deletePlayer(playerId: number, ctx: ActionContext): Promis if (!existing) throw notFound('Spieler'); await prisma.$transaction(async (tx) => { + // Rohzeilen der Importe verlieren ihren Personenbezug. + await tx.importRow.updateMany({ + where: { + OR: [ + { resolvedPlayerId: playerId }, + ...(existing.steamId ? [{ playerKey: existing.steamId }] : []), + ], + }, + data: { + playerKey: null, + playerName: null, + resolvedPlayerId: null, + raw: {}, + }, + }); + + // Fruehere Audit-Eintraege zu diesem Spieler behalten den Vorgang, nicht den Inhalt. + await tx.auditLog.updateMany({ + where: { targetType: 'player', targetId: String(playerId) }, + data: { details: { redacted: true } }, + }); + await tx.player.delete({ where: { id: playerId } }); await recordAudit(tx, ctx, { action: 'player.delete', targetType: 'player', targetId: playerId, + // Bewusst ohne Name und SteamID: der Eintrag soll die Loeschung belegen, + // nicht die geloeschten Daten konservieren. details: { - displayName: existing.displayName, - steamId: existing.steamId, removedCharacters: existing._count.characters, removedLedgerRows: existing._count.ledger, }, diff --git a/server/test/privacy.test.ts b/server/test/privacy.test.ts new file mode 100644 index 0000000..81926d1 --- /dev/null +++ b/server/test/privacy.test.ts @@ -0,0 +1,183 @@ +import assert from 'node:assert/strict'; +import { after, before, describe, it } from 'node:test'; +import { startTestDatabase, type TestDatabase } from './harness.js'; + +type Imports = typeof import('../src/services/imports.js'); +type Players = typeof import('../src/services/players.js'); +type Characters = typeof import('../src/services/characters.js'); +type Credits = typeof import('../src/services/credits.js'); +type Audit = typeof import('../src/services/audit.js'); +type Db = typeof import('../src/db.js'); + +let database: TestDatabase; +let imports: Imports; +let players: Players; +let characters: Characters; +let credits: Credits; +let audit: Audit; +let db: Db; +let ctx: { actorId: number; actorName: string }; + +// Ein Export, wie ihn ein Shop liefern koennte: mehr Spalten als die App braucht. +const CSV = [ + 'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart', + 'P-1;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal', +].join('\n'); + +const MAPPING = { + playerKey: 'Steam-ID', + product: 'Produkt', + orderId: 'Bestellnummer', + createMissingPlayers: true, +}; + +before(async () => { + database = await startTestDatabase(54333); + process.env.DATABASE_URL = database.url; + process.env.SESSION_SECRET = 'test-secret-that-is-long-enough-000000'; + process.env.NODE_ENV = 'test'; + + db = await import('../src/db.js'); + imports = await import('../src/services/imports.js'); + players = await import('../src/services/players.js'); + characters = await import('../src/services/characters.js'); + credits = await import('../src/services/credits.js'); + audit = await import('../src/services/audit.js'); + + const user = await db.prisma.user.create({ + data: { username: 'datenschuetzer', passwordHash: 'x', role: 'ADMIN' }, + }); + ctx = { actorId: user.id, actorName: user.username }; + + await db.prisma.productMapping.create({ + data: { matchPattern: 'cc-paket', target: 'CC_PACKAGE', priority: 10 }, + }); +}); + +after(async () => { + await db.prisma.$disconnect(); + await database.stop(); +}); + +describe('Datenminimierung beim Import', () => { + it('verwirft nach dem Buchen die nicht zugeordneten Spalten', async () => { + const parsed = imports.parseUpload('shop.csv', Buffer.from(CSV, 'utf8')); + const importId = await imports.createImport('shop.csv', parsed, ctx); + await imports.applyMapping(importId, MAPPING, ctx); + + const before = await db.prisma.importRow.findFirstOrThrow({ where: { importId } }); + const rawBefore = before.raw as Record; + assert.equal(rawBefore.Email, 'spieler@example.com', 'vor dem Buchen ist noch alles da'); + + await imports.commitImport(importId, ctx); + + const after = await db.prisma.importRow.findFirstOrThrow({ where: { importId } }); + const rawAfter = after.raw as Record; + + assert.equal(rawAfter.Email, undefined, 'die Mailadresse ist weg'); + assert.equal(rawAfter.Rechnungsadresse, undefined, 'die Anschrift ist weg'); + assert.equal(rawAfter.Zahlungsart, undefined, 'die Zahlungsart ist weg'); + assert.equal(rawAfter['Steam-ID'], '76561198000000501', 'die zugeordnete Spalte bleibt'); + }); + + it('raeumt auch einen verworfenen Import auf', async () => { + const parsed = imports.parseUpload('shop2.csv', Buffer.from(CSV, 'utf8')); + const importId = await imports.createImport('shop2.csv', parsed, ctx); + await imports.applyMapping(importId, MAPPING, ctx); + + await imports.discardImport(importId, ctx); + + const row = await db.prisma.importRow.findFirstOrThrow({ where: { importId } }); + const raw = row.raw as Record; + assert.equal(raw.Email, undefined, 'auch ohne Buchung bleiben keine Rohdaten liegen'); + }); +}); + +describe('Fehlgeschlagene Anmeldung', () => { + it('schreibt einen unbekannten Benutzernamen nicht ins Protokoll', async () => { + // Genau der Fall: jemand tippt sein Passwort ins Namensfeld. + const versehen = 'MeinGeheimesPasswort123'; + + await audit.recordAudit(db.prisma, { actorId: null, actorName: null, ip: '127.0.0.1' }, { + action: 'auth.login_failed', + targetType: 'user', + targetId: null, + details: { reason: 'unknown_account' }, + }); + + const entries = await db.prisma.auditLog.findMany({ + where: { action: 'auth.login_failed' }, + }); + + for (const entry of entries) { + assert.notEqual(entry.actorName, versehen); + assert.equal(entry.actorName, null, 'ohne bekanntes Konto bleibt das Namensfeld leer'); + } + }); +}); + +describe('Loeschung eines Spielers', () => { + it('entfernt auch Spuren in Import-Zeilen und Audit-Details', async () => { + // Eigene Bestellnummer, sonst greift zu Recht die Dedup-Logik. + const csv = [ + 'Bestellnummer;Steam-ID;Produkt;Email;Rechnungsadresse;Zahlungsart', + 'P-3;76561198000000501;CC-Paket;spieler@example.com;Musterweg 1, 12345 Musterstadt;PayPal', + ].join('\n'); + + const parsed = imports.parseUpload('shop3.csv', Buffer.from(csv, 'utf8')); + const importId = await imports.createImport('shop3.csv', parsed, ctx); + await imports.applyMapping(importId, MAPPING, ctx); + await imports.commitImport(importId, ctx); + + const player = await db.prisma.player.findUniqueOrThrow({ + where: { steamId: '76561198000000501' }, + }); + await characters.createCharacter(player.id, { name: 'Testfigur' }, ctx); + + await players.deletePlayer(player.id, ctx); + + const gone = await db.prisma.player.findUnique({ where: { id: player.id } }); + assert.equal(gone, null, 'der Spieler ist weg'); + + const ledgerLeft = await db.prisma.creditLedger.count({ where: { playerId: player.id } }); + assert.equal(ledgerLeft, 0, 'die Guthaben-Zeilen sind mit weg'); + + const rows = await db.prisma.importRow.findMany({ where: { importId } }); + for (const row of rows) { + assert.equal(row.playerKey, null, 'die SteamID steht nicht mehr in der Import-Zeile'); + assert.equal(row.resolvedPlayerId, null); + assert.deepEqual(row.raw, {}, 'die Rohzeile ist geleert'); + } + + const auditEntries = await db.prisma.auditLog.findMany({ + where: { targetType: 'player', targetId: String(player.id) }, + }); + assert.ok(auditEntries.length > 0, 'die Vorgaenge selbst bleiben nachvollziehbar'); + for (const entry of auditEntries) { + const details = JSON.stringify(entry.details ?? {}); + assert.ok( + !details.includes('76561198000000501'), + `SteamID steckt noch in ${entry.action}: ${details}`, + ); + } + }); +}); + +describe('Aufbewahrung des Audit-Logs', () => { + it('loescht Eintraege jenseits der Frist, neuere bleiben', async () => { + const alt = new Date(Date.now() - 400 * 24 * 3600 * 1000); + + const veraltet = await db.prisma.auditLog.create({ + data: { action: 'test.alt', targetType: 'test', createdAt: alt }, + }); + const frisch = await db.prisma.auditLog.create({ + data: { action: 'test.frisch', targetType: 'test' }, + }); + + const removed = await audit.cleanupOldAuditEntries(); + assert.ok(removed >= 1); + + assert.equal(await db.prisma.auditLog.findUnique({ where: { id: veraltet.id } }), null); + assert.ok(await db.prisma.auditLog.findUnique({ where: { id: frisch.id } })); + }); +});