- 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,12 @@
|
||||
CREATE TABLE "email_verifications" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"email" varchar(320) NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "email_verified_at" timestamp with time zone;--> statement-breakpoint
|
||||
ALTER TABLE "users" ADD COLUMN "pending_email" varchar(320);--> statement-breakpoint
|
||||
ALTER TABLE "email_verifications" ADD CONSTRAINT "email_verifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "email_verifications_user_idx" ON "email_verifications" USING btree ("user_id");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
||||
"when": 1791262411052,
|
||||
"tag": "0011_password_resets",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1791305642128,
|
||||
"tag": "0012_email_verification",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { outbox } from '../src/lib/mail';
|
||||
import { bearer, createUser, useTestApp } from './helpers';
|
||||
|
||||
const app = useTestApp();
|
||||
beforeEach(() => {
|
||||
outbox.length = 0;
|
||||
});
|
||||
|
||||
async function mailTo(to: string, subject: RegExp) {
|
||||
const find = () => outbox.filter((m) => m.to === to && subject.test(m.subject)).at(-1);
|
||||
for (let i = 0; i < 50 && !find(); i++) await new Promise((r) => setTimeout(r, 10));
|
||||
return find();
|
||||
}
|
||||
const tokenFrom = (text: string) => new URL(text.match(/http\S+verify-email\S+/)![0]).searchParams.get('token')!;
|
||||
const verify = (token: string) => app().inject({ method: 'POST', url: '/auth/verify-email', payload: { token } });
|
||||
const me = async (token: string) => (await app().inject({ url: '/me', headers: bearer(token) })).json();
|
||||
|
||||
describe('email verification', () => {
|
||||
it('sends a confirmation link at sign-up that confirms the address', async () => {
|
||||
const { token, user } = await createUser(app(), 'alice');
|
||||
expect(user.emailVerified).toBe(false);
|
||||
const mail = await mailTo('alice@example.com', /Confirm your musicshare email/);
|
||||
expect(mail!.text).toContain('http://web.test/verify-email?token=');
|
||||
|
||||
const res = await verify(tokenFrom(mail!.text));
|
||||
expect(res.json()).toEqual({ ok: true, email: 'alice@example.com' });
|
||||
expect((await me(token)).emailVerified).toBe(true);
|
||||
expect((await verify(tokenFrom(mail!.text))).statusCode).toBe(400); // one use
|
||||
});
|
||||
|
||||
it('resends, and refuses once confirmed', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
const resend = () => app().inject({ method: 'POST', url: '/me/verify-email/resend', headers: bearer(token), payload: {} });
|
||||
expect((await resend()).statusCode).toBe(200);
|
||||
await verify(tokenFrom((await mailTo('alice@example.com', /Confirm/))!.text));
|
||||
expect((await resend()).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing email', () => {
|
||||
it('only switches once the new address is confirmed, and warns the old one', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
await verify(tokenFrom((await mailTo('alice@example.com', /Confirm/))!.text));
|
||||
|
||||
const res = await app().inject({
|
||||
method: 'POST',
|
||||
url: '/me/email',
|
||||
headers: bearer(token),
|
||||
payload: { email: 'New@Example.com', password: 'password123' },
|
||||
});
|
||||
expect(res.json()).toMatchObject({ email: 'alice@example.com', pendingEmail: 'new@example.com' });
|
||||
expect(await mailTo('alice@example.com', /being changed/)).toBeTruthy();
|
||||
|
||||
await verify(tokenFrom((await mailTo('new@example.com', /Confirm your new/))!.text));
|
||||
expect(await me(token)).toMatchObject({ email: 'new@example.com', emailVerified: true, pendingEmail: null });
|
||||
const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'new@example.com', password: 'password123' } });
|
||||
expect(login.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it('needs the password, and a free address', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
await createUser(app(), 'bob');
|
||||
const change = (email: string, password: string) =>
|
||||
app().inject({ method: 'POST', url: '/me/email', headers: bearer(token), payload: { email, password } });
|
||||
expect((await change('x@example.com', 'wrong-password')).statusCode).toBe(403);
|
||||
expect((await change('bob@example.com', 'password123')).statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it('can be cancelled', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
await app().inject({ method: 'POST', url: '/me/email', headers: bearer(token), payload: { email: 'x@example.com', password: 'password123' } });
|
||||
const link = tokenFrom((await mailTo('x@example.com', /Confirm/))!.text);
|
||||
const res = await app().inject({ method: 'DELETE', url: '/me/email/pending', headers: bearer(token) });
|
||||
expect(res.json().pendingEmail).toBeNull();
|
||||
expect((await verify(link)).statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('changing password', () => {
|
||||
it('needs the current password and signs out other sessions only', async () => {
|
||||
const { token } = await createUser(app(), 'alice');
|
||||
const other = (
|
||||
await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'password123' } })
|
||||
).json().token;
|
||||
const change = (currentPassword: string) =>
|
||||
app().inject({ method: 'POST', url: '/me/password', headers: bearer(token), payload: { currentPassword, newPassword: 'better-pass-9' } });
|
||||
|
||||
expect((await change('nope')).statusCode).toBe(403);
|
||||
expect((await change('password123')).statusCode).toBe(200);
|
||||
expect((await app().inject({ url: '/me', headers: bearer(token) })).statusCode).toBe(200);
|
||||
expect((await app().inject({ url: '/me', headers: bearer(other) })).statusCode).toBe(401);
|
||||
const login = await app().inject({ method: 'POST', url: '/auth/login', payload: { email: 'alice@example.com', password: 'better-pass-9' } });
|
||||
expect(login.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ function assertTestTargets() {
|
||||
|
||||
export async function resetState() {
|
||||
assertTestTargets();
|
||||
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars, password_resets restart identity cascade`;
|
||||
await sql`truncate users, tracks, track_links, sessions, follows, connections, now_playing, featured_songs, listens, devices, blocks, reports, avatars, password_resets, email_verifications restart identity cascade`;
|
||||
await redis.flushdb();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,11 @@ beforeEach(() => {
|
||||
outbox.length = 0;
|
||||
});
|
||||
|
||||
/** The email is sent in the background; give it a moment. */
|
||||
/** The reset email is sent in the background; give it a moment. (Sign-up sends its own confirmation email.) */
|
||||
async function nextMail() {
|
||||
for (let i = 0; i < 50 && outbox.length === 0; i++) await new Promise((r) => setTimeout(r, 10));
|
||||
return outbox.at(-1);
|
||||
const reset = () => outbox.filter((m) => m.subject.startsWith('Reset')).at(-1);
|
||||
for (let i = 0; i < 50 && !reset(); i++) await new Promise((r) => setTimeout(r, 10));
|
||||
return reset();
|
||||
}
|
||||
|
||||
const tokenFrom = (text: string) => new URL(text.match(/http\S+/)![0]).searchParams.get('token')!;
|
||||
@@ -50,7 +51,7 @@ describe('password reset', () => {
|
||||
const res = await requestReset('nobody@example.com');
|
||||
expect(res.json()).toEqual({ ok: true });
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
expect(outbox).toHaveLength(0);
|
||||
expect(outbox.filter((m) => m.subject.startsWith('Reset'))).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('links work once, and a new request replaces the old link', async () => {
|
||||
|
||||
Reference in New Issue
Block a user