feat(auth): /api/auth routes + integration tests (pending wiring)
This commit is contained in:
229
packages/backend/src/routes/auth.ts
Normal file
229
packages/backend/src/routes/auth.ts
Normal file
@@ -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<number>`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<string, unknown> = { 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;
|
||||
}
|
||||
95
packages/backend/src/tests/auth.integration.test.ts
Normal file
95
packages/backend/src/tests/auth.integration.test.ts
Normal file
@@ -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<typeof makeTestDb>;
|
||||
let mailer: CaptureMailer;
|
||||
let app: ReturnType<typeof createApp>;
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user