diff --git a/packages/auth/src/config.ts b/packages/auth/src/config.ts index 281f0c0b..eeb74082 100644 --- a/packages/auth/src/config.ts +++ b/packages/auth/src/config.ts @@ -129,15 +129,42 @@ export const authConfig: NextAuthConfig = { // lets anyone spam sign-in requests to stack up valid codes, and every extra // one multiplies the odds of a blind guess against a 6-digit secret. if (db) { + // One code email per address per minute, whatever instance takes the + // request: the live code's expiry says when it was issued. A repeat + // inside that minute sends nothing and keeps the code already in the + // inbox, so hammering sign-in cannot flood somebody's mail or burn + // the shared sending quota one address at a time. + const issuedWithinMinute = new Date( + Date.now() + 9 * 60 * 1000, + ).toISOString(); const expiresISO = expires.toISOString(); - await db.execute(sql` - DELETE FROM "verificationToken" - WHERE "identifier" = ${identifier} AND "token" LIKE 'custom:%' - `); - await db.execute(sql` - INSERT INTO "verificationToken" ("identifier", "token", "expires") - VALUES (${identifier}, ${customToken}, ${expiresISO}::timestamp) - `); + // Check, drop and insert as one step per address: the advisory lock + // serialises concurrent requests for the same identifier across + // instances, so parallel sign-ins cannot all see "no recent code" + // and each send one. + const issued = await db.transaction(async (tx) => { + await tx.execute( + sql`SELECT pg_advisory_xact_lock(hashtext(${identifier}))`, + ); + const recent = await tx.execute(sql` + SELECT 1 FROM "verificationToken" + WHERE "identifier" = ${identifier} AND "token" LIKE 'custom:%' + AND "expires" > ${issuedWithinMinute}::timestamp + LIMIT 1 + `); + if (recent.rows.length > 0) return false; + + await tx.execute(sql` + DELETE FROM "verificationToken" + WHERE "identifier" = ${identifier} AND "token" LIKE 'custom:%' + `); + await tx.execute(sql` + INSERT INTO "verificationToken" ("identifier", "token", "expires") + VALUES (${identifier}, ${customToken}, ${expiresISO}::timestamp) + `); + return true; + }); + if (!issued) return; } const { createTransport } = await import("nodemailer"); @@ -165,6 +192,18 @@ export const authConfig: NextAuthConfig = { throw new Error(`Email(s) could not be sent`); } } catch { + // The row above now looks freshly issued; left in place it would make + // the retry inside the next minute send nothing and report success. + if (db) { + await db + .execute( + sql` + DELETE FROM "verificationToken" + WHERE "identifier" = ${identifier} AND "token" = ${customToken} + `, + ) + .catch(() => undefined); + } throw new Error( "Failed to send verification email. Please try again later.", ); diff --git a/packages/db/src/schemas/auth.ts b/packages/db/src/schemas/auth.ts index d885370f..d95a1d3a 100644 --- a/packages/db/src/schemas/auth.ts +++ b/packages/db/src/schemas/auth.ts @@ -66,6 +66,10 @@ export const verificationTokens = pgTable( identifier: text("identifier").notNull(), token: text("token").notNull(), expires: timestamp("expires", { mode: "date" }).notNull(), + // Wrong guesses against this identifier's sign-in code. Counted here, not + // in the per-instance rate limiter, so every instance sees the same total + // and a code dies after MAX_CODE_ATTEMPTS misses however requests spread. + attempts: integer("attempts").notNull().default(0), }, (vt) => [primaryKey({ columns: [vt.identifier, vt.token] })], ); diff --git a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts index 31c636dc..d350de03 100644 --- a/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts +++ b/sites/mainweb/app/(portal)/api/auth/verify-email/route.ts @@ -15,7 +15,7 @@ import { isBootcampAddOnOnly, planFromMetadata, } from "@query/db/services/membership"; -import { eq, and, isNull } from "drizzle-orm"; +import { eq, and, isNull, like, gte, sql } from "drizzle-orm"; import { rateLimit, cache, resolveClientIp } from "@query/api"; import type { DrizzleDB } from "@query/db"; @@ -27,6 +27,13 @@ import type { DrizzleDB } from "@query/db"; * returns the session cookie + redirect URL. */ +/** + * Wrong guesses a code survives. Six digits against five guesses is 1 in + * 200,000 per code; the in-memory limiter alone is per instance, so with ten + * instances it allowed ten times the guesses it was sized for. + */ +const MAX_CODE_ATTEMPTS = 5; + export async function POST(request: NextRequest) { try { const body = await request.json(); @@ -91,6 +98,21 @@ export async function POST(request: NextRequest) { if (!invite) { console.warn(`[verify-email] No matching code for ${safeEmail}`); + // Charge the miss to the live code, and burn it once it has absorbed + // MAX_CODE_ATTEMPTS. Returning (not throwing) commits both. + const liveCode = and( + eq(verificationTokens.identifier, identifier), + like(verificationTokens.token, "custom:%"), + ); + await tx + .update(verificationTokens) + .set({ attempts: sql`${verificationTokens.attempts} + 1` }) + .where(liveCode); + await tx + .delete(verificationTokens) + .where( + and(liveCode, gte(verificationTokens.attempts, MAX_CODE_ATTEMPTS)), + ); return null; } diff --git a/sites/mainweb/lib/verify-email-route.test.ts b/sites/mainweb/lib/verify-email-route.test.ts new file mode 100644 index 00000000..e95243ae --- /dev/null +++ b/sites/mainweb/lib/verify-email-route.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { NextRequest } from "next/server"; + +// What the route's transaction does with verificationToken on a guess. +const calls = vi.hoisted(() => ({ + updates: [] as unknown[], + deletes: 0, + matched: null as null | { expires: Date }, +})); + +vi.mock("@query/db", () => { + const tx = { + // The consume step: DELETE … RETURNING the row a correct code matches. + delete: () => ({ + where: () => { + calls.deletes += 1; + return Object.assign(Promise.resolve(undefined), { + returning: async () => (calls.matched ? [calls.matched] : []), + }); + }, + }), + update: () => ({ + set: (values: unknown) => ({ + where: async () => { + calls.updates.push(values); + }, + }), + }), + }; + return { + db: { transaction: async (cb: (t: typeof tx) => unknown) => cb(tx) }, + users: {}, + sessions: {}, + accounts: {}, + stripePayments: {}, + userAccountLinks: {}, + verificationTokens: { + identifier: "identifier", + token: "token", + attempts: "attempts", + }, + }; +}); + +vi.mock("@query/db/services/membership", () => ({ + createOrUpdateMembership: vi.fn(), + paidForBootcamp: vi.fn(), + isBootcampAddOnOnly: vi.fn(), + planFromMetadata: vi.fn(), +})); + +vi.mock("@query/api", () => ({ + rateLimit: () => ({ allowed: true }), + cache: { delete: vi.fn(), deletePattern: vi.fn() }, + resolveClientIp: () => "127.0.0.1", +})); + +vi.mock("drizzle-orm", () => ({ + eq: (...args: unknown[]) => ({ eq: args }), + and: (...args: unknown[]) => ({ and: args }), + isNull: (...args: unknown[]) => ({ isNull: args }), + like: (...args: unknown[]) => ({ like: args }), + gte: (...args: unknown[]) => ({ gte: args }), + sql: (strings: TemplateStringsArray) => ({ sql: strings.join("?") }), +})); + +const { POST } = await import("@/app/(portal)/api/auth/verify-email/route"); + +const guess = (code: string) => + POST({ + json: async () => ({ code, email: "Member@GaTech.edu" }), + headers: { get: () => null }, + } as unknown as NextRequest); + +describe("verify-email wrong guesses", () => { + beforeEach(() => { + calls.updates = []; + calls.deletes = 0; + calls.matched = null; + }); + + it("charges a wrong code against the live one and burns it at the limit", async () => { + const res = await guess("000000"); + + expect(res.status).toBe(401); + // One attempt added to the live code… + expect(calls.updates).toHaveLength(1); + expect(calls.updates[0]).toHaveProperty("attempts"); + // …then the consume attempt plus the burn-at-limit delete. + expect(calls.deletes).toBe(2); + }); +});