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
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "totpLastCounter" INTEGER;
+4
View File
@@ -128,6 +128,10 @@ model User {
/// sperrt sich aus, wer die Einrichtung abbricht. /// sperrt sich aus, wer die Einrichtung abbricht.
totpSecretCipher String? totpSecretCipher String?
totpEnabledAt DateTime? totpEnabledAt DateTime?
/// Zuletzt eingeloestes Zeitfenster. RFC 6238 verlangt, denselben Code
/// kein zweites Mal anzunehmen – ohne diesen Merker liesse sich ein
/// abgefangener Code die restliche Gueltigkeit ueber wiederverwenden.
totpLastCounter Int?
sessions Session[] sessions Session[]
credentials Credential[] credentials Credential[]
+7 -4
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { requireUserApi } from "@/lib/auth/guard"; import { requireUserApi } from "@/lib/auth/guard";
import { verifyPassword } from "@/lib/auth/password"; import { verifyPassword } from "@/lib/auth/password";
import { countUnusedRecoveryCodes, issueRecoveryCodes } from "@/lib/auth/recovery"; 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 { APP_NAME } from "@/lib/config";
import { encryptSecret, tryDecryptSecret } from "@/lib/crypto"; import { encryptSecret, tryDecryptSecret } from "@/lib/crypto";
import { prisma } from "@/lib/db"; 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); 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( return jsonError(
"Der Code stimmt nicht. Geht die Uhr des Geraets richtig?", "Der Code stimmt nicht. Geht die Uhr des Geraets richtig?",
401, 401,
@@ -118,7 +119,9 @@ export async function PUT(request: Request) {
await prisma.user.update({ await prisma.user.update({
where: { id: auth.user.id }, 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({ return NextResponse.json({
@@ -157,7 +160,7 @@ export async function DELETE(request: Request) {
await prisma.$transaction([ await prisma.$transaction([
prisma.user.update({ prisma.user.update({
where: { id: auth.user.id }, 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.recoveryCode.deleteMany({ where: { userId: auth.user.id } }),
prisma.pendingLogin.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"; } from "@/lib/auth/mfa";
import { redeemRecoveryCode } from "@/lib/auth/recovery"; import { redeemRecoveryCode } from "@/lib/auth/recovery";
import { createSession, setSessionCookie } from "@/lib/auth/session"; 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 { noteSuccessfulLogin } from "@/lib/auth/users";
import { prisma } from "@/lib/db";
import { LIMITS, rateLimit } from "@/lib/security/rateLimit"; import { LIMITS, rateLimit } from "@/lib/security/rateLimit";
import { clientIp, jsonError, readJsonBody, tooManyRequests } from "@/lib/security/request"; 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 // Wiederherstellungscode. Beide Wege zu probieren waere unnoetig – die
// Formate ueberschneiden sich nicht. // Formate ueberschneiden sich nicht.
const istZiffern = /^[\d\s]+$/.test(eingabe); const istZiffern = /^[\d\s]+$/.test(eingabe);
const erfolg = istZiffern
? Boolean(pending.user.totpSecret) && verifyTotp(pending.user.totpSecret!, eingabe) // Bei den Ziffern zaehlt zusaetzlich, dass das Zeitfenster neuer ist als
: await redeemRecoveryCode(pending.user.id, eingabe); // 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) { if (!erfolg) {
const weiterMoeglich = await noteMfaFailure(pending.id); const weiterMoeglich = await noteMfaFailure(pending.id);
@@ -83,6 +94,14 @@ export async function POST(request: Request) {
await discardPendingLogin(pending.id); await discardPendingLogin(pending.id);
await noteSuccessfulLogin(pending.user.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, { const session = await createSession(pending.user.id, {
userAgent: request.headers.get("user-agent"), userAgent: request.headers.get("user-agent"),
ip, ip,
+3
View File
@@ -75,6 +75,8 @@ export interface PendingLoginState {
isActive: boolean; isActive: boolean;
/** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */ /** Entschluesselter TOTP-Schluessel; null, wenn er nicht lesbar ist. */
totpSecret: string | null; totpSecret: string | null;
/** Zuletzt eingeloestes Zeitfenster – gegen die Wiederverwendung. */
totpLastCounter: number | null;
}; };
} }
@@ -104,6 +106,7 @@ export async function readPendingLogin(): Promise<PendingLoginState | null> {
role: pending.user.role, role: pending.user.role,
isActive: pending.user.isActive, isActive: pending.user.isActive,
totpSecret: tryDecryptSecret(pending.user.totpSecretCipher), totpSecret: tryDecryptSecret(pending.user.totpSecretCipher),
totpLastCounter: pending.user.totpLastCounter,
}, },
}; };
} }
+31
View File
@@ -6,6 +6,7 @@ import {
counterFor, counterFor,
generateTotpSecret, generateTotpSecret,
groupSecret, groupSecret,
matchTotp,
otpauthUri, otpauthUri,
totpCode, totpCode,
verifyTotp, verifyTotp,
@@ -124,3 +125,33 @@ describe("Einrichtung", () => {
expect(groupSecret("ABCDEFGH")).toBe("ABCD EFGH"); expect(groupSecret("ABCDEFGH")).toBe("ABCD EFGH");
}); });
}); });
describe("matchTotp", () => {
const SECRET = base32Encode(Buffer.from("12345678901234567890", "utf8"));
const JETZT = new Date(1111111109_000);
/**
* Der Zaehler ist noetig, um einen verbrauchten Code zu entwerten. RFC 6238
* verlangt das in Abschnitt 5.2 ausdruecklich – sonst laesst sich ein
* abgefangener Code die restliche Gueltigkeitsdauer ueber wiederverwenden.
*/
it("gibt das Zeitfenster zurueck, das gepasst hat", () => {
expect(matchTotp(SECRET, "081804", JETZT)).toBe(37037036);
});
it("nennt bei den Nachbarfenstern deren eigene Nummer", () => {
// Genau daran haengt die Entwertung: der Code aus dem vorigen Fenster
// darf den Merker nicht auf einen hoeheren Stand setzen.
const naechstes = new Date(JETZT.getTime() + TOTP_PERIOD * 1000);
expect(matchTotp(SECRET, "081804", naechstes)).toBe(37037036);
});
it("liefert null bei falschem Code", () => {
expect(matchTotp(SECRET, "000000", JETZT)).toBeNull();
});
it("stimmt mit verifyTotp ueberein", () => {
expect(matchTotp(SECRET, "081804", JETZT) !== null).toBe(verifyTotp(SECRET, "081804", JETZT));
expect(matchTotp(SECRET, "000000", JETZT) !== null).toBe(verifyTotp(SECRET, "000000", JETZT));
});
});
+31 -13
View File
@@ -102,26 +102,44 @@ export function counterFor(at: Date = new Date()): number {
} }
/** /**
* Prueft einen eingegebenen Code. Vergleicht in konstanter Zeit und probiert * Prueft einen eingegebenen Code und gibt das Zeitfenster zurueck, das
* die Nachbarfenster mit, damit eine leicht abweichende Uhr nicht stoert. * gepasst hat – oder null.
*
* Das Fenster wird gebraucht, um einen verbrauchten Code zu entwerten:
* RFC 6238 verlangt in Abschnitt 5.2 ausdruecklich, dass derselbe Code kein
* zweites Mal angenommen wird. Ohne diese Zahl liesse sich ein abgefangener
* Code die restliche Gueltigkeitsdauer ueber wiederverwenden.
*
* Verglichen wird in konstanter Zeit, und die Nachbarfenster werden
* mitprobiert, damit eine leicht abweichende Uhr nicht stoert.
*/ */
export function matchTotp(
secret: string,
code: string,
at: Date = new Date(),
window: number = TOTP_WINDOW,
): number | null {
const eingabe = code.replace(/\s/g, "");
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return null;
const jetzt = counterFor(at);
let treffer: number | null = null;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = jetzt + schritt;
}
return treffer;
}
/** Wie matchTotp, wenn nur interessiert *ob* der Code stimmt. */
export function verifyTotp( export function verifyTotp(
secret: string, secret: string,
code: string, code: string,
at: Date = new Date(), at: Date = new Date(),
window: number = TOTP_WINDOW, window: number = TOTP_WINDOW,
): boolean { ): boolean {
const eingabe = code.replace(/\s/g, ""); return matchTotp(secret, code, at, window) !== null;
if (!/^\d+$/.test(eingabe) || eingabe.length !== TOTP_DIGITS) return false;
const jetzt = counterFor(at);
let treffer = false;
for (let schritt = -window; schritt <= window; schritt += 1) {
// Nicht fruehzeitig abbrechen: sonst haengt die Laufzeit davon ab,
// welches Fenster gepasst hat.
if (constantEquals(totpCode(secret, jetzt + schritt), eingabe)) treffer = true;
}
return treffer;
} }
function constantEquals(a: string, b: string): boolean { function constantEquals(a: string, b: string): boolean {