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:
@@ -183,8 +183,8 @@ export async function verifyAndEnableTotp(
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
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",
|
||||
);
|
||||
|
||||
@@ -210,9 +210,17 @@ export async function verifyAndEnableTotp(
|
||||
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 },
|
||||
data: { totpEnabled: true },
|
||||
data: { totpEnabled: true, lastTotpAt: new Date() },
|
||||
});
|
||||
|
||||
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." });
|
||||
}
|
||||
|
||||
const user = await findUniqueOrThrow(
|
||||
ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, totpSecret: true, totpEnabled: true },
|
||||
}),
|
||||
"User",
|
||||
);
|
||||
const user = await ctx.db.user.findUnique({
|
||||
where: { id: input.userId },
|
||||
select: { id: true, totpSecret: true, totpEnabled: true, lastTotpAt: true },
|
||||
}) as { id: string; totpSecret: string | null; totpEnabled: boolean; lastTotpAt: Date | null } | null;
|
||||
|
||||
if (!user.totpEnabled || !user.totpSecret) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "TOTP is not enabled for this user." });
|
||||
// Generic error for both not-found and TOTP-not-enabled to prevent user enumeration
|
||||
if (!user || !user.totpEnabled || !user.totpSecret) {
|
||||
throw new TRPCError({ code: "UNAUTHORIZED", message: "Invalid TOTP token." });
|
||||
}
|
||||
|
||||
const { TOTP, Secret } = await import("otpauth");
|
||||
@@ -266,6 +272,20 @@ export async function verifyTotp(
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "lastTotpAt" TIMESTAMP(3);
|
||||
@@ -187,6 +187,7 @@ model User {
|
||||
lastActiveAt DateTime?
|
||||
totpSecret String? // Base32 TOTP secret
|
||||
totpEnabled Boolean @default(false)
|
||||
lastTotpAt DateTime? // tracks last successful TOTP use to prevent replay
|
||||
isActive Boolean @default(true)
|
||||
deletedAt DateTime?
|
||||
|
||||
|
||||
Reference in New Issue
Block a user