Storage→Spaces, security hardening, production-blocker fixes, tests + CI
Storage - Migrate document/media storage from local disk to DigitalOcean Spaces (S3); lib/storage.ts now streams via the S3 SDK; SPACES_* env vars required. - Add scripts/migrate-storage-to-spaces.ts (idempotent, one-time). Security hardening (all report findings) - DB pool fails closed in production when the CA cert is missing (no more silent unverified TLS); warns in dev. - trustProxy: 1 (was true) so X-Forwarded-For can't be spoofed to evade rate limits. - Login lockout keyed by (email, ip) so an attacker can't lock out a victim. - Superadmin auto-grant now requires a verified email. - CSRF tokens HMAC-signed; exact-path exemptions; logout no longer exempt. - Upload content-sniffing (magic bytes) rejects spoofed MIME types. - create-admin.ts reads creds from env/argv; seed-demo.ts guarded behind ALLOW_SEED. Production-blocker fixes - SPA deep-link/refresh no longer 500s (decorateReply fix); index.html served no-cache. - Invoice numbering is transaction-safe (per-firm advisory lock + max sequence), eliminating concurrent collisions and delete-reuse — no schema change. - Checkout guards against double-billing a firm already on a paid plan. - Fix render-loop in CreateInvoiceDrawer / ManualEntryDrawer (unstable effect deps). Honesty / trust - Remove fabricated testimonials, stats, strikethrough "was" prices, contact SLA, and the login-panel stats; replace with non-fabricated copy. - Fix cookie-policy consent-key mismatch. (Legal pages still need lawyer review.) Quality - Add Vitest unit tests (file-signature, password hashing) and GitHub Actions CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
310568690b
commit
97e1d4c60b
+123
-14
@@ -2,12 +2,26 @@ 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 {
|
||||
getDb,
|
||||
users,
|
||||
firms,
|
||||
loginAttempts,
|
||||
passwordResets,
|
||||
emailVerifications,
|
||||
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 {
|
||||
sendEmail,
|
||||
passwordResetEmail,
|
||||
passwordChangedEmail,
|
||||
welcomeEmail,
|
||||
verifyEmailEmail,
|
||||
} from '../lib/email';
|
||||
import { env } from '../env';
|
||||
|
||||
const signupBody = z.object({
|
||||
@@ -24,19 +38,33 @@ const loginBody = z.object({
|
||||
|
||||
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(
|
||||
eq(loginAttempts.email, email),
|
||||
eq(loginAttempts.success, false),
|
||||
gte(loginAttempts.attemptedAt, since),
|
||||
),
|
||||
);
|
||||
.where(and(...conditions));
|
||||
return rows[0]?.count ?? 0;
|
||||
}
|
||||
|
||||
@@ -70,7 +98,12 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
.returning();
|
||||
if (!user) return reply.code(500).send({ error: 'user_create_failed' });
|
||||
|
||||
const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin);
|
||||
const isSuperadmin = await ensureSuperadminFlag(
|
||||
user.id,
|
||||
user.email,
|
||||
user.isSuperadmin,
|
||||
user.emailVerifiedAt,
|
||||
);
|
||||
|
||||
const { token, expiresAt } = await createSession({
|
||||
userId: user.id,
|
||||
@@ -80,9 +113,17 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
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'));
|
||||
// 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: {
|
||||
@@ -124,7 +165,12 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
return reply.code(403).send({ error: 'account_suspended' });
|
||||
}
|
||||
|
||||
const isSuperadmin = await ensureSuperadminFlag(user.id, user.email, user.isSuperadmin);
|
||||
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));
|
||||
|
||||
@@ -238,6 +284,69 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
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' });
|
||||
|
||||
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 };
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user