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:
@@ -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 } }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user