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();
}
// 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