Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 47 additions & 8 deletions packages/auth/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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.",
);
Expand Down
4 changes: 4 additions & 0 deletions packages/db/src/schemas/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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] })],
);
24 changes: 23 additions & 1 deletion sites/mainweb/app/(portal)/api/auth/verify-email/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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();
Expand Down Expand Up @@ -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;
}

Expand Down
92 changes: 92 additions & 0 deletions sites/mainweb/lib/verify-email-route.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading