security: atomic compare-and-swap for TOTP replay window (#43, part 1)
The previous SELECT → compare → UPDATE sequence let two concurrent login requests with the same valid 6-digit code both observe a stale lastTotpAt, both pass the in-JS replay check, and both succeed. A stolen TOTP (shoulder- surf, phishing-proxy replay) was usable twice within its 30 s window. Replace the three callsites (login authorize, self-service enable, self- service verify) with a shared consumeTotpWindow() helper: a single updateMany() expresses "window unused" as a SQL WHERE clause, so Postgres' row lock serialises concurrent writers and whichever commits second sees count=0 and is treated as a replay. Backup codes (ticket part 2) are tracked as follow-up work. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import { prisma } from "@capakraken/db";
|
||||
import { authRateLimiter } from "@capakraken/api/middleware/rate-limit";
|
||||
import { createAuditEntry } from "@capakraken/api/lib/audit";
|
||||
import { logger } from "@capakraken/api/lib/logger";
|
||||
import { consumeTotpWindow } from "@capakraken/api/lib/totp-consume";
|
||||
import NextAuth, { type NextAuthConfig } from "next-auth";
|
||||
import Credentials from "next-auth/providers/credentials";
|
||||
import { CredentialsSignin } from "next-auth";
|
||||
@@ -188,15 +189,12 @@ 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
|
||||
) {
|
||||
// Atomic replay-guard: a single UPDATE ... WHERE lastTotpAt is null
|
||||
// OR older than 30 s both serialises concurrent logins (row lock)
|
||||
// and expresses the "unused window" precondition in SQL. count=0
|
||||
// means another request consumed this window first → replay.
|
||||
const accepted = await consumeTotpWindow(prisma, user.id);
|
||||
if (!accepted) {
|
||||
logger.warn({ email, reason: "totp_replay" }, "TOTP replay attack blocked");
|
||||
void createAuditEntry({
|
||||
db: prisma,
|
||||
@@ -210,12 +208,6 @@ const config = {
|
||||
});
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user