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