fix(auth): Einmalkennwoerter nicht zweimal annehmen

Ein TOTP-Code blieb rund 90 Sekunden gueltig und liess sich darin beliebig
oft verwenden. RFC 6238 verlangt in Abschnitt 5.2 ausdruecklich das
Gegenteil: derselbe Code darf kein zweites Mal angenommen werden.

Ohne diese Sperre kann ein abgefangener Code - ueber die Schulter gesehen,
von einer weiterleitenden Phishing-Seite, von Schadsoftware vom Bildschirm
gelesen - die restliche Gueltigkeitsdauer ueber wiederverwendet werden.

verifyTotp gibt dafuer jetzt ueber matchTotp das Zeitfenster zurueck, das
gepasst hat; angenommen wird nur, was neuer ist als das zuletzt eingeloeste.
Der Vergleich bleibt dabei in konstanter Zeit - es wird weiterhin nicht
fruehzeitig abgebrochen.

Auch die Aktivierung verbrennt ihr Fenster: der Code, der eben
scharfgeschaltet hat, soll nicht Sekunden spaeter noch zum Anmelden taugen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 00:14:10 +02:00
co-authored by Claude Opus 5
parent 72e5eb43c4
commit 27f51b6be8
7 changed files with 101 additions and 21 deletions
+7 -4
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password";
import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery";
import { generateTotpSecret, groupSecret, otpauthUri, verifyTotp } from "@/lib/auth/totp";
import { generateTotpSecret, groupSecret, matchTotp, otpauthUri } from "@/lib/auth/totp";
import { APP_NAME } from "@/lib/config";
import { encryptSecret, tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db";
@@ -109,7 +109,8 @@ export async function PUT(request: Request) {
return jsonError("Es liegt kein Schluessel vor. Bitte die Einrichtung neu starten.", 409);
}
if (!verifyTotp(secret, parsed.data.code)) {
const fenster = matchTotp(secret, parsed.data.code);
if (fenster === null) {
return jsonError(
"Der Code stimmt nicht. Geht die Uhr des Geraets richtig?",
401,
@@ -118,7 +119,9 @@ export async function PUT(request: Request) {
await prisma.user.update({
where: { id: auth.user.id },
data: { totpEnabledAt: new Date() },
// Das Fenster gleich mitverbrennen: der Code, der eben aktiviert hat,
// soll nicht Sekunden spaeter noch zum Anmelden taugen.
data: { totpEnabledAt: new Date(), totpLastCounter: fenster },
});
return NextResponse.json({
@@ -157,7 +160,7 @@ export async function DELETE(request: Request) {
await prisma.$transaction([
prisma.user.update({
where: { id: auth.user.id },
data: { totpSecretCipher: null, totpEnabledAt: null },
data: { totpSecretCipher: null, totpEnabledAt: null, totpLastCounter: null },
}),
prisma.recoveryCode.deleteMany({ where: { userId: auth.user.id } }),
prisma.pendingLogin.deleteMany({ where: { userId: auth.user.id } }),
+23 -4
View File
@@ -8,8 +8,9 @@ import {
} from "@/lib/auth/mfa";
import { redeemRecoveryCode } from "@/lib/auth/recovery";
import { createSession, setSessionCookie } from "@/lib/auth/session";
import { verifyTotp } from "@/lib/auth/totp";
import { matchTotp } from "@/lib/auth/totp";
import { noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request";
@@ -63,9 +64,19 @@ export async function POST(request: Request) {
// Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die
// Formate ueberschneiden sich nicht.
const istZiffern = /^[\d\s]+$/.test(eingabe);
const erfolg = istZiffern
? Boolean(pending.user.totpSecret) && verifyTotp(pending.user.totpSecret!, eingabe)
: await redeemRecoveryCode(pending.user.id, eingabe);
// Bei den Ziffern zaehlt zusaetzlich, dass das Zeitfenster neuer ist als
// das zuletzt eingeloeste. Sonst liesse sich ein abgefangener Code die
// restliche Gueltigkeitsdauer ueber wiederverwenden – RFC 6238, 5.2.
const fenster =
istZiffern && pending.user.totpSecret
? matchTotp(pending.user.totpSecret, eingabe)
: null;
const frisch =
fenster !== null &&
(pending.user.totpLastCounter === null || fenster > pending.user.totpLastCounter);
const erfolg = istZiffern ? frisch : await redeemRecoveryCode(pending.user.id, eingabe);
if (!erfolg) {
const weiterMoeglich = await noteMfaFailure(pending.id);
@@ -83,6 +94,14 @@ export async function POST(request: Request) {
await discardPendingLogin(pending.id);
await noteSuccessfulLogin(pending.user.id);
// Das eingeloeste Fenster merken, damit derselbe Code verbrannt ist.
if (fenster !== null) {
await prisma.user.update({
where: { id: pending.user.id },
data: { totpLastCounter: fenster },
});
}
const session = await createSession(pending.user.id, {
userAgent: request.headers.get("user-agent"),
ip,