diff --git a/apps/web/src/server/auth.ts b/apps/web/src/server/auth.ts index 7df1b57..7a0ebd7 100644 --- a/apps/web/src/server/auth.ts +++ b/apps/web/src/server/auth.ts @@ -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 diff --git a/packages/api/src/router/user-self-service-procedure-support.ts b/packages/api/src/router/user-self-service-procedure-support.ts index d8f91f0..fedb157 100644 --- a/packages/api/src/router/user-self-service-procedure-support.ts +++ b/packages/api/src/router/user-self-service-procedure-support.ts @@ -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 }; } diff --git a/packages/db/prisma/migrations/20260409_user_last_totp_at.sql b/packages/db/prisma/migrations/20260409_user_last_totp_at.sql new file mode 100644 index 0000000..4a631d3 --- /dev/null +++ b/packages/db/prisma/migrations/20260409_user_last_totp_at.sql @@ -0,0 +1 @@ +ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "lastTotpAt" TIMESTAMP(3); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index a9a5ff3..b0a4bb3 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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?