Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,244 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { and, eq, gte, isNull, sql } from 'drizzle-orm';
|
||||
import { getDb, users, firms, loginAttempts, passwordResets, sessions as sessionsTable } from '@lawdesk/db';
|
||||
import { hashPassword, verifyPassword } from '../auth/password';
|
||||
import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions';
|
||||
import { ensureSuperadminFlag } from '../auth/superadmin';
|
||||
import { generateCsrfToken } from '../auth/csrf';
|
||||
import { sendEmail, passwordResetEmail, welcomeEmail } from '../lib/email';
|
||||
import { env } from '../env';
|
||||
|
||||
const signupBody = z.object({
|
||||
email: z.string().email().max(254).toLowerCase().trim(),
|
||||
password: z.string().min(10).max(200),
|
||||
fullName: z.string().min(1).max(120).trim(),
|
||||
firmName: z.string().min(1).max(160).trim(),
|
||||
});
|
||||
|
||||
const loginBody = z.object({
|
||||
email: z.string().email().max(254).toLowerCase().trim(),
|
||||
password: z.string().min(1).max(200),
|
||||
});
|
||||
|
||||
const MAX_FAILS_PER_15_MIN = 5;
|
||||
|
||||
async function recentFailedAttempts(email: string, ip: string | null): Promise<number> {
|
||||
const since = new Date(Date.now() - 15 * 60 * 1000);
|
||||
const db = getDb();
|
||||
const rows = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(loginAttempts)
|
||||
.where(
|
||||
and(
|
||||
eq(loginAttempts.email, email),
|
||||
eq(loginAttempts.success, false),
|
||||
gte(loginAttempts.attemptedAt, since),
|
||||
),
|
||||
);
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post(
|
||||
'/api/auth/signup',
|
||||
{ config: { rateLimit: { max: 5, timeWindow: '1 hour' } } },
|
||||
async (req, reply) => {
|
||||
const body = signupBody.parse(req.body);
|
||||
const db = getDb();
|
||||
|
||||
const existing = await db.select({ id: users.id }).from(users).where(eq(users.email, body.email)).limit(1);
|
||||
if (existing.length > 0) {
|
||||
return reply.code(409).send({ error: 'email_taken' });
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
|
||||
const [firm] = await db.insert(firms).values({ name: body.firmName }).returning();
|
||||
if (!firm) return reply.code(500).send({ error: 'firm_create_failed' });
|
||||
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: body.email,
|
||||
passwordHash,
|
||||
fullName: body.fullName,
|
||||
firmId: firm.id,
|
||||
role: 'owner',
|
||||
})
|
||||
.returning();
|
||||
if (!user) return reply.code(500).send({ error: 'user_create_failed' });
|
||||
|
||||
const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin);
|
||||
|
||||
const { token, expiresAt } = await createSession({
|
||||
userId: user.id,
|
||||
ip: req.ip,
|
||||
userAgent: req.headers['user-agent'] ?? null,
|
||||
});
|
||||
app.setSessionCookie(reply, token, expiresAt);
|
||||
app.setCsrfCookie(reply, generateCsrfToken());
|
||||
|
||||
// Fire-and-forget welcome email (no blocking)
|
||||
const welcome = welcomeEmail(user.fullName, null);
|
||||
sendEmail({ to: user.email, ...welcome }).catch((err) => app.log.warn({ err }, 'welcome email failed'));
|
||||
|
||||
return reply.code(201).send({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
firmId: firm.id,
|
||||
role: user.role,
|
||||
isSuperadmin,
|
||||
isSuspended: user.isSuspended,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
app.post(
|
||||
'/api/auth/login',
|
||||
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
|
||||
async (req, reply) => {
|
||||
const body = loginBody.parse(req.body);
|
||||
const db = getDb();
|
||||
const ip = req.ip ?? null;
|
||||
|
||||
const fails = await recentFailedAttempts(body.email, ip);
|
||||
if (fails >= MAX_FAILS_PER_15_MIN) {
|
||||
return reply.code(429).send({ error: 'too_many_attempts' });
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.email, body.email)).limit(1);
|
||||
|
||||
const ok = user ? await verifyPassword(user.passwordHash, body.password) : false;
|
||||
|
||||
await db.insert(loginAttempts).values({ email: body.email, ip, success: ok });
|
||||
|
||||
if (!ok || !user) {
|
||||
return reply.code(401).send({ error: 'invalid_credentials' });
|
||||
}
|
||||
|
||||
if (user.isSuspended) {
|
||||
return reply.code(403).send({ error: 'account_suspended' });
|
||||
}
|
||||
|
||||
const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin);
|
||||
|
||||
await db.update(users).set({ lastSeenAt: new Date() }).where(eq(users.id, user.id));
|
||||
|
||||
const { token, expiresAt } = await createSession({
|
||||
userId: user.id,
|
||||
ip,
|
||||
userAgent: req.headers['user-agent'] ?? null,
|
||||
});
|
||||
app.setSessionCookie(reply, token, expiresAt);
|
||||
app.setCsrfCookie(reply, generateCsrfToken());
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
fullName: user.fullName,
|
||||
firmId: user.firmId,
|
||||
role: user.role,
|
||||
isSuperadmin,
|
||||
isSuspended: user.isSuspended,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
app.post('/api/auth/logout', async (req, reply) => {
|
||||
const token = req.cookies?.[SESSION_COOKIE];
|
||||
if (token) await destroySession(token);
|
||||
app.clearSessionCookie(reply);
|
||||
app.clearCsrfCookie(reply);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', async (req, reply) => {
|
||||
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
|
||||
return { user: req.user };
|
||||
});
|
||||
|
||||
// ─────────────────────────── Password reset ───────────────────────────
|
||||
|
||||
// Request a reset link. Always returns ok=true so an attacker can't enumerate emails.
|
||||
app.post(
|
||||
'/api/auth/request-password-reset',
|
||||
{ config: { rateLimit: { max: 5, timeWindow: '15 minutes' } } },
|
||||
async (req) => {
|
||||
const parsed = z.object({ email: z.string().email().max(254).toLowerCase().trim() }).safeParse(req.body);
|
||||
if (!parsed.success) return { ok: true };
|
||||
|
||||
const db = getDb();
|
||||
const [user] = await db.select().from(users).where(eq(users.email, parsed.data.email)).limit(1);
|
||||
if (!user || user.isSuspended) return { ok: true };
|
||||
|
||||
const rawToken = crypto.randomBytes(32).toString('base64url');
|
||||
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
|
||||
|
||||
await db.insert(passwordResets).values({ tokenHash, userId: user.id, expiresAt });
|
||||
|
||||
const resetUrl = `${env.PUBLIC_URL}/reset-password?token=${rawToken}`;
|
||||
const tpl = passwordResetEmail(user.fullName, resetUrl);
|
||||
sendEmail({ to: user.email, ...tpl }).catch((err) =>
|
||||
app.log.warn({ err }, 'password reset email failed'),
|
||||
);
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
// Apply a new password using the token from the email.
|
||||
app.post(
|
||||
'/api/auth/reset-password',
|
||||
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
|
||||
async (req, reply) => {
|
||||
const parsed = z
|
||||
.object({
|
||||
token: z.string().min(20).max(200),
|
||||
password: z.string().min(10).max(200),
|
||||
})
|
||||
.safeParse(req.body);
|
||||
if (!parsed.success) return reply.code(400).send({ error: 'invalid_input' });
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(parsed.data.token).digest('hex');
|
||||
const db = getDb();
|
||||
|
||||
const [reset] = await db
|
||||
.select()
|
||||
.from(passwordResets)
|
||||
.where(and(eq(passwordResets.tokenHash, tokenHash), isNull(passwordResets.consumedAt)))
|
||||
.limit(1);
|
||||
|
||||
if (!reset) return reply.code(400).send({ error: 'invalid_or_used_token' });
|
||||
if (reset.expiresAt.getTime() < Date.now()) {
|
||||
return reply.code(400).send({ error: 'token_expired' });
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, reset.userId)).limit(1);
|
||||
if (!user) return reply.code(400).send({ error: 'user_not_found' });
|
||||
if (user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
|
||||
|
||||
const passwordHash = await hashPassword(parsed.data.password);
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(users)
|
||||
.set({ passwordHash, updatedAt: new Date() })
|
||||
.where(eq(users.id, user.id));
|
||||
await tx
|
||||
.update(passwordResets)
|
||||
.set({ consumedAt: new Date() })
|
||||
.where(eq(passwordResets.tokenHash, tokenHash));
|
||||
// Revoke all existing sessions for this user — they should re-login with the new password
|
||||
await tx.delete(sessionsTable).where(eq(sessionsTable.userId, user.id));
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user