diff --git a/scripts/test-login-rate-limit.ts b/scripts/test-login-rate-limit.ts new file mode 100644 index 0000000..1892c87 --- /dev/null +++ b/scripts/test-login-rate-limit.ts @@ -0,0 +1,61 @@ +import "dotenv/config"; + +/** + * Login throttling (rate-limit scope `login`, checked in verifyIdentityPassword): + * (1) within the limit a wrong password returns null, the right one succeeds + * (2) after exceeding the per-account limit even the correct password returns null + * (3) other accounts are unaffected by one account's counter (per-account key) + * (4) resetRateLimits restores access (in-memory counter) + * Outside a request there is no IP → only the account counter applies (documented behaviour). + */ +import { prisma } from "../src/server/db"; +import { verifyIdentityPassword } from "../src/server/auth"; +import { RATE_LIMITS, resetRateLimits } from "../src/server/rate-limit"; +import { hashPassword } from "../src/server/password"; + +let failures = 0; +const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +async function main() { + const stamp = Date.now(); + const pw = "Richtig-Passwort-2026!"; + const emailA = `zz-rl-a-${stamp}@example.test`; + const emailB = `zz-rl-b-${stamp}@example.test`; + const hash = await hashPassword(pw); + const [a, b] = await Promise.all([ + prisma.identity.create({ data: { email: emailA, passwordHash: hash } }), + prisma.identity.create({ data: { email: emailB, passwordHash: hash } }), + ]); + resetRateLimits(); + try { + // (1) wrong then right, far below the limit (lockout after 5 failures is a separate mechanism) + ok((await verifyIdentityPassword(emailA, "falsch")) === null, "(1) falsches Passwort → null"); + await prisma.identity.update({ where: { id: a.id }, data: { failedLogins: 0, lockedUntil: null } }); + ok((await verifyIdentityPassword(emailA, pw))?.identityId === a.id, "(1) richtiges Passwort → Erfolg"); + + // (2) exhaust the per-account limit with successful attempts (no lockout involved) + const limit = RATE_LIMITS.login.limit; + for (let i = 0; i < limit; i++) await verifyIdentityPassword(emailA, pw); + ok((await verifyIdentityPassword(emailA, pw)) === null, `(2) nach ${limit}+ Versuchen: auch richtiges Passwort → null (gedrosselt)`); + + // (3) other account unaffected + ok((await verifyIdentityPassword(emailB, pw))?.identityId === b.id, "(3) anderes Konto nicht betroffen"); + + // (4) reset + resetRateLimits(); + ok((await verifyIdentityPassword(emailA, pw))?.identityId === a.id, "(4) nach Reset wieder möglich"); + } finally { + await prisma.identity.deleteMany({ where: { id: { in: [a.id, b.id] } } }); + await prisma.$disconnect(); + } + console.log(failures ? `\n${failures} Fehler` : "\nOK — Login-Drosselung je Konto wirksam."); + process.exit(failures ? 1 : 0); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/src/server/auth.ts b/src/server/auth.ts index 70d6828..ef97727 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -202,6 +202,16 @@ export async function verifyIdentityPassword( password: string, ): Promise<{ identityId: string; mfaRequired: boolean } | null> { assertSecureEnv(); + // Throttle per IP and per account before touching the identity (password spraying, F-05 follow-up). + // Limited attempts look exactly like a failed login: generic result, constant-time dummy hash. + const { clientIp } = await import("@/server/auth-selfservice"); + const { checkRateLimit } = await import("@/server/rate-limit"); + const throttle = checkRateLimit("login", { ip: await clientIp(), account: email.toLowerCase() }); + if (!throttle.allowed) { + await verifyPassword(await dummyHash(), password); + console.warn(`[auth] login rate limited (retry after ${throttle.retryAfterSeconds}s)`); + return null; + } const identity = await prisma.identity.findUnique({ where: { email: email.toLowerCase() } }); if (!identity || identity.status !== "ACTIVE") { await verifyPassword(await dummyHash(), password); // konstante Laufzeit (F-05) diff --git a/src/server/mail/worker.ts b/src/server/mail/worker.ts index 46c281c..c1b3078 100644 --- a/src/server/mail/worker.ts +++ b/src/server/mail/worker.ts @@ -1,5 +1,5 @@ import { UnrecoverableError, Worker, type Job } from "bullmq"; -import { deliverMail, markMailFailed, MailNotConfiguredError } from "./deliver"; +import { deliverMail, markMailFailed, MailAttachmentError, MailNotConfiguredError } from "./deliver"; import { closeQueues, getConnection, getDeadLetterQueue, getSchedulerQueue } from "./queue"; import { DUE_REMINDER_JOB, MAIL_QUEUE, SCHEDULER_QUEUE, type MailJob } from "./job"; import { closeMailProvider } from "./provider-smtp"; @@ -34,8 +34,9 @@ export function startMailWorker(): Worker { const { messageId } = await deliverMail(job.data); return { messageId }; } catch (err) { - if (err instanceof MailNotConfiguredError) { - // Nicht wiederholen — die Konfiguration ändert sich nicht durch Warten. + if (err instanceof MailNotConfiguredError || err instanceof MailAttachmentError) { + // Nicht wiederholen — Konfiguration bzw. ein unzulässiger/fehlender Anhang + // (fremder Mandant, Prüfsumme, Größe) ändern sich nicht durch Warten. // BullMQ bricht die Retry-Kette bei UnrecoverableError sofort ab. throw new UnrecoverableError(err.message); } diff --git a/src/server/rate-limit.ts b/src/server/rate-limit.ts index 03775c7..b81b3c6 100644 --- a/src/server/rate-limit.ts +++ b/src/server/rate-limit.ts @@ -50,6 +50,8 @@ function perMinute(name: string, fallback: number): number { export const RATE_LIMITS = { /** Reset-Anfrage: 5 pro Stunde je IP und je Konto. */ passwordResetRequest: { limit: 5, windowMs: 60 * 60_000 }, + /** Login (Passwortprüfung): je IP und je Konto – bremst Password-Spraying über viele Konten. */ + login: { limit: perMinute("LOGIN_RATE_LIMIT_PER_15_MIN", 20), windowMs: 15 * 60_000 }, /** Reset-Einlösung: begrenzt das Durchprobieren manipulierter Links. */ passwordResetRedeem: { limit: 10, windowMs: 15 * 60_000 }, /** Alt-Passwort-Prüfung bei Selbständerung. */ diff --git a/src/server/services/field/uploads.ts b/src/server/services/field/uploads.ts index c6bc5a2..88372d8 100644 --- a/src/server/services/field/uploads.ts +++ b/src/server/services/field/uploads.ts @@ -3,7 +3,7 @@ import { storage } from "@/server/storage/adapter"; import { dispatchJob } from "@/server/jobs/dispatch"; import { JOB_QUEUES } from "@/server/jobs/queues"; import { ServiceError, type ServiceCtx } from "@/server/services/context"; -import { audit, isUniqueViolation, requireFieldOrder } from "./common"; +import { isUniqueViolation, requireFieldOrder } from "./common"; import { storeFile } from "@/server/services/documents/store"; import { sniffMime } from "./mime"; @@ -91,13 +91,6 @@ export async function storeFieldUpload( } } - await audit(ctx, "create", "document", doc.id, null, { - workOrderId: wo.id, - category: doc.category, - fileName: doc.fileName, - mimeType: doc.mimeType, - fileSize: doc.fileSize, - checksum: doc.checksum, - }); + // The document `create` audit is written by services/documents/store.ts#storeFile. return { documentId: doc.id, duplicate: false }; }