Addresses the findings from the platform security audit. Verified green: all-workspace typecheck, web build, 16 API unit tests, 23 e2e auth tests, and 0 high/critical production dependency vulnerabilities. Dependencies (High): - Bump drizzle-orm 0.36→0.45.2 (GHSA-gpj5-g38j-94v9 SQLi-via-identifier) and drizzle-kit→0.31.10; npm audit fix cleared fast-uri path-traversal and the react-router open-redirect. Remaining audit items are dev-only build tooling (esbuild/vite), not shipped at runtime. AI cost control + storage quota (new ai_usage table, migration 0002): - Per-firm monthly AI token budget enforced before each completion (429), with every completion recorded to an ai_usage ledger (lib/ai-usage.ts). - Enforce per-plan storage quota on upload (402) and maintain storage_bytes_used on upload/delete (lib/storage-quota.ts); widen the column int→bigint so 8GB/50GB plans don't overflow. Auth (defense-in-depth): - Constant-time login: verify against a dummy argon2 hash when the account doesn't exist, closing the timing/enumeration oracle (verifyPasswordSafe). - Enforce suspension on requireSuperadmin, /auth/me, /auth/resend-verification. Web: - Validate the post-login ?next= redirect to same-origin paths only (open-redirect / phishing). Deploy hardening: - docker-compose: memory/CPU limits so a spike can't OOM the Dokploy host. - .dockerignore: keep destructive one-off scripts (seed-demo, create-admin, migrate-storage) out of the runtime image; retain the cron scripts. - seed-demo.ts: hard-refuse NODE_ENV=production and the prod DB host. Webhooks / config: - Stripe idempotency via a stripe_events ledger (skip already-processed events; record only after successful processing so a transient failure still retries); make the plan-upgraded email non-blocking. - Rate-limit account export and invoice PDF; cap invoice item arrays at 200. - Require TURNSTILE_SECRET_KEY in production (bot protection no longer fails open on a forgotten key); don't load .env under NODE_ENV=test so the suite is hermetic. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
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,
|
|
emailVerifications,
|
|
sessions as sessionsTable,
|
|
} from '@lawdesk/db';
|
|
import { hashPassword, verifyPasswordSafe } from '../auth/password';
|
|
import { SESSION_COOKIE, createSession, destroySession } from '../auth/sessions';
|
|
import { ensureSuperadminFlag } from '../auth/superadmin';
|
|
import { generateCsrfToken } from '../auth/csrf';
|
|
import {
|
|
sendEmail,
|
|
passwordResetEmail,
|
|
passwordChangedEmail,
|
|
welcomeEmail,
|
|
verifyEmailEmail,
|
|
} from '../lib/email';
|
|
import { env } from '../env';
|
|
import { verifyTurnstile } from '../lib/turnstile';
|
|
|
|
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(),
|
|
turnstileToken: z.string().max(3000).optional(),
|
|
});
|
|
|
|
const loginBody = z.object({
|
|
email: z.string().email().max(254).toLowerCase().trim(),
|
|
password: z.string().min(1).max(200),
|
|
turnstileToken: z.string().max(3000).optional(),
|
|
});
|
|
|
|
const MAX_FAILS_PER_15_MIN = 5;
|
|
|
|
// Mints an email-verification token (stored hashed, like password resets) and returns the
|
|
// clickable URL. The link hits the API directly — the vite dev proxy and the prod same-origin
|
|
// setup both route /api/* to this server.
|
|
async function createVerifyUrl(userId: string): Promise<string> {
|
|
const rawToken = crypto.randomBytes(32).toString('base64url');
|
|
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
|
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // 24 hours
|
|
await getDb().insert(emailVerifications).values({ tokenHash, userId, expiresAt });
|
|
return `${env.PUBLIC_URL}/api/auth/verify-email?token=${rawToken}`;
|
|
}
|
|
|
|
async function recentFailedAttempts(email: string, ip: string | null): Promise<number> {
|
|
const since = new Date(Date.now() - 15 * 60 * 1000);
|
|
const db = getDb();
|
|
const conditions = [
|
|
eq(loginAttempts.email, email),
|
|
eq(loginAttempts.success, false),
|
|
gte(loginAttempts.attemptedAt, since),
|
|
];
|
|
// Key the lockout on (email, ip). A single IP that keeps failing against an account gets
|
|
// throttled, but an attacker firing bad passwords from another IP can no longer lock the
|
|
// legitimate owner out of their own account (previously this counted by email alone).
|
|
if (ip) conditions.push(eq(loginAttempts.ip, ip));
|
|
const rows = await db
|
|
.select({ count: sql<number>`count(*)::int` })
|
|
.from(loginAttempts)
|
|
.where(and(...conditions));
|
|
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);
|
|
|
|
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
|
|
return reply.code(400).send({ error: 'captcha_failed' });
|
|
}
|
|
|
|
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,
|
|
user.emailVerifiedAt,
|
|
);
|
|
|
|
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 with a verification link (no blocking)
|
|
createVerifyUrl(user.id)
|
|
.catch((err) => {
|
|
app.log.warn({ err }, 'verify token create failed — sending welcome without link');
|
|
return null;
|
|
})
|
|
.then((verifyUrl) => {
|
|
const welcome = welcomeEmail(user.fullName, verifyUrl);
|
|
return 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,
|
|
emailVerified: Boolean(user.emailVerifiedAt),
|
|
},
|
|
});
|
|
});
|
|
|
|
app.post(
|
|
'/api/auth/login',
|
|
{ config: { rateLimit: { max: 10, timeWindow: '15 minutes' } } },
|
|
async (req, reply) => {
|
|
const body = loginBody.parse(req.body);
|
|
|
|
if (!(await verifyTurnstile(body.turnstileToken, req.ip))) {
|
|
return reply.code(400).send({ error: 'captcha_failed' });
|
|
}
|
|
|
|
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);
|
|
|
|
// Always run an argon2 verify — against the real hash if the account exists, else against a
|
|
// dummy hash — so both paths take equal time and can't be used to enumerate valid emails.
|
|
const ok = await verifyPasswordSafe(user?.passwordHash, body.password);
|
|
|
|
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,
|
|
user.emailVerifiedAt,
|
|
);
|
|
|
|
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,
|
|
emailVerified: Boolean(user.emailVerifiedAt),
|
|
},
|
|
};
|
|
});
|
|
|
|
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' });
|
|
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
|
|
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, reply) => {
|
|
const parsed = z
|
|
.object({
|
|
email: z.string().email().max(254).toLowerCase().trim(),
|
|
turnstileToken: z.string().max(3000).optional(),
|
|
})
|
|
.safeParse(req.body);
|
|
if (!parsed.success) return { ok: true };
|
|
|
|
// Bot check is orthogonal to email enumeration — a captcha failure is reported
|
|
// honestly; only account existence is concealed by the ok-always contract.
|
|
if (!(await verifyTurnstile(parsed.data.turnstileToken, req.ip))) {
|
|
return reply.code(400).send({ error: 'captcha_failed' });
|
|
}
|
|
|
|
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));
|
|
});
|
|
|
|
// Security notice — lets the real owner react fast if the reset wasn't theirs.
|
|
const tpl = passwordChangedEmail(user.fullName);
|
|
sendEmail({ to: user.email, ...tpl }).catch((err) =>
|
|
app.log.warn({ err }, 'password changed email failed'),
|
|
);
|
|
|
|
return { ok: true };
|
|
},
|
|
);
|
|
|
|
// ─────────────────────────── Email verification ───────────────────────────
|
|
|
|
// Landing endpoint for the link in welcome/verification emails. Redirects to the web app
|
|
// either way; ?verified=1|0 lets the UI show a toast.
|
|
app.get('/api/auth/verify-email', async (req, reply) => {
|
|
const parsed = z.object({ token: z.string().min(20).max(200) }).safeParse(req.query);
|
|
if (!parsed.success) return reply.redirect(`${env.PUBLIC_URL}/login?verified=0`);
|
|
|
|
const tokenHash = crypto.createHash('sha256').update(parsed.data.token).digest('hex');
|
|
const db = getDb();
|
|
|
|
const [row] = await db
|
|
.select()
|
|
.from(emailVerifications)
|
|
.where(and(eq(emailVerifications.tokenHash, tokenHash), isNull(emailVerifications.consumedAt)))
|
|
.limit(1);
|
|
|
|
if (!row || row.expiresAt.getTime() < Date.now()) {
|
|
return reply.redirect(`${env.PUBLIC_URL}/login?verified=0`);
|
|
}
|
|
|
|
await db.transaction(async (tx) => {
|
|
await tx
|
|
.update(users)
|
|
.set({ emailVerifiedAt: new Date(), updatedAt: new Date() })
|
|
.where(and(eq(users.id, row.userId), isNull(users.emailVerifiedAt)));
|
|
await tx
|
|
.update(emailVerifications)
|
|
.set({ consumedAt: new Date() })
|
|
.where(eq(emailVerifications.tokenHash, tokenHash));
|
|
});
|
|
|
|
return reply.redirect(`${env.PUBLIC_URL}/login?verified=1`);
|
|
});
|
|
|
|
// Re-send the verification email for the logged-in user.
|
|
app.post(
|
|
'/api/auth/resend-verification',
|
|
{ config: { rateLimit: { max: 3, timeWindow: '15 minutes' } } },
|
|
async (req, reply) => {
|
|
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
|
|
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
|
|
|
|
const db = getDb();
|
|
const [user] = await db.select().from(users).where(eq(users.id, req.user.id)).limit(1);
|
|
if (!user) return reply.code(404).send({ error: 'user_not_found' });
|
|
if (user.emailVerifiedAt) return { ok: true, alreadyVerified: true };
|
|
|
|
const verifyUrl = await createVerifyUrl(user.id);
|
|
const tpl = verifyEmailEmail(user.fullName, verifyUrl);
|
|
sendEmail({ to: user.email, ...tpl }).catch((err) =>
|
|
app.log.warn({ err }, 'verification email failed'),
|
|
);
|
|
|
|
return { ok: true };
|
|
},
|
|
);
|
|
}
|