230 lines
10 KiB
TypeScript
230 lines
10 KiB
TypeScript
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;
|
|
}
|