import { hash, verify } from '@node-rs/argon2'; import { and, count, eq, gt, isNull, or, sql } from 'drizzle-orm'; import type { FastifyInstance } from 'fastify'; import { forgotPasswordSchema, loginSchema, registerSchema, resetPasswordSchema } from '@musicshare/shared'; import { db, schema } from '../db'; import { env } from '../env'; import { SESSION_COOKIE, createSession } from '../lib/auth'; import { randomToken, sha256 } from '../lib/crypto'; import { HttpError } from '../lib/http'; import { sendMail, simpleEmail } from '../lib/mail'; import { toMeDTO } from './me'; const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } }; const RESET_MINUTES = 60; export async function authRoutes(app: FastifyInstance) { app.post('/auth/register', authRateLimit, async (req, reply) => { const input = registerSchema.parse(req.body); // Usernames are stored lowercase; keep the capitalization they typed as the display name. const typed = String((req.body as { username?: unknown }).username ?? '').trim(); const displayName = typed.toLowerCase() === input.username ? typed : input.username; const [taken] = await db .select({ username: schema.users.username, email: schema.users.email }) .from(schema.users) .where(or(eq(schema.users.username, input.username), eq(schema.users.email, input.email))); if (taken) throw new HttpError(409, taken.email === input.email ? 'Email already registered' : 'Username taken'); const passwordHash = await hash(input.password); const user = await db.transaction(async (tx) => { // Serialize sign-ups so two simultaneous "first" accounts can't both become admin. await tx.execute(sql`select pg_advisory_xact_lock(4242)`); const [{ n }] = (await tx.select({ n: count() }).from(schema.users)) as [{ n: number }]; const [created] = await tx .insert(schema.users) .values({ username: input.username, displayName, email: input.email, passwordHash, role: n === 0 ? 'admin' : 'user' }) .returning(); return created; }); const token = await createSession(user!.id, reply); return { user: toMeDTO(user!), token }; }); app.post('/auth/login', authRateLimit, async (req, reply) => { const input = loginSchema.parse(req.body); const [user] = await db.select().from(schema.users).where(eq(schema.users.email, input.email)); if (!user || !(await verify(user.passwordHash, input.password))) { throw new HttpError(401, 'Invalid email or password'); } if (user.suspendedAt) throw new HttpError(403, 'This account has been suspended'); const token = await createSession(user.id, reply); return { user: toMeDTO(user), token }; }); /** * Emails a one-time reset link. Responds the same whether or not the email has * an account, so it can't be used to find out who's signed up. */ app.post('/auth/forgot-password', { config: { rateLimit: { max: 5, timeWindow: '1 hour' } } }, async (req) => { const { email } = forgotPasswordSchema.parse(req.body); const [user] = await db.select().from(schema.users).where(eq(schema.users.email, email)); if (!user || user.suspendedAt) return { ok: true }; // One live link at a time: asking again replaces the old one. await db.delete(schema.passwordResets).where(eq(schema.passwordResets.userId, user.id)); const token = randomToken(); await db.insert(schema.passwordResets).values({ id: sha256(token), userId: user.id, expiresAt: new Date(Date.now() + RESET_MINUTES * 60_000), }); const url = `${env.WEB_URL.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(token)}`; const mail = simpleEmail({ lines: [`Hi ${user.displayName ?? user.username},`, 'Someone (hopefully you) asked to reset your musicshare password.'], button: { label: 'Choose a new password', url }, footer: `This link works once and expires in ${RESET_MINUTES} minutes. If you didn't ask for it, you can ignore this email; your password won't change.`, }); // Send in the background: waiting on the mail server would make the response // slower for real accounts, which would also reveal who has one. sendMail({ to: user.email, subject: 'Reset your musicshare password', ...mail }).catch((err) => req.log.error({ err }, 'password reset email failed'), ); return { ok: true }; }); app.post('/auth/reset-password', authRateLimit, async (req, reply) => { const input = resetPasswordSchema.parse(req.body); const passwordHash = await hash(input.password); const user = await db.transaction(async (tx) => { const [reset] = await tx .update(schema.passwordResets) .set({ usedAt: new Date() }) .where( and( eq(schema.passwordResets.id, sha256(input.token)), isNull(schema.passwordResets.usedAt), gt(schema.passwordResets.expiresAt, new Date()), ), ) .returning(); if (!reset) return null; const [updated] = await tx.update(schema.users).set({ passwordHash }).where(eq(schema.users.id, reset.userId)).returning(); // Sign out everywhere: whoever knew the old password shouldn't stay signed in. await tx.delete(schema.sessions).where(eq(schema.sessions.userId, reset.userId)); return updated ?? null; }); if (!user) throw new HttpError(400, 'This reset link has expired or was already used. Ask for a new one.'); if (user.suspendedAt) throw new HttpError(403, 'This account has been suspended'); const token = await createSession(user.id, reply); return { user: toMeDTO(user), token }; }); app.post('/auth/logout', async (req, reply) => { if (req.sessionId) await db.delete(schema.sessions).where(eq(schema.sessions.id, req.sessionId)); reply.clearCookie(SESSION_COOKIE, { path: '/' }); return { ok: true }; }); }