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%
16.5 KB · 292 lines typescript
Raw Blame History
1'use server';23import { redirect } from 'next/navigation';4import { and, eq, isNull } from '@/lib/db';5import { newId } from '@rareindex/shared';6import { sendMail, verificationEmail, mfaCodeEmail, newLoginEmail, passwordResetEmail } from '@rareindex/notify';7import { db, users, loginEvents, recoveryCodes, watchlists } from '@/lib/db';8import { hashPassword, verifyPassword, decryptSecret, hmacToken, normalizeRecoveryCode, signPayload, verifyPayload } from './crypto';9import { issueCode, verifyCode } from './codes';10import { createSession, getCurrentUser, destroySession, revokeAllSessions } from './session';11import { isTrustedDevice, trustThisDevice, revokeAllDevices } from './devices';12import { clearPending, getPending, safeNext, setPending, type PendingStage } from './pending';13import { enforce, RateLimited } from './rate-limit';14import { clientInfo } from './request';15import { verifyTotp } from './totp';16import { codeSchema, emailSchema, loginSchema, passwordSchema, signupSchema } from './validation';17import { fieldErrorsFrom, type ActionState } from './state';1819const SITE = () => (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io').replace(/\/$/, '');2021async function logLogin(input: { userId?: string | null; email?: string | null; outcome: string; method?: string | null }) {22  const { ip, userAgent } = await clientInfo();23  await db().insert(loginEvents).values({ id: newId('event'), userId: input.userId ?? null, email: input.email ?? null, ip, userAgent, outcome: input.outcome, method: input.method ?? null });24}2526function fail(error: string, extra: Partial<ActionState> = {}): ActionState {27  return { ok: false, error, ...extra };28}2930async function guarded<T>(fn: () => Promise<T>): Promise<T | ActionState> {31  try {32    return await fn();33  } catch (err) {34    if (err instanceof RateLimited) return fail(err.message, { data: { retryAfter: err.retryAfterSeconds } });35    throw err;36  }37}3839// ---------------------------------------------------------------- signup4041export async function signupAction(_prev: ActionState, formData: FormData): Promise<ActionState> {42  return guarded(async () => {43    const parsed = signupSchema.safeParse({ email: formData.get('email'), password: formData.get('password'), name: formData.get('name') ?? '' });44    if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });45    const { email, password, name } = parsed.data;46    const { ip } = await clientInfo();47    await enforce(`signup:ip:${ip ?? 'x'}`, 10, 3600);48    await enforce(`signup:email:${email}`, 5, 3600);4950    const existing = await db().select({ id: users.id, verified: users.emailVerifiedAt, deletedAt: users.deletedAt }).from(users).where(eq(users.email, email)).limit(1);51    let userId: string;52    if (existing[0]) {53      if (existing[0].verified && !existing[0].deletedAt) return fail('An account with this e-mail already exists.', { fieldErrors: { email: 'Already registered — sign in instead.' } });54      // unverified (or soft-deleted) account: allow re-registration by resetting credentials55      userId = existing[0].id;56      await db().update(users).set({ passwordHash: await hashPassword(password), name: name || null, deletedAt: null, purgeAfter: null }).where(eq(users.id, userId));57    } else {58      userId = newId('user');59      await db().insert(users).values({ id: userId, email, passwordHash: await hashPassword(password), name: name || null, preferences: { emailAlerts: true, digest: 'weekly', digestWeekday: 1, newLoginEmails: true } });60      await db().insert(watchlists).values({ id: newId('watchlist'), userId, name: 'Watchlist' });61    }62    const issued = await issueCode({ email, purpose: 'verify_email', userId });63    if ('code' in issued) {64      const mail = verificationEmail({ code: issued.code, minutes: issued.minutes });65      await sendMail({ to: email, ...mail, tags: [{ name: 'kind', value: 'verify' }] });66    }67    await setPending({ uid: userId, email, stage: 'verify', next: safeNext(String(formData.get('next') ?? ''), '/collections?welcome=1'), newDevice: true });68    redirect('/verify');69  });70}7172export async function resendCodeAction(_prev: ActionState): Promise<ActionState> {73  return guarded(async () => {74    const p = await getPending();75    if (!p) return fail('Your session expired. Start again.');76    const { ip } = await clientInfo();77    await enforce(`resend:${p.email}`, 6, 3600);78    await enforce(`resend:ip:${ip ?? 'x'}`, 20, 3600);79    const purpose = p.stage === 'verify' ? 'verify_email' : 'mfa_email';80    const issued = await issueCode({ email: p.email, purpose, userId: p.uid });81    if ('cooldownSeconds' in issued) return fail(`Please wait ${issued.cooldownSeconds}s before requesting another code.`, { data: { retryAfter: issued.cooldownSeconds } });82    const { userAgent } = await clientInfo();83    const mail = purpose === 'verify_email' ? verificationEmail({ code: issued.code, minutes: issued.minutes }) : mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent });84    await sendMail({ to: p.email, ...mail });85    return { ok: true, message: 'A new code is on its way.' };86  });87}8889export async function verifyEmailAction(_prev: ActionState, formData: FormData): Promise<ActionState> {90  return guarded(async () => {91    const p = await getPending();92    if (!p || p.stage !== 'verify') return fail('Your session expired. Sign in to get a new code.');93    const code = codeSchema.safeParse(formData.get('code'));94    if (!code.success) return fail('Enter the 6-digit code.', { fieldErrors: { code: code.error.issues[0]?.message ?? 'Invalid' } });95    await enforce(`verify:${p.email}`, 12, 900);96    const res = await verifyCode({ email: p.email, purpose: 'verify_email', code: code.data });97    if (!res.ok) return fail(res.reason === 'locked' ? 'Too many wrong codes. Request a new one.' : res.reason === 'expired' ? 'That code expired. Request a new one.' : res.reason === 'missing' ? 'No active code. Request a new one.' : 'That code is not right.');98    await db().update(users).set({ emailVerifiedAt: new Date(), lastLoginAt: new Date() }).where(eq(users.id, p.uid));99    await createSession(p.uid);100    await trustThisDevice(p.uid);101    await logLogin({ userId: p.uid, email: p.email, outcome: 'success', method: 'email_code' });102    await clearPending();103    redirect(safeNext(p.next, '/collections?welcome=1'));104  });105}106107// ---------------------------------------------------------------- login108109export async function loginAction(_prev: ActionState, formData: FormData): Promise<ActionState> {110  return guarded(async () => {111    const parsed = loginSchema.safeParse({ email: formData.get('email'), password: formData.get('password') });112    if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) });113    const { email, password } = parsed.data;114    const next = safeNext(String(formData.get('next') ?? ''));115    const { ip, userAgent } = await clientInfo();116    await enforce(`login:ip:${ip ?? 'x'}`, 30, 900);117    await enforce(`login:email:${email}`, 10, 900);118119    const rows = await db().select().from(users).where(eq(users.email, email)).limit(1);120    const user = rows[0];121    // constant-ish time: always run a hash verification122    const good = await verifyPassword(password, user?.passwordHash ?? 'scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA');123    if (!user || !good) {124      await logLogin({ userId: user?.id ?? null, email, outcome: user ? 'bad_password' : 'unknown_user', method: 'password' });125      return fail('E-mail or password is incorrect.');126    }127    if (user.deletedAt && user.purgeAfter && user.purgeAfter.getTime() < Date.now()) return fail('This account has been deleted.');128129    if (!user.emailVerifiedAt) {130      const issued = await issueCode({ email, purpose: 'verify_email', userId: user.id });131      if ('code' in issued) await sendMail({ to: email, ...verificationEmail({ code: issued.code, minutes: issued.minutes }) });132      await setPending({ uid: user.id, email, stage: 'verify', next, newDevice: true });133      redirect('/verify');134    }135136    const trusted = await isTrustedDevice(user.id);137    let stage: PendingStage | null = null;138    if (user.mfaEnabled && !trusted) stage = 'totp';139    else if (!user.mfaEnabled && user.alwaysAskCode && !trusted) stage = 'email_code';140141    if (stage) {142      if (stage === 'email_code') {143        const issued = await issueCode({ email, purpose: 'mfa_email', userId: user.id });144        if ('code' in issued) await sendMail({ to: email, ...mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent }) });145      }146      await logLogin({ userId: user.id, email, outcome: 'mfa_required', method: 'password' });147      await setPending({ uid: user.id, email, stage, next, newDevice: !trusted });148      redirect('/mfa');149    }150151    await completeLogin(user.id, email, 'password', !trusted, false);152    redirect(next);153  });154}155156async function completeLogin(userId: string, email: string, method: string, newDevice: boolean, trust: boolean) {157  const rows = await db().select({ deletedAt: users.deletedAt, prefs: users.preferences, name: users.name }).from(users).where(eq(users.id, userId)).limit(1);158  const u = rows[0];159  await db().update(users).set({ lastLoginAt: new Date(), ...(u?.deletedAt ? { deletedAt: null, purgeAfter: null } : {}) }).where(eq(users.id, userId));160  await createSession(userId);161  if (trust) await trustThisDevice(userId);162  await logLogin({ userId, email, outcome: 'success', method });163  await clearPending();164  const prefs = (u?.prefs ?? {}) as { newLoginEmails?: boolean };165  if (newDevice && prefs.newLoginEmails !== false) {166    const { ip, userAgent } = await clientInfo();167    void sendMail({ to: email, ...newLoginEmail({ when: new Date(), ip, userAgent, method }) }).catch(() => {});168  }169}170171export async function mfaVerifyAction(_prev: ActionState, formData: FormData): Promise<ActionState> {172  return guarded(async () => {173    const p = await getPending();174    if (!p || (p.stage !== 'totp' && p.stage !== 'email_code')) return fail('Your sign-in session expired. Sign in again.');175    const method = String(formData.get('method') ?? (p.stage === 'totp' ? 'totp' : 'email_code'));176    const trust = formData.get('trust') === 'on';177    const raw = String(formData.get('code') ?? '');178    await enforce(`mfa:${p.uid}`, 12, 900);179    const rows = await db().select().from(users).where(eq(users.id, p.uid)).limit(1);180    const user = rows[0];181    if (!user) return fail('Account not found.');182183    if (method === 'totp') {184      if (!user.mfaEnabled || !user.totpSecretEnc) return fail('Authenticator is not enabled on this account.');185      const delta = verifyTotp(decryptSecret(user.totpSecretEnc), raw);186      if (delta === null) {187        await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'totp' });188        return fail('That authenticator code is not valid.');189      }190    } else if (method === 'email_code') {191      const code = codeSchema.safeParse(raw);192      if (!code.success) return fail('Enter the 6-digit code.');193      const res = await verifyCode({ email: p.email, purpose: 'mfa_email', code: code.data });194      if (!res.ok) {195        await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'email_code' });196        return fail(res.reason === 'locked' ? 'Too many wrong codes. Request a new one.' : res.reason === 'expired' || res.reason === 'missing' ? 'That code expired or was not issued. Request a new one.' : 'That code is not right.');197      }198    } else if (method === 'recovery') {199      const norm = normalizeRecoveryCode(raw);200      const hash = hmacToken(norm, 'recovery');201      const rc = await db().select({ id: recoveryCodes.id }).from(recoveryCodes).where(and(eq(recoveryCodes.userId, user.id), eq(recoveryCodes.codeHash, hash), isNull(recoveryCodes.usedAt))).limit(1);202      if (!rc[0]) {203        await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'recovery_code' });204        return fail('That recovery code is not valid or was already used.');205      }206      await db().update(recoveryCodes).set({ usedAt: new Date() }).where(eq(recoveryCodes.id, rc[0].id));207    } else {208      return fail('Unknown method.');209    }210    await completeLogin(user.id, p.email, method === 'recovery' ? 'recovery_code' : method, p.newDevice, trust);211    redirect(safeNext(p.next));212  });213}214215/** Switch a TOTP challenge to an e-mailed code (fallback). */216export async function mfaUseEmailAction(_prev: ActionState): Promise<ActionState> {217  return guarded(async () => {218    const p = await getPending();219    if (!p || p.stage !== 'totp') return fail('Your sign-in session expired.');220    await enforce(`mfa-email:${p.uid}`, 5, 3600);221    const issued = await issueCode({ email: p.email, purpose: 'mfa_email', userId: p.uid });222    if ('cooldownSeconds' in issued) return fail(`Please wait ${issued.cooldownSeconds}s before requesting another code.`);223    const { ip, userAgent } = await clientInfo();224    await sendMail({ to: p.email, ...mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent }) });225    await setPending({ ...p, stage: 'email_code' });226    return { ok: true, message: 'We e-mailed you a sign-in code.' };227  });228}229230export async function logoutAction(): Promise<void> {231  await destroySession();232  redirect('/');233}234235export async function logoutEverywhereAction(): Promise<ActionState> {236  const u = await getCurrentUser();237  if (!u) return fail('Not signed in.');238  const n = await revokeAllSessions(u.id, true);239  await revokeAllDevices(u.id);240  return { ok: true, message: `Signed out of ${n} other session${n === 1 ? '' : 's'} and forgot all trusted devices.` };241}242243// ---------------------------------------------------------------- password reset244245export async function forgotAction(_prev: ActionState, formData: FormData): Promise<ActionState> {246  return guarded(async () => {247    const email = emailSchema.safeParse(formData.get('email'));248    if (!email.success) return fail('Enter a valid e-mail address.');249    const { ip } = await clientInfo();250    await enforce(`forgot:ip:${ip ?? 'x'}`, 10, 3600);251    await enforce(`forgot:${email.data}`, 4, 3600);252    const rows = await db().select({ id: users.id }).from(users).where(eq(users.email, email.data)).limit(1);253    if (rows[0]) {254      const issued = await issueCode({ email: email.data, purpose: 'password_reset', userId: rows[0].id });255      if ('code' in issued) {256        const t = signPayload({ email: email.data, code: issued.code }, issued.minutes * 60);257        await sendMail({ to: email.data, ...passwordResetEmail({ code: issued.code, link: `${SITE()}/reset?t=${encodeURIComponent(t)}`, minutes: issued.minutes }) });258      }259    }260    // Same response whether or not the account exists.261    return { ok: true, message: 'If an account exists for that address, a reset code is on its way.' };262  });263}264265export async function resetAction(_prev: ActionState, formData: FormData): Promise<ActionState> {266  return guarded(async () => {267    let email = String(formData.get('email') ?? '');268    let code = String(formData.get('code') ?? '');269    const t = String(formData.get('t') ?? '');270    if (t) {271      const p = verifyPayload<{ email: string; code: string }>(t);272      if (!p) return fail('This reset link expired. Request a new one.');273      email = p.email;274      code = p.code;275    }276    const e = emailSchema.safeParse(email);277    const c = codeSchema.safeParse(code);278    const pw = passwordSchema.safeParse(formData.get('password'));279    if (!e.success || !c.success || !pw.success) return fail('Check the highlighted fields.', { fieldErrors: { ...(e.success ? {} : { email: 'Invalid e-mail' }), ...(c.success ? {} : { code: 'Enter the 6-digit code' }), ...(pw.success ? {} : { password: pw.error.issues[0]?.message ?? 'Invalid password' }) } });280    await enforce(`reset:${e.data}`, 10, 900);281    const res = await verifyCode({ email: e.data, purpose: 'password_reset', code: c.data });282    if (!res.ok) return fail(res.reason === 'locked' ? 'Too many attempts. Request a new code.' : 'That code is not valid or expired.');283    const rows = await db().select({ id: users.id }).from(users).where(eq(users.email, e.data)).limit(1);284    if (!rows[0]) return fail('Account not found.');285    await db().update(users).set({ passwordHash: await hashPassword(pw.data), passwordChangedAt: new Date(), emailVerifiedAt: new Date() }).where(eq(users.id, rows[0].id));286    await revokeAllSessions(rows[0].id, false);287    await revokeAllDevices(rows[0].id);288    await logLogin({ userId: rows[0].id, email: e.data, outcome: 'success', method: 'password_reset' });289    redirect('/login?reset=1');290  });291}292