Email confirmation, change email/password, show/hide passwords
CI / check (push) Canceled after 0s

- 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:
2026-10-06 13:05:26 -04:00
co-authored by Claude Opus 5.5
parent 45a7bd190a
commit 85e1643c29
25 changed files with 2077 additions and 19 deletions
+2
View File
@@ -11,6 +11,7 @@ import { loadUser } from './lib/auth';
import { HttpError } from './lib/http';
import { redis } from './lib/redis';
import { adminRoutes } from './routes/admin';
import { accountRoutes } from './routes/account';
import { authRoutes } from './routes/auth';
import { avatarRoutes } from './routes/avatars';
import { connectionRoutes } from './routes/connections';
@@ -66,6 +67,7 @@ export async function buildApp({ logger = true, rateLimits = true }: { logger?:
});
await app.register(authRoutes);
await app.register(accountRoutes);
await app.register(meRoutes);
await app.register(avatarRoutes);
await app.register(userRoutes);
+19
View File
@@ -22,6 +22,10 @@ export const users = pgTable('users', {
username: varchar('username', { length: 32 }).notNull().unique(),
email: varchar('email', { length: 320 }).notNull().unique(),
passwordHash: text('password_hash').notNull(),
/** Set once they click the link we email them; null until then. */
emailVerifiedAt: timestamp('email_verified_at', { withTimezone: true }),
/** A new address waiting for its confirmation link; `email` changes only once it's clicked. */
pendingEmail: varchar('pending_email', { length: 320 }),
displayName: varchar('display_name', { length: 64 }),
bio: varchar('bio', { length: 280 }),
/** Path of the uploaded picture (`/avatars/<id>?v=<version>`), resolved against API_PUBLIC_URL when sent. */
@@ -238,6 +242,21 @@ export const featuredSongs = pgTable('featured_songs', {
.default(sql`now()`),
});
/** Email confirmation links (sign-up and email changes). Only a hash of the token is stored. */
export const emailVerifications = pgTable(
'email_verifications',
{
id: text('id').primaryKey(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
email: varchar('email', { length: 320 }).notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: createdAt(),
},
(t) => [index('email_verifications_user_idx').on(t.userId)],
);
/** One-time password reset links. Only a hash of the token is stored. */
export const passwordResets = pgTable(
'password_resets',
+78
View File
@@ -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!);
});
}
+2
View File
@@ -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 };
});
+2
View File
@@ -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,
@@ -0,0 +1,52 @@
import { eq } from 'drizzle-orm';
import type { FastifyBaseLogger } from 'fastify';
import { db, schema } from '../db';
import type { User } from '../db/schema';
import { env } from '../env';
import { randomToken, sha256 } from '../lib/crypto';
import { sendMail, simpleEmail } from '../lib/mail';
const VERIFY_HOURS = 24;
/**
* Emails a confirmation link for `email` (their current address after sign-up,
* or a new one they're switching to). Replaces any earlier link.
*/
export async function sendVerificationEmail(user: User, email: string, log: FastifyBaseLogger) {
await db.delete(schema.emailVerifications).where(eq(schema.emailVerifications.userId, user.id));
const token = randomToken();
await db.insert(schema.emailVerifications).values({
id: sha256(token),
userId: user.id,
email,
expiresAt: new Date(Date.now() + VERIFY_HOURS * 3600_000),
});
const changing = email !== user.email;
const url = `${env.WEB_URL.replace(/\/$/, '')}/verify-email?token=${encodeURIComponent(token)}`;
const mail = simpleEmail({
lines: changing
? [`Hi ${user.displayName ?? user.username},`, `Confirm ${email} as the new email for your musicshare account.`]
: [`Welcome to musicshare, ${user.displayName ?? user.username}!`, 'Confirm your email so you can reset your password if you ever forget it.'],
button: { label: changing ? 'Confirm new email' : 'Confirm my email', url },
footer: `This link expires in ${VERIFY_HOURS} hours. If you didn't ask for this, you can ignore it.`,
});
sendMail({ to: email, subject: changing ? 'Confirm your new musicshare email' : 'Confirm your musicshare email', ...mail }).catch(
(err) => log.error({ err }, 'verification email failed'),
);
}
/** Tells the old address an email change was requested, so a hijacker can't do it quietly. */
export function notifyEmailChange(user: User, newEmail: string, log: FastifyBaseLogger) {
const mail = simpleEmail({
lines: [
`Hi ${user.displayName ?? user.username},`,
`Someone asked to change your musicshare email to ${newEmail}. It only changes once that address is confirmed.`,
"If this wasn't you, reset your password now.",
],
button: { label: 'Reset my password', url: `${env.WEB_URL.replace(/\/$/, '')}/forgot-password` },
footer: 'You can ignore this email if you made the change.',
});
sendMail({ to: user.email, subject: 'Your musicshare email is being changed', ...mail }).catch((err) =>
log.error({ err }, 'email change notice failed'),
);
}