- Sign-up emails a confirmation link (24h); unconfirmed accounts see a banner on the web with "send it again", and status in Settings. - Change email (needs the password): the new address gets a confirmation link and only replaces the old one once clicked; the old address gets a heads-up with a reset-password link. Pending changes can be cancelled. - Change password (needs the current one): signs out other sessions. - Password fields have Show/Hide on web and mobile. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MzFywujuH9TiuXbr9vjoX8
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import { and, eq, gt, ne } from 'drizzle-orm';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { changeEmailSchema, changePasswordSchema, verifyEmailSchema } from '@musicshare/shared';
|
||||
import { db, schema } from '../db';
|
||||
import { requireUser } from '../lib/auth';
|
||||
import { sha256 } from '../lib/crypto';
|
||||
import { HttpError } from '../lib/http';
|
||||
import { notifyEmailChange, sendVerificationEmail } from '../services/emailVerification';
|
||||
import { toMeDTO } from './me';
|
||||
|
||||
const sensitive = { config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } };
|
||||
|
||||
export async function accountRoutes(app: FastifyInstance) {
|
||||
/** The link in the confirmation email. Works signed in or not (it's often opened on another device). */
|
||||
app.post('/auth/verify-email', sensitive, async (req) => {
|
||||
const { token } = verifyEmailSchema.parse(req.body);
|
||||
return db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.delete(schema.emailVerifications)
|
||||
.where(and(eq(schema.emailVerifications.id, sha256(token)), gt(schema.emailVerifications.expiresAt, new Date())))
|
||||
.returning();
|
||||
if (!row) throw new HttpError(400, 'This confirmation link has expired or was already used.');
|
||||
const [taken] = await tx
|
||||
.select({ id: schema.users.id })
|
||||
.from(schema.users)
|
||||
.where(and(eq(schema.users.email, row.email), ne(schema.users.id, row.userId)));
|
||||
if (taken) throw new HttpError(409, 'That email now belongs to another account.');
|
||||
await tx
|
||||
.update(schema.users)
|
||||
.set({ email: row.email, emailVerifiedAt: new Date(), pendingEmail: null })
|
||||
.where(eq(schema.users.id, row.userId));
|
||||
return { ok: true as const, email: row.email };
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/me/verify-email/resend', { config: { rateLimit: { max: 3, timeWindow: '1 hour' } } }, async (req) => {
|
||||
const user = requireUser(req);
|
||||
const email = user.pendingEmail ?? (user.emailVerifiedAt ? null : user.email);
|
||||
if (!email) throw new HttpError(400, 'Your email is already confirmed.');
|
||||
await sendVerificationEmail(user, email, req.log);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
app.post('/me/password', sensitive, async (req) => {
|
||||
const user = requireUser(req);
|
||||
const input = changePasswordSchema.parse(req.body);
|
||||
if (!(await verify(user.passwordHash, input.currentPassword))) throw new HttpError(403, 'Your current password is wrong.');
|
||||
await db.update(schema.users).set({ passwordHash: await hash(input.newPassword) }).where(eq(schema.users.id, user.id));
|
||||
// Keep this session; sign out everywhere else.
|
||||
await db
|
||||
.delete(schema.sessions)
|
||||
.where(and(eq(schema.sessions.userId, user.id), ne(schema.sessions.id, req.sessionId!)));
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
app.post('/me/email', sensitive, async (req) => {
|
||||
const user = requireUser(req);
|
||||
const input = changeEmailSchema.parse(req.body);
|
||||
if (!(await verify(user.passwordHash, input.password))) throw new HttpError(403, 'Your password is wrong.');
|
||||
if (input.email === user.email) throw new HttpError(400, "That's already your email.");
|
||||
const [taken] = await db.select({ id: schema.users.id }).from(schema.users).where(eq(schema.users.email, input.email));
|
||||
if (taken) throw new HttpError(409, 'That email is already registered.');
|
||||
const [updated] = await db.update(schema.users).set({ pendingEmail: input.email }).where(eq(schema.users.id, user.id)).returning();
|
||||
await sendVerificationEmail(updated!, input.email, req.log);
|
||||
if (user.emailVerifiedAt) notifyEmailChange(user, input.email, req.log);
|
||||
return toMeDTO(updated!);
|
||||
});
|
||||
|
||||
app.delete('/me/email/pending', async (req) => {
|
||||
const user = requireUser(req);
|
||||
await db.delete(schema.emailVerifications).where(eq(schema.emailVerifications.userId, user.id));
|
||||
const [updated] = await db.update(schema.users).set({ pendingEmail: null }).where(eq(schema.users.id, user.id)).returning();
|
||||
// Unconfirmed sign-ups keep a way to confirm their current address.
|
||||
if (!updated!.emailVerifiedAt) await sendVerificationEmail(updated!, updated!.email, req.log);
|
||||
return toMeDTO(updated!);
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ 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 { sendVerificationEmail } from '../services/emailVerification';
|
||||
import { toMeDTO } from './me';
|
||||
|
||||
const authRateLimit = { config: { rateLimit: { max: 10, timeWindow: '1 minute' } } };
|
||||
@@ -36,6 +37,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
.returning();
|
||||
return created;
|
||||
});
|
||||
await sendVerificationEmail(user!, user!.email, req.log);
|
||||
const token = await createSession(user!.id, reply);
|
||||
return { user: toMeDTO(user!), token };
|
||||
});
|
||||
|
||||
@@ -20,6 +20,8 @@ export const toMeDTO = (u: User): MeDTO => ({
|
||||
displayName: u.displayName,
|
||||
avatarUrl: publicAvatarUrl(u.avatarUrl),
|
||||
email: u.email,
|
||||
emailVerified: Boolean(u.emailVerifiedAt),
|
||||
pendingEmail: u.pendingEmail,
|
||||
bio: u.bio,
|
||||
preferredService: u.preferredService,
|
||||
role: u.role,
|
||||
|
||||
Reference in New Issue
Block a user