SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
3.4 KB · 54 lines typescript
Raw Blame History
1import 'server-only';2import { and, desc, eq, gt, isNull } from '@/lib/db';3import { newId } from '@rareindex/shared';4import { authCodes, db } from '@/lib/db';5import { hmacToken, numericCode, safeEqual } from './crypto';67export type CodePurpose = 'verify_email' | 'mfa_email' | 'password_reset' | 'change_email' | 'new_device';89export const CODE_TTL_MIN: Record<CodePurpose, number> = { verify_email: 10, mfa_email: 10, password_reset: 30, change_email: 15, new_device: 10 };10const MAX_ATTEMPTS = 5;11const RESEND_COOLDOWN_S = 45;1213export interface IssuedCode {14  code: string;15  expiresAt: Date;16  minutes: number;17}1819/** Issue a fresh code, invalidating previous unconsumed codes for the same (email, purpose). */20export async function issueCode(params: { email: string; purpose: CodePurpose; userId?: string | null; payload?: Record<string, unknown> }): Promise<IssuedCode | { cooldownSeconds: number }> {21  const email = params.email.toLowerCase();22  const last = await db().select({ createdAt: authCodes.createdAt }).from(authCodes).where(and(eq(authCodes.email, email), eq(authCodes.purpose, params.purpose), isNull(authCodes.consumedAt))).orderBy(desc(authCodes.createdAt)).limit(1);23  const lastAt = last[0]?.createdAt;24  if (lastAt && Date.now() - lastAt.getTime() < RESEND_COOLDOWN_S * 1000) {25    return { cooldownSeconds: Math.ceil((RESEND_COOLDOWN_S * 1000 - (Date.now() - lastAt.getTime())) / 1000) };26  }27  await db().update(authCodes).set({ consumedAt: new Date() }).where(and(eq(authCodes.email, email), eq(authCodes.purpose, params.purpose), isNull(authCodes.consumedAt)));28  const code = numericCode(6);29  const minutes = CODE_TTL_MIN[params.purpose];30  const expiresAt = new Date(Date.now() + minutes * 60_000);31  await db().insert(authCodes).values({ id: newId('event'), userId: params.userId ?? null, email, purpose: params.purpose, codeHash: hmacToken(code, `code:${params.purpose}`), expiresAt, payload: params.payload ?? {} });32  return { code, expiresAt, minutes };33}3435export type VerifyOutcome = { ok: true; payload: Record<string, unknown>; userId: string | null } | { ok: false; reason: 'invalid' | 'expired' | 'locked' | 'missing' };3637/** Verify and consume a code. Counts attempts; locks after MAX_ATTEMPTS. */38export async function verifyCode(params: { email: string; purpose: CodePurpose; code: string }): Promise<VerifyOutcome> {39  const email = params.email.toLowerCase();40  const clean = params.code.replace(/\D/g, '');41  const rows = await db().select().from(authCodes).where(and(eq(authCodes.email, email), eq(authCodes.purpose, params.purpose), isNull(authCodes.consumedAt))).orderBy(desc(authCodes.createdAt)).limit(1);42  const row = rows[0];43  if (!row) return { ok: false, reason: 'missing' };44  if (row.attempts >= MAX_ATTEMPTS) return { ok: false, reason: 'locked' };45  if (row.expiresAt.getTime() < Date.now()) return { ok: false, reason: 'expired' };46  const good = clean.length === 6 && safeEqual(hmacToken(clean, `code:${params.purpose}`), row.codeHash);47  if (!good) {48    await db().update(authCodes).set({ attempts: row.attempts + 1 }).where(eq(authCodes.id, row.id));49    return { ok: false, reason: row.attempts + 1 >= MAX_ATTEMPTS ? 'locked' : 'invalid' };50  }51  await db().update(authCodes).set({ consumedAt: new Date() }).where(and(eq(authCodes.id, row.id), isNull(authCodes.consumedAt), gt(authCodes.expiresAt, new Date())));52  return { ok: true, payload: row.payload ?? {}, userId: row.userId };53}54