diff --git a/packages/backend/src/routes/auth.ts b/packages/backend/src/routes/auth.ts new file mode 100644 index 0000000..d7322e4 --- /dev/null +++ b/packages/backend/src/routes/auth.ts @@ -0,0 +1,229 @@ +import { Router } from 'express'; +import { eq, sql } from 'drizzle-orm'; +import { + registerSchema, loginSchema, verifyEmailSchema, resendVerificationSchema, + forgotPasswordSchema, resetPasswordSchema, profileUpdateSchema, changePasswordSchema, + acceptInviteSchema, type PublicUser, +} from '@flashcard/shared'; +import type { Db } from '../db/client.js'; +import { users } from '../db/schema.js'; +import { hashPassword, verifyPassword } from '../services/auth/passwords.js'; +import { createAuthToken, consumeAuthToken, invalidateTokensForUser } from '../services/auth/tokens.js'; +import { createAuthSession, invalidateAuthSession, invalidateAllForUser } from '../services/auth/sessions.js'; +import { getMailer } from '../services/auth/email.js'; +import { + verifyEmailTemplate, passwordResetTemplate, changeEmailTemplate, +} from '../services/auth/templates.js'; +import { SID_COOKIE, CSRF_COOKIE, sidCookieOptions, csrfCookieOptions, clearCookieOptions } from '../lib/cookies.js'; +import { ApiError } from '../lib/errors.js'; +import { requireAuth } from '../middleware/auth.js'; +import { verifyCsrf } from '../middleware/csrf.js'; +import { loginLimiter, registerLimiter, forgotPasswordLimiter, tokenLimiter } from '../middleware/rate-limit.js'; +import { randomBytes } from 'node:crypto'; + +const VERIFY_TTL = 24 * 60 * 60; +const RESET_TTL = 60 * 60; +const CHANGE_EMAIL_TTL = 24 * 60 * 60; + +function nowSec() { return Math.floor(Date.now() / 1000); } +function appUrl() { return process.env.APP_URL ?? 'http://localhost:5173'; } + +function toPublicUser(r: typeof users.$inferSelect): PublicUser { + return { id: r.id, email: r.email, displayName: r.displayName, role: r.role }; +} + +export function authRouter(db: Db): Router { + const r = Router(); + + r.get('/me', (req, res) => { + if (!req.user) { + res.status(401).json({ error: { code: 'UNAUTHENTICATED', message: 'Not signed in' } }); + return; + } + res.json(req.user); + }); + + r.post('/register', registerLimiter, async (req, res, next) => { + try { + const input = registerSchema.parse(req.body); + const existing = db.select().from(users).where(eq(users.email, input.email)).get(); + if (existing) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use'); + + const totalUsers = db.select({ c: sql`count(*)`.as('c') }).from(users).get()?.c ?? 0; + const role: 'user' | 'sysadmin' = Number(totalUsers) === 0 ? 'sysadmin' : 'user'; + const passwordHash = await hashPassword(input.password); + const [user] = db.insert(users).values({ + email: input.email, + displayName: input.displayName, + passwordHash, + role, + }).returning().all(); + + const { plaintext } = await createAuthToken(db, user!.id, 'verify_email', VERIFY_TTL); + const tpl = verifyEmailTemplate(appUrl(), plaintext, user!.displayName); + await getMailer().send(user!.email, tpl); + + res.status(201).json({ id: user!.id, email: user!.email, displayName: user!.displayName, role: user!.role }); + } catch (e) { next(e); } + }); + + r.post('/verify-email', tokenLimiter, async (req, res, next) => { + try { + const { token } = verifyEmailSchema.parse(req.body); + let consumed = await consumeAuthToken(db, token, 'verify_email'); + let purpose: 'verify_email' | 'change_email' = 'verify_email'; + if (!consumed) { + consumed = await consumeAuthToken(db, token, 'change_email'); + purpose = 'change_email'; + } + if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token'); + + if (purpose === 'verify_email') { + db.update(users).set({ emailVerifiedAt: nowSec(), updatedAt: nowSec() }) + .where(eq(users.id, consumed.userId)).run(); + } else { + const newEmail = consumed.payload; + if (!newEmail) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid token payload'); + const taken = db.select().from(users).where(eq(users.email, newEmail)).get(); + if (taken && taken.id !== consumed.userId) { + throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use'); + } + db.update(users).set({ email: newEmail, pendingEmail: null, updatedAt: nowSec() }) + .where(eq(users.id, consumed.userId)).run(); + } + res.status(200).json({ ok: true }); + } catch (e) { next(e); } + }); + + r.post('/resend-verification', tokenLimiter, async (req, res, next) => { + try { + const { email } = resendVerificationSchema.parse(req.body); + const u = db.select().from(users).where(eq(users.email, email)).get(); + if (u && !u.emailVerifiedAt && u.isActive) { + await invalidateTokensForUser(db, u.id, 'verify_email'); + const { plaintext } = await createAuthToken(db, u.id, 'verify_email', VERIFY_TTL); + const tpl = verifyEmailTemplate(appUrl(), plaintext, u.displayName); + await getMailer().send(u.email, tpl); + } + res.status(200).json({ ok: true }); + } catch (e) { next(e); } + }); + + r.post('/login', loginLimiter, async (req, res, next) => { + try { + const input = loginSchema.parse(req.body); + const u = db.select().from(users).where(eq(users.email, input.email)).get(); + if (!u || !u.passwordHash || !(await verifyPassword(input.password, u.passwordHash))) { + throw new ApiError(401, 'INVALID_CREDENTIALS', 'Invalid email or password'); + } + if (!u.isActive) throw new ApiError(403, 'ACCOUNT_DISABLED', 'Account is disabled'); + if (!u.emailVerifiedAt) throw new ApiError(403, 'EMAIL_NOT_VERIFIED', 'Email not verified'); + + const s = await createAuthSession(db, u.id, { + userAgent: req.headers['user-agent'] ?? null, + ip: req.ip ?? null, + }); + const csrf = randomBytes(24).toString('base64url'); + res.cookie(SID_COOKIE, s.id, sidCookieOptions(s.expiresAt)); + res.cookie(CSRF_COOKIE, csrf, csrfCookieOptions(s.expiresAt)); + res.json(toPublicUser(u)); + } catch (e) { next(e); } + }); + + r.post('/logout', requireAuth, verifyCsrf, async (req, res, next) => { + try { + if (req.sessionId) await invalidateAuthSession(db, req.sessionId); + res.clearCookie(SID_COOKIE, clearCookieOptions()); + res.clearCookie(CSRF_COOKIE, { ...clearCookieOptions(), httpOnly: false }); + res.status(204).end(); + } catch (e) { next(e); } + }); + + r.post('/forgot-password', forgotPasswordLimiter, async (req, res, next) => { + try { + const { email } = forgotPasswordSchema.parse(req.body); + const u = db.select().from(users).where(eq(users.email, email)).get(); + if (u && u.isActive && u.emailVerifiedAt) { + await invalidateTokensForUser(db, u.id, 'password_reset'); + const { plaintext } = await createAuthToken(db, u.id, 'password_reset', RESET_TTL); + const tpl = passwordResetTemplate(appUrl(), plaintext, u.displayName); + await getMailer().send(u.email, tpl); + } + res.status(200).json({ ok: true }); + } catch (e) { next(e); } + }); + + r.post('/reset-password', tokenLimiter, async (req, res, next) => { + try { + const { token, password } = resetPasswordSchema.parse(req.body); + const consumed = await consumeAuthToken(db, token, 'password_reset'); + if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token'); + const passwordHash = await hashPassword(password); + db.update(users).set({ passwordHash, updatedAt: nowSec() }) + .where(eq(users.id, consumed.userId)).run(); + await invalidateAllForUser(db, consumed.userId); + res.status(200).json({ ok: true }); + } catch (e) { next(e); } + }); + + r.post('/accept-invite', tokenLimiter, async (req, res, next) => { + try { + const input = acceptInviteSchema.parse(req.body); + const consumed = await consumeAuthToken(db, input.token, 'invite'); + if (!consumed) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid or expired token'); + const passwordHash = await hashPassword(input.password); + const [updated] = db.update(users).set({ + passwordHash, displayName: input.displayName, + emailVerifiedAt: nowSec(), updatedAt: nowSec(), + }).where(eq(users.id, consumed.userId)).returning().all(); + if (!updated) throw new ApiError(400, 'INVALID_TOKEN', 'Invalid invitation'); + const s = await createAuthSession(db, updated.id, { + userAgent: req.headers['user-agent'] ?? null, ip: req.ip ?? null, + }); + const csrf = randomBytes(24).toString('base64url'); + res.cookie(SID_COOKIE, s.id, sidCookieOptions(s.expiresAt)); + res.cookie(CSRF_COOKIE, csrf, csrfCookieOptions(s.expiresAt)); + res.status(200).json(toPublicUser(updated)); + } catch (e) { next(e); } + }); + + r.patch('/profile', requireAuth, verifyCsrf, async (req, res, next) => { + try { + const input = profileUpdateSchema.parse(req.body); + const user = req.user!; + const updates: Record = { updatedAt: nowSec() }; + if (input.displayName !== undefined) updates.displayName = input.displayName; + let sendEmailChange: string | null = null; + if (input.email !== undefined && input.email !== user.email) { + const taken = db.select().from(users).where(eq(users.email, input.email)).get(); + if (taken) throw new ApiError(409, 'EMAIL_TAKEN', 'Email already in use'); + updates.pendingEmail = input.email; + sendEmailChange = input.email; + } + const [updated] = db.update(users).set(updates).where(eq(users.id, user.id)).returning().all(); + if (sendEmailChange && updated) { + const { plaintext } = await createAuthToken(db, updated.id, 'change_email', CHANGE_EMAIL_TTL, sendEmailChange); + const tpl = changeEmailTemplate(appUrl(), plaintext, updated.displayName, sendEmailChange); + await getMailer().send(sendEmailChange, tpl); + } + res.json(toPublicUser(updated!)); + } catch (e) { next(e); } + }); + + r.post('/change-password', requireAuth, verifyCsrf, async (req, res, next) => { + try { + const input = changePasswordSchema.parse(req.body); + const user = req.user!; + const row = db.select().from(users).where(eq(users.id, user.id)).get(); + if (!row?.passwordHash || !(await verifyPassword(input.currentPassword, row.passwordHash))) { + throw new ApiError(401, 'INVALID_CREDENTIALS', 'Current password is incorrect'); + } + const passwordHash = await hashPassword(input.newPassword); + db.update(users).set({ passwordHash, updatedAt: nowSec() }).where(eq(users.id, user.id)).run(); + if (req.sessionId) await invalidateAllForUser(db, user.id, { except: req.sessionId }); + res.status(204).end(); + } catch (e) { next(e); } + }); + + return r; +} diff --git a/packages/backend/src/tests/auth.integration.test.ts b/packages/backend/src/tests/auth.integration.test.ts new file mode 100644 index 0000000..b4f4ed4 --- /dev/null +++ b/packages/backend/src/tests/auth.integration.test.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import request from 'supertest'; +import { createApp } from '../app.js'; +import { makeTestDb } from './dbHelper.js'; +import { setMailerForTests, type Mailer } from '../services/auth/email.js'; + +class CaptureMailer implements Mailer { + sent: { to: string; subject: string; text: string; html: string }[] = []; + async send(to: string, m: { subject: string; html: string; text: string }) { + this.sent.push({ to, ...m }); + } +} + +let env: ReturnType; +let mailer: CaptureMailer; +let app: ReturnType; + +beforeEach(() => { + env = makeTestDb(); + mailer = new CaptureMailer(); + setMailerForTests(mailer); + app = createApp(env.db); +}); + +function tokenFromMail(text: string): string { + const m = text.match(/token=([^\s&"]+)/); + if (!m) throw new Error('no token in mail'); + return decodeURIComponent(m[1]!); +} + +function extractCookieValue(cookies: string[], name: string): string { + for (const c of cookies) { + if (c.startsWith(`${name}=`)) { + const v = c.split(';')[0]!.slice(name.length + 1); + return decodeURIComponent(v); + } + } + throw new Error(`cookie ${name} not found`); +} + +describe('auth integration', () => { + it('register → verify → login → me → logout', async () => { + const reg = await request(app).post('/api/auth/register').send({ + email: 'alice@example.com', displayName: 'Alice', password: 'secretpass', + }); + expect(reg.status).toBe(201); + expect(mailer.sent).toHaveLength(1); + const verifyToken = tokenFromMail(mailer.sent[0]!.text); + + const bad = await request(app).post('/api/auth/login').send({ email: 'alice@example.com', password: 'secretpass' }); + expect(bad.status).toBe(403); + expect(bad.body.error.code).toBe('EMAIL_NOT_VERIFIED'); + + const ver = await request(app).post('/api/auth/verify-email').send({ token: verifyToken }); + expect(ver.status).toBe(200); + + const ok = await request(app).post('/api/auth/login').send({ email: 'alice@example.com', password: 'secretpass' }); + expect(ok.status).toBe(200); + const cookies = ok.headers['set-cookie'] as unknown as string[]; + expect(cookies.find((c) => c.startsWith('flashcard_sid='))).toBeDefined(); + expect(cookies.find((c) => c.startsWith('flashcard_csrf='))).toBeDefined(); + + const me = await request(app).get('/api/auth/me').set('Cookie', cookies); + expect(me.status).toBe(200); + expect(me.body.email).toBe('alice@example.com'); + + const csrf = extractCookieValue(cookies, 'flashcard_csrf'); + const logout = await request(app).post('/api/auth/logout').set('Cookie', cookies).set('x-csrf-token', csrf); + expect(logout.status).toBe(204); + + const me2 = await request(app).get('/api/auth/me').set('Cookie', cookies); + expect(me2.status).toBe(401); + }); + + it('marks first registered user as sysadmin', async () => { + const r = await request(app).post('/api/auth/register').send({ + email: 'first@example.com', displayName: 'First', password: 'secretpass', + }); + expect(r.status).toBe(201); + const token = tokenFromMail(mailer.sent[0]!.text); + await request(app).post('/api/auth/verify-email').send({ token }); + + const login = await request(app).post('/api/auth/login').send({ email: 'first@example.com', password: 'secretpass' }); + const cookies = login.headers['set-cookie'] as unknown as string[]; + const me = await request(app).get('/api/auth/me').set('Cookie', cookies); + expect(me.body.role).toBe('sysadmin'); + }); + + it('rejects duplicate email', async () => { + await request(app).post('/api/auth/register').send({ email: 'a@example.com', displayName: 'A', password: 'secretpass' }); + const dup = await request(app).post('/api/auth/register').send({ email: 'A@example.com', displayName: 'A2', password: 'secretpass' }); + expect(dup.status).toBe(409); + expect(dup.body.error.code).toBe('EMAIL_TAKEN'); + }); +});