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:
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);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user