'use server'; import { redirect } from 'next/navigation'; import { and, eq, isNull } from '@/lib/db'; import { newId } from '@rareindex/shared'; import { sendMail, verificationEmail, mfaCodeEmail, newLoginEmail, passwordResetEmail } from '@rareindex/notify'; import { db, users, loginEvents, recoveryCodes, watchlists } from '@/lib/db'; import { hashPassword, verifyPassword, decryptSecret, hmacToken, normalizeRecoveryCode, signPayload, verifyPayload } from './crypto'; import { issueCode, verifyCode } from './codes'; import { createSession, getCurrentUser, destroySession, revokeAllSessions } from './session'; import { isTrustedDevice, trustThisDevice, revokeAllDevices } from './devices'; import { clearPending, getPending, safeNext, setPending, type PendingStage } from './pending'; import { enforce, RateLimited } from './rate-limit'; import { clientInfo } from './request'; import { verifyTotp } from './totp'; import { codeSchema, emailSchema, loginSchema, passwordSchema, signupSchema } from './validation'; import { fieldErrorsFrom, type ActionState } from './state'; const SITE = () => (process.env.NEXT_PUBLIC_SITE_URL ?? 'https://www.rareindex.io').replace(/\/$/, ''); async function logLogin(input: { userId?: string | null; email?: string | null; outcome: string; method?: string | null }) { const { ip, userAgent } = await clientInfo(); 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 }); } function fail(error: string, extra: Partial = {}): ActionState { return { ok: false, error, ...extra }; } async function guarded(fn: () => Promise): Promise { try { return await fn(); } catch (err) { if (err instanceof RateLimited) return fail(err.message, { data: { retryAfter: err.retryAfterSeconds } }); throw err; } } // ---------------------------------------------------------------- signup export async function signupAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const parsed = signupSchema.safeParse({ email: formData.get('email'), password: formData.get('password'), name: formData.get('name') ?? '' }); if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) }); const { email, password, name } = parsed.data; const { ip } = await clientInfo(); await enforce(`signup:ip:${ip ?? 'x'}`, 10, 3600); await enforce(`signup:email:${email}`, 5, 3600); const existing = await db().select({ id: users.id, verified: users.emailVerifiedAt, deletedAt: users.deletedAt }).from(users).where(eq(users.email, email)).limit(1); let userId: string; if (existing[0]) { if (existing[0].verified && !existing[0].deletedAt) return fail('An account with this e-mail already exists.', { fieldErrors: { email: 'Already registered — sign in instead.' } }); // unverified (or soft-deleted) account: allow re-registration by resetting credentials userId = existing[0].id; await db().update(users).set({ passwordHash: await hashPassword(password), name: name || null, deletedAt: null, purgeAfter: null }).where(eq(users.id, userId)); } else { userId = newId('user'); await db().insert(users).values({ id: userId, email, passwordHash: await hashPassword(password), name: name || null, preferences: { emailAlerts: true, digest: 'weekly', digestWeekday: 1, newLoginEmails: true } }); await db().insert(watchlists).values({ id: newId('watchlist'), userId, name: 'Watchlist' }); } const issued = await issueCode({ email, purpose: 'verify_email', userId }); if ('code' in issued) { const mail = verificationEmail({ code: issued.code, minutes: issued.minutes }); await sendMail({ to: email, ...mail, tags: [{ name: 'kind', value: 'verify' }] }); } await setPending({ uid: userId, email, stage: 'verify', next: safeNext(String(formData.get('next') ?? ''), '/collections?welcome=1'), newDevice: true }); redirect('/verify'); }); } export async function resendCodeAction(_prev: ActionState): Promise { return guarded(async () => { const p = await getPending(); if (!p) return fail('Your session expired. Start again.'); const { ip } = await clientInfo(); await enforce(`resend:${p.email}`, 6, 3600); await enforce(`resend:ip:${ip ?? 'x'}`, 20, 3600); const purpose = p.stage === 'verify' ? 'verify_email' : 'mfa_email'; const issued = await issueCode({ email: p.email, purpose, userId: p.uid }); if ('cooldownSeconds' in issued) return fail(`Please wait ${issued.cooldownSeconds}s before requesting another code.`, { data: { retryAfter: issued.cooldownSeconds } }); const { userAgent } = await clientInfo(); const mail = purpose === 'verify_email' ? verificationEmail({ code: issued.code, minutes: issued.minutes }) : mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent }); await sendMail({ to: p.email, ...mail }); return { ok: true, message: 'A new code is on its way.' }; }); } export async function verifyEmailAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const p = await getPending(); if (!p || p.stage !== 'verify') return fail('Your session expired. Sign in to get a new code.'); const code = codeSchema.safeParse(formData.get('code')); if (!code.success) return fail('Enter the 6-digit code.', { fieldErrors: { code: code.error.issues[0]?.message ?? 'Invalid' } }); await enforce(`verify:${p.email}`, 12, 900); const res = await verifyCode({ email: p.email, purpose: 'verify_email', code: code.data }); 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.'); await db().update(users).set({ emailVerifiedAt: new Date(), lastLoginAt: new Date() }).where(eq(users.id, p.uid)); await createSession(p.uid); await trustThisDevice(p.uid); await logLogin({ userId: p.uid, email: p.email, outcome: 'success', method: 'email_code' }); await clearPending(); redirect(safeNext(p.next, '/collections?welcome=1')); }); } // ---------------------------------------------------------------- login export async function loginAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const parsed = loginSchema.safeParse({ email: formData.get('email'), password: formData.get('password') }); if (!parsed.success) return fail('Check the highlighted fields.', { fieldErrors: fieldErrorsFrom(parsed.error.issues) }); const { email, password } = parsed.data; const next = safeNext(String(formData.get('next') ?? '')); const { ip, userAgent } = await clientInfo(); await enforce(`login:ip:${ip ?? 'x'}`, 30, 900); await enforce(`login:email:${email}`, 10, 900); const rows = await db().select().from(users).where(eq(users.email, email)).limit(1); const user = rows[0]; // constant-ish time: always run a hash verification const good = await verifyPassword(password, user?.passwordHash ?? 'scrypt$32768$8$1$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'); if (!user || !good) { await logLogin({ userId: user?.id ?? null, email, outcome: user ? 'bad_password' : 'unknown_user', method: 'password' }); return fail('E-mail or password is incorrect.'); } if (user.deletedAt && user.purgeAfter && user.purgeAfter.getTime() < Date.now()) return fail('This account has been deleted.'); if (!user.emailVerifiedAt) { const issued = await issueCode({ email, purpose: 'verify_email', userId: user.id }); if ('code' in issued) await sendMail({ to: email, ...verificationEmail({ code: issued.code, minutes: issued.minutes }) }); await setPending({ uid: user.id, email, stage: 'verify', next, newDevice: true }); redirect('/verify'); } const trusted = await isTrustedDevice(user.id); let stage: PendingStage | null = null; if (user.mfaEnabled && !trusted) stage = 'totp'; else if (!user.mfaEnabled && user.alwaysAskCode && !trusted) stage = 'email_code'; if (stage) { if (stage === 'email_code') { const issued = await issueCode({ email, purpose: 'mfa_email', userId: user.id }); if ('code' in issued) await sendMail({ to: email, ...mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent }) }); } await logLogin({ userId: user.id, email, outcome: 'mfa_required', method: 'password' }); await setPending({ uid: user.id, email, stage, next, newDevice: !trusted }); redirect('/mfa'); } await completeLogin(user.id, email, 'password', !trusted, false); redirect(next); }); } async function completeLogin(userId: string, email: string, method: string, newDevice: boolean, trust: boolean) { const rows = await db().select({ deletedAt: users.deletedAt, prefs: users.preferences, name: users.name }).from(users).where(eq(users.id, userId)).limit(1); const u = rows[0]; await db().update(users).set({ lastLoginAt: new Date(), ...(u?.deletedAt ? { deletedAt: null, purgeAfter: null } : {}) }).where(eq(users.id, userId)); await createSession(userId); if (trust) await trustThisDevice(userId); await logLogin({ userId, email, outcome: 'success', method }); await clearPending(); const prefs = (u?.prefs ?? {}) as { newLoginEmails?: boolean }; if (newDevice && prefs.newLoginEmails !== false) { const { ip, userAgent } = await clientInfo(); void sendMail({ to: email, ...newLoginEmail({ when: new Date(), ip, userAgent, method }) }).catch(() => {}); } } export async function mfaVerifyAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const p = await getPending(); if (!p || (p.stage !== 'totp' && p.stage !== 'email_code')) return fail('Your sign-in session expired. Sign in again.'); const method = String(formData.get('method') ?? (p.stage === 'totp' ? 'totp' : 'email_code')); const trust = formData.get('trust') === 'on'; const raw = String(formData.get('code') ?? ''); await enforce(`mfa:${p.uid}`, 12, 900); const rows = await db().select().from(users).where(eq(users.id, p.uid)).limit(1); const user = rows[0]; if (!user) return fail('Account not found.'); if (method === 'totp') { if (!user.mfaEnabled || !user.totpSecretEnc) return fail('Authenticator is not enabled on this account.'); const delta = verifyTotp(decryptSecret(user.totpSecretEnc), raw); if (delta === null) { await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'totp' }); return fail('That authenticator code is not valid.'); } } else if (method === 'email_code') { const code = codeSchema.safeParse(raw); if (!code.success) return fail('Enter the 6-digit code.'); const res = await verifyCode({ email: p.email, purpose: 'mfa_email', code: code.data }); if (!res.ok) { await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'email_code' }); 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.'); } } else if (method === 'recovery') { const norm = normalizeRecoveryCode(raw); const hash = hmacToken(norm, 'recovery'); 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); if (!rc[0]) { await logLogin({ userId: user.id, email: p.email, outcome: 'mfa_failed', method: 'recovery_code' }); return fail('That recovery code is not valid or was already used.'); } await db().update(recoveryCodes).set({ usedAt: new Date() }).where(eq(recoveryCodes.id, rc[0].id)); } else { return fail('Unknown method.'); } await completeLogin(user.id, p.email, method === 'recovery' ? 'recovery_code' : method, p.newDevice, trust); redirect(safeNext(p.next)); }); } /** Switch a TOTP challenge to an e-mailed code (fallback). */ export async function mfaUseEmailAction(_prev: ActionState): Promise { return guarded(async () => { const p = await getPending(); if (!p || p.stage !== 'totp') return fail('Your sign-in session expired.'); await enforce(`mfa-email:${p.uid}`, 5, 3600); const issued = await issueCode({ email: p.email, purpose: 'mfa_email', userId: p.uid }); if ('cooldownSeconds' in issued) return fail(`Please wait ${issued.cooldownSeconds}s before requesting another code.`); const { ip, userAgent } = await clientInfo(); await sendMail({ to: p.email, ...mfaCodeEmail({ code: issued.code, minutes: issued.minutes, ip, userAgent }) }); await setPending({ ...p, stage: 'email_code' }); return { ok: true, message: 'We e-mailed you a sign-in code.' }; }); } export async function logoutAction(): Promise { await destroySession(); redirect('/'); } export async function logoutEverywhereAction(): Promise { const u = await getCurrentUser(); if (!u) return fail('Not signed in.'); const n = await revokeAllSessions(u.id, true); await revokeAllDevices(u.id); return { ok: true, message: `Signed out of ${n} other session${n === 1 ? '' : 's'} and forgot all trusted devices.` }; } // ---------------------------------------------------------------- password reset export async function forgotAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { const email = emailSchema.safeParse(formData.get('email')); if (!email.success) return fail('Enter a valid e-mail address.'); const { ip } = await clientInfo(); await enforce(`forgot:ip:${ip ?? 'x'}`, 10, 3600); await enforce(`forgot:${email.data}`, 4, 3600); const rows = await db().select({ id: users.id }).from(users).where(eq(users.email, email.data)).limit(1); if (rows[0]) { const issued = await issueCode({ email: email.data, purpose: 'password_reset', userId: rows[0].id }); if ('code' in issued) { const t = signPayload({ email: email.data, code: issued.code }, issued.minutes * 60); await sendMail({ to: email.data, ...passwordResetEmail({ code: issued.code, link: `${SITE()}/reset?t=${encodeURIComponent(t)}`, minutes: issued.minutes }) }); } } // Same response whether or not the account exists. return { ok: true, message: 'If an account exists for that address, a reset code is on its way.' }; }); } export async function resetAction(_prev: ActionState, formData: FormData): Promise { return guarded(async () => { let email = String(formData.get('email') ?? ''); let code = String(formData.get('code') ?? ''); const t = String(formData.get('t') ?? ''); if (t) { const p = verifyPayload<{ email: string; code: string }>(t); if (!p) return fail('This reset link expired. Request a new one.'); email = p.email; code = p.code; } const e = emailSchema.safeParse(email); const c = codeSchema.safeParse(code); const pw = passwordSchema.safeParse(formData.get('password')); 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' }) } }); await enforce(`reset:${e.data}`, 10, 900); const res = await verifyCode({ email: e.data, purpose: 'password_reset', code: c.data }); if (!res.ok) return fail(res.reason === 'locked' ? 'Too many attempts. Request a new code.' : 'That code is not valid or expired.'); const rows = await db().select({ id: users.id }).from(users).where(eq(users.email, e.data)).limit(1); if (!rows[0]) return fail('Account not found.'); await db().update(users).set({ passwordHash: await hashPassword(pw.data), passwordChangedAt: new Date(), emailVerifiedAt: new Date() }).where(eq(users.id, rows[0].id)); await revokeAllSessions(rows[0].id, false); await revokeAllDevices(rows[0].id); await logLogin({ userId: rows[0].id, email: e.data, outcome: 'success', method: 'password_reset' }); redirect('/login?reset=1'); }); }