Härtung: Login-Drosselung je IP/Konto, Mail-Anhangfehler ohne Retry, doppeltes Upload-Audit
- rate-limit: neuer Scope login (20/15 min, LOGIN_RATE_LIMIT_PER_15_MIN) je IP und je Konto; geprüft in verifyIdentityPassword (Login-Seite + Credentials-Provider), gedrosselt verhält sich wie Fehlanmeldung (generisch, konstante Laufzeit) – L10a - mail/worker: MailAttachmentError wie MailNotConfiguredError unrecoverable (fremder Mandant/Prüfsumme/Größe ändern sich nicht durch Warten) – L11 - field/uploads: Dokument-Audit nur noch in storeFile (vorher doppelt) – L10a - Test test-login-rate-limit Gate 65/65 grün; komplette Suite mit RLS_ENFORCED=true 65/65 grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
@@ -202,6 +202,16 @@ export async function verifyIdentityPassword(
|
|||||||
password: string,
|
password: string,
|
||||||
): Promise<{ identityId: string; mfaRequired: boolean } | null> {
|
): Promise<{ identityId: string; mfaRequired: boolean } | null> {
|
||||||
assertSecureEnv();
|
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() } });
|
const identity = await prisma.identity.findUnique({ where: { email: email.toLowerCase() } });
|
||||||
if (!identity || identity.status !== "ACTIVE") {
|
if (!identity || identity.status !== "ACTIVE") {
|
||||||
await verifyPassword(await dummyHash(), password); // konstante Laufzeit (F-05)
|
await verifyPassword(await dummyHash(), password); // konstante Laufzeit (F-05)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { UnrecoverableError, Worker, type Job } from "bullmq";
|
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 { closeQueues, getConnection, getDeadLetterQueue, getSchedulerQueue } from "./queue";
|
||||||
import { DUE_REMINDER_JOB, MAIL_QUEUE, SCHEDULER_QUEUE, type MailJob } from "./job";
|
import { DUE_REMINDER_JOB, MAIL_QUEUE, SCHEDULER_QUEUE, type MailJob } from "./job";
|
||||||
import { closeMailProvider } from "./provider-smtp";
|
import { closeMailProvider } from "./provider-smtp";
|
||||||
@@ -34,8 +34,9 @@ export function startMailWorker(): Worker<MailJob> {
|
|||||||
const { messageId } = await deliverMail(job.data);
|
const { messageId } = await deliverMail(job.data);
|
||||||
return { messageId };
|
return { messageId };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof MailNotConfiguredError) {
|
if (err instanceof MailNotConfiguredError || err instanceof MailAttachmentError) {
|
||||||
// Nicht wiederholen — die Konfiguration ändert sich nicht durch Warten.
|
// 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.
|
// BullMQ bricht die Retry-Kette bei UnrecoverableError sofort ab.
|
||||||
throw new UnrecoverableError(err.message);
|
throw new UnrecoverableError(err.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ function perMinute(name: string, fallback: number): number {
|
|||||||
export const RATE_LIMITS = {
|
export const RATE_LIMITS = {
|
||||||
/** Reset-Anfrage: 5 pro Stunde je IP und je Konto. */
|
/** Reset-Anfrage: 5 pro Stunde je IP und je Konto. */
|
||||||
passwordResetRequest: { limit: 5, windowMs: 60 * 60_000 },
|
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. */
|
/** Reset-Einlösung: begrenzt das Durchprobieren manipulierter Links. */
|
||||||
passwordResetRedeem: { limit: 10, windowMs: 15 * 60_000 },
|
passwordResetRedeem: { limit: 10, windowMs: 15 * 60_000 },
|
||||||
/** Alt-Passwort-Prüfung bei Selbständerung. */
|
/** Alt-Passwort-Prüfung bei Selbständerung. */
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { storage } from "@/server/storage/adapter";
|
|||||||
import { dispatchJob } from "@/server/jobs/dispatch";
|
import { dispatchJob } from "@/server/jobs/dispatch";
|
||||||
import { JOB_QUEUES } from "@/server/jobs/queues";
|
import { JOB_QUEUES } from "@/server/jobs/queues";
|
||||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
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 { storeFile } from "@/server/services/documents/store";
|
||||||
import { sniffMime } from "./mime";
|
import { sniffMime } from "./mime";
|
||||||
|
|
||||||
@@ -91,13 +91,6 @@ export async function storeFieldUpload(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await audit(ctx, "create", "document", doc.id, null, {
|
// The document `create` audit is written by services/documents/store.ts#storeFile.
|
||||||
workOrderId: wo.id,
|
|
||||||
category: doc.category,
|
|
||||||
fileName: doc.fileName,
|
|
||||||
mimeType: doc.mimeType,
|
|
||||||
fileSize: doc.fileSize,
|
|
||||||
checksum: doc.checksum,
|
|
||||||
});
|
|
||||||
return { documentId: doc.id, duplicate: false };
|
return { documentId: doc.id, duplicate: false };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user