CI / check (push) Canceled after 0s
"Forgot your password?" on web and mobile sends a one-time link (1 hour) to the account's email; the website's reset page sets the new password, signs out every other session and signs in. The request responds the same whether or not the email has an account, and the email goes out in the background so timing doesn't reveal it either. Only a hash of the link token is stored. Sends through SMTP_* settings; without them, emails are logged (and captured in tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
118 lines
5.8 KiB
TypeScript
118 lines
5.8 KiB
TypeScript
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 };
|
|
});
|
|
}
|