fix(security): prevent TOTP replay attacks and fix user enumeration in verifyTotp

Adds lastTotpAt timestamp to User model. After a successful TOTP validation,
the timestamp is recorded. Any reuse of the same code within the 30-second
window is rejected as a replay attack.

verifyTotp now returns a single generic UNAUTHORIZED error regardless of
whether the user ID is invalid or TOTP is not enabled, preventing enumeration
of user IDs and MFA status.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-09 21:41:09 +02:00
parent 1833182e90
commit afabaa0b7a
4 changed files with 64 additions and 13 deletions
+29
View File
@@ -146,6 +146,35 @@ const config = {
}); });
throw new InvalidTotpError(); throw new InvalidTotpError();
} }
// Replay-attack prevention: reject if the same 30-second window was already used
const userWithTotp = await prisma.user.findUnique({
where: { id: user.id },
select: { lastTotpAt: true },
}) as { lastTotpAt: Date | null } | null;
if (
userWithTotp?.lastTotpAt != null &&
Date.now() - userWithTotp.lastTotpAt.getTime() < 30_000
) {
logger.warn({ email, reason: "totp_replay" }, "TOTP replay attack blocked");
void createAuditEntry({
db: prisma,
entityType: "Auth",
entityId: user.id,
entityName: user.email,
action: "CREATE",
userId: user.id,
summary: "Login failed — TOTP replay detected",
source: "ui",
});
throw new InvalidTotpError();
}
// Record successful TOTP use to prevent replay within the same window
await (prisma.user.update as Function)({
where: { id: user.id },
data: { lastTotpAt: new Date() },
});
} }
// MFA enforcement: if the user's role is in requireMfaForRoles but they // MFA enforcement: if the user's role is in requireMfaForRoles but they
@@ -183,8 +183,8 @@ export async function verifyAndEnableTotp(
const user = await findUniqueOrThrow( const user = await findUniqueOrThrow(
ctx.db.user.findUnique({ ctx.db.user.findUnique({
where: { id: ctx.dbUser!.id }, where: { id: ctx.dbUser!.id },
select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true }, select: { id: true, name: true, email: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
}), }) as Promise<{ id: string; name: string | null; email: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null>,
"User", "User",
); );
@@ -210,9 +210,17 @@ export async function verifyAndEnableTotp(
throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid TOTP token." }); throw new TRPCError({ code: "BAD_REQUEST", message: "Invalid TOTP token." });
} }
await ctx.db.user.update({ // Replay-attack prevention: reject if the same 30-second window was already used
if (
user.lastTotpAt != null &&
Date.now() - user.lastTotpAt.getTime() < 30_000
) {
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP code already used. Wait for the next code." });
}
await (ctx.db.user.update as Function)({
where: { id: user.id }, where: { id: user.id },
data: { totpEnabled: true }, data: { totpEnabled: true, lastTotpAt: new Date() },
}); });
void createAuditEntry({ void createAuditEntry({
@@ -239,16 +247,14 @@ export async function verifyTotp(
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many TOTP attempts. Please wait before trying again." }); throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Too many TOTP attempts. Please wait before trying again." });
} }
const user = await findUniqueOrThrow( const user = await ctx.db.user.findUnique({
ctx.db.user.findUnique({ where: { id: input.userId },
where: { id: input.userId }, select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
select: { id: true, totpSecret: true, totpEnabled: true }, }) as { id: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null;
}),
"User",
);
if (!user.totpEnabled || !user.totpSecret) { // Generic error for both not-found and TOTP-not-enabled to prevent user enumeration
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." }); if (!user || !user.totpEnabled || !user.totpSecret) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
} }
const { TOTP, Secret } = await import("otpauth"); const { TOTP, Secret } = await import("otpauth");
@@ -266,6 +272,20 @@ export async function verifyTotp(
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." }); throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
} }
// Replay-attack prevention: reject if the same 30-second window was already used
if (
user.lastTotpAt != null &&
Date.now() - user.lastTotpAt.getTime() < 30_000
) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
}
// Record successful TOTP use to prevent replay within the same window
await (ctx.db.user.update as Function)({
where: { id: user.id },
data: { lastTotpAt: new Date() },
});
return { valid: true }; return { valid: true };
} }
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "lastTotpAt" TIMESTAMP(3);
+1
View File
@@ -187,6 +187,7 @@ model User {
lastActiveAt DateTime? lastActiveAt DateTime?
totpSecret String? // Base32 TOTP secret totpSecret String? // Base32 TOTP secret
totpEnabled Boolean @default(false) totpEnabled Boolean @default(false)
lastTotpAt DateTime? // tracks last successful TOTP use to prevent replay
isActive Boolean @default(true) isActive Boolean @default(true)
deletedAt DateTime? deletedAt DateTime?