fix(auth): lock accounts after failed attempts, one-time TOTP codes, stronger MFA changes

- Per-account lockout: five failed attempts (password, MFA code or a
  step-up confirmation) lock the account for 15 minutes, on top of the
  per-IP throttle - guesses spread over many addresses no longer help.
- A TOTP code is accepted once: the time step is stored and that step or
  any earlier one is refused (also between two concurrent requests).
- Login runs bcrypt on every path and answers unknown email, wrong
  password, deactivated and locked accounts with the same message; failed
  logins for unknown emails no longer write attacker-chosen text into the
  audit log.
- Turning MFA off needs password and a current code; turning it on needs
  the password. Both end all other sessions.
- New audit action ACCOUNT_LOCKED.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-01 02:27:47 +02:00
1 parent 79d20ee64e
commit 5c666fe49a
22 files changed
+494 -91

No files matched your search

@@ -46,13 +46,18 @@ describe("MfaSection", () => {
const codeInput = await screen.findByLabelText("Code");
await userEvent.type(codeInput, "123456");
await userEvent.type(screen.getByLabelText("Passwort"), "correct-password");
await userEvent.click(screen.getByRole("button", { name: "Bestätigen" }));
expect(confirmMfaEnrollment).toHaveBeenCalledWith("setup-token", "123456");
expect(confirmMfaEnrollment).toHaveBeenCalledWith(
"setup-token",
"123456",
"correct-password",
);
await vi.waitFor(() => expect(onChanged).toHaveBeenCalled());
});
it("disables MFA after confirming the current password", async () => {
it("disables MFA after confirming the current password and a code", async () => {
vi.mocked(disableMfa).mockResolvedValueOnce(undefined);
const onChanged = vi.fn();
renderWithClient(<MfaSection mfaEnabled onChanged={onChanged} />);
@@ -60,9 +65,10 @@ describe("MfaSection", () => {
await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" }));
const passwordInput = await screen.findByLabelText("Passwort");
await userEvent.type(passwordInput, "correct-password");
await userEvent.type(screen.getByLabelText("Code aus der Authenticator-App"), "654321");
await userEvent.click(screen.getByRole("button", { name: "Deaktivieren" }));
expect(disableMfa).toHaveBeenCalledWith("correct-password");
expect(disableMfa).toHaveBeenCalledWith("correct-password", "654321");
await vi.waitFor(() => expect(onChanged).toHaveBeenCalled());
});
});
@@ -26,10 +26,17 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
function messageOf(error: unknown, fallback: string): string {
return error instanceof Error && error.message ? error.message : fallback;
}
const digitsOnly = (value: string) => value.replace(/\D/g, "").slice(0, 6);
function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
const [open, setOpen] = useState(false);
const [enrollment, setEnrollment] = useState<MfaEnrollment | null>(null);
const [code, setCode] = useState("");
const [currentPassword, setCurrentPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const startMutation = useMutation({
@@ -43,20 +50,21 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
if (!enrollment) {
throw new Error("no enrollment");
}
return confirmMfaEnrollment(enrollment.setupToken, code);
return confirmMfaEnrollment(enrollment.setupToken, code, currentPassword);
},
onSuccess: () => {
toast.success("MFA aktiviert");
toast.success("MFA aktiviert. Andere Geräte müssen sich neu anmelden.");
onEnabled();
handleClose();
},
onError: () => setError("Code ungültig."),
onError: (err) => setError(messageOf(err, "Code oder Passwort ungültig.")),
});
function handleTrigger() {
setError(null);
setEnrollment(null);
setCode("");
setCurrentPassword("");
setOpen(true);
startMutation.mutate();
}
@@ -65,6 +73,7 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
setOpen(false);
setEnrollment(null);
setCode("");
setCurrentPassword("");
setError(null);
}
@@ -83,7 +92,8 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
<DialogHeader>
<DialogTitle>MFA einrichten</DialogTitle>
<DialogDescription>
QR-Code mit deiner Authenticator-App scannen, dann den angezeigten Code eingeben.
1. QR-Code mit deiner Authenticator-App scannen. 2. Den angezeigten Code und dein
Passwort eingeben.
</DialogDescription>
</DialogHeader>
{enrollment ? (
@@ -104,7 +114,18 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
maxLength={6}
required
value={code}
onChange={(event) => setCode(event.target.value)}
onChange={(event) => setCode(digitsOnly(event.target.value))}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="mfa-enroll-password">Passwort</Label>
<Input
id="mfa-enroll-password"
type="password"
autoComplete="current-password"
required
value={currentPassword}
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</div>
{error ? (
@@ -134,21 +155,23 @@ function EnrollDialog({ onEnabled }: { onEnabled: () => void }) {
function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
const [open, setOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [mfaCode, setMfaCode] = useState("");
const [error, setError] = useState<string | null>(null);
const disableMutation = useMutation({
mutationFn: () => disableMfa(currentPassword),
mutationFn: () => disableMfa(currentPassword, mfaCode),
onSuccess: () => {
toast.success("MFA deaktiviert");
toast.success("MFA deaktiviert. Andere Geräte müssen sich neu anmelden.");
onDisabled();
handleClose();
},
onError: () => setError("Passwort ist falsch."),
onError: (err) => setError(messageOf(err, "Passwort oder Code ist falsch.")),
});
function handleClose() {
setOpen(false);
setCurrentPassword("");
setMfaCode("");
setError(null);
}
@@ -166,7 +189,9 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
<DialogContent>
<DialogHeader>
<DialogTitle>MFA deaktivieren</DialogTitle>
<DialogDescription>Zur Bestätigung aktuelles Passwort eingeben.</DialogDescription>
<DialogDescription>
Zur Bestätigung aktuelles Passwort und den Code aus der Authenticator-App eingeben.
</DialogDescription>
</DialogHeader>
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
<div className="flex flex-col gap-2">
@@ -180,6 +205,19 @@ function DisableDialog({ onDisabled }: { onDisabled: () => void }) {
onChange={(event) => setCurrentPassword(event.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="disable-mfa-code">Code aus der Authenticator-App</Label>
<Input
id="disable-mfa-code"
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
required
value={mfaCode}
onChange={(event) => setMfaCode(digitsOnly(event.target.value))}
/>
</div>
{error ? (
<p role="alert" className="text-sm text-destructive">
{error}
@@ -64,7 +64,7 @@ describe("LoginPage", () => {
await userEvent.type(screen.getByLabelText("Passwort"), "wrong-password");
await userEvent.click(screen.getByRole("button", { name: /anmelden/i }));
expect(await screen.findByRole("alert")).toHaveTextContent(/fehlgeschlagen/i);
expect(await screen.findByRole("alert")).toHaveTextContent(/E-Mail oder Passwort falsch/i);
});
it("shows the code step and verifies MFA when required", async () => {
@@ -107,7 +107,9 @@ function LoginPageContent() {
router.push("/dashboard");
}
} catch {
setError("Login fehlgeschlagen. Bitte E-Mail und Passwort prüfen.");
setError(
"E-Mail oder Passwort falsch. Nach 5 Fehlversuchen ist die Anmeldung für 15 Minuten gesperrt.",
);
} finally {
setIsSubmitting(false);
}
@@ -124,7 +126,9 @@ function LoginPageContent() {
await verifyMfa(mfaToken, code);
router.push("/dashboard");
} catch {
setError("Code ungültig. Bitte erneut versuchen.");
setError(
"Code ungültig oder schon benutzt. Bitte den nächsten Code aus der App eingeben.",
);
} finally {
setIsSubmitting(false);
}
@@ -9,6 +9,7 @@ const AUDIT_ACTION_LABELS: Record<string, string> = {
TOKEN_REFRESHED: "Token erneuert",
MFA_ENABLED: "MFA aktiviert",
MFA_DISABLED: "MFA deaktiviert",
ACCOUNT_LOCKED: "Konto vorübergehend gesperrt",
};
export function auditActionLabel(action: string): string {
+8 -4
View File
@@ -65,16 +65,20 @@ export function enrollMfa(): Promise<MfaEnrollment> {
return apiFetch<MfaEnrollment>("/profile/mfa/enroll", { method: "POST" });
}
export function confirmMfaEnrollment(setupToken: string, code: string): Promise<void> {
export function confirmMfaEnrollment(
setupToken: string,
code: string,
currentPassword: string,
): Promise<void> {
return apiFetch<void>("/profile/mfa/enroll/confirm", {
method: "POST",
body: JSON.stringify({ setupToken, code }),
body: JSON.stringify({ setupToken, code, currentPassword }),
});
}
export function disableMfa(currentPassword: string): Promise<void> {
export function disableMfa(currentPassword: string, mfaCode: string): Promise<void> {
return apiFetch<void>("/profile/mfa/disable", {
method: "POST",
body: JSON.stringify({ currentPassword }),
body: JSON.stringify({ currentPassword, mfaCode }),
});
}