Initial commit — eLegal Software monorepo
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import { isProd, env } from '../env';
|
||||
import { SESSION_COOKIE } from './sessions';
|
||||
|
||||
export const CSRF_COOKIE = 'csrf';
|
||||
export const CSRF_HEADER = 'x-csrf-token';
|
||||
const TOKEN_BYTES = 32;
|
||||
|
||||
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
|
||||
|
||||
// Routes that legitimately bypass CSRF — they receive their own auth (signature check)
|
||||
// or have no session yet, so a CSRF attack against them is meaningless.
|
||||
const CSRF_EXEMPT_PREFIXES = ['/api/auth/', '/api/contact', '/api/webhooks/', '/api/tool-usage'];
|
||||
|
||||
export function generateCsrfToken(): string {
|
||||
return crypto.randomBytes(TOKEN_BYTES).toString('base64url');
|
||||
}
|
||||
|
||||
function constantTimeEqual(a: string, b: string): boolean {
|
||||
const ab = Buffer.from(a);
|
||||
const bb = Buffer.from(b);
|
||||
if (ab.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
setCsrfCookie: (reply: FastifyReply, token: string) => void;
|
||||
clearCsrfCookie: (reply: FastifyReply) => void;
|
||||
}
|
||||
}
|
||||
|
||||
async function plugin(app: FastifyInstance) {
|
||||
app.decorate('setCsrfCookie', (reply: FastifyReply, token: string) => {
|
||||
reply.setCookie(CSRF_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: false, // intentional — JS reads this and echoes it as a header
|
||||
secure: isProd,
|
||||
sameSite: 'lax',
|
||||
domain: env.COOKIE_DOMAIN || undefined,
|
||||
});
|
||||
});
|
||||
|
||||
app.decorate('clearCsrfCookie', (reply: FastifyReply) => {
|
||||
reply.clearCookie(CSRF_COOKIE, {
|
||||
path: '/',
|
||||
secure: isProd,
|
||||
sameSite: 'lax',
|
||||
domain: env.COOKIE_DOMAIN || undefined,
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-mint a CSRF token whenever an authenticated session exists but no CSRF cookie is set.
|
||||
// This makes the protection self-bootstrapping after sessions created before CSRF was enabled.
|
||||
app.addHook('onRequest', async (req, reply) => {
|
||||
if (!req.cookies?.[SESSION_COOKIE]) return;
|
||||
if (req.cookies?.[CSRF_COOKIE]) return;
|
||||
const token = generateCsrfToken();
|
||||
app.setCsrfCookie(reply, token);
|
||||
req.cookies = { ...req.cookies, [CSRF_COOKIE]: token };
|
||||
});
|
||||
|
||||
// Verify CSRF on every state-changing request that has a session cookie.
|
||||
app.addHook('preHandler', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (SAFE_METHODS.has(req.method)) return;
|
||||
if (!req.cookies?.[SESSION_COOKIE]) return; // unauthenticated → nothing to protect
|
||||
const url = req.routeOptions.url || req.url;
|
||||
if (CSRF_EXEMPT_PREFIXES.some((p) => url.startsWith(p))) return;
|
||||
|
||||
const cookie = req.cookies?.[CSRF_COOKIE];
|
||||
const header = (req.headers[CSRF_HEADER] as string | undefined) ?? '';
|
||||
if (!cookie || !header || !constantTimeEqual(cookie, header)) {
|
||||
return reply.code(403).send({ error: 'csrf_failed' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const csrfPlugin = fp(plugin, { name: 'csrf', dependencies: ['auth'] });
|
||||
@@ -0,0 +1,16 @@
|
||||
import argon2 from 'argon2';
|
||||
|
||||
const ARGON2_OPTIONS: argon2.Options = {
|
||||
type: argon2.argon2id,
|
||||
memoryCost: 64 * 1024,
|
||||
timeCost: 3,
|
||||
parallelism: 1,
|
||||
};
|
||||
|
||||
export function hashPassword(password: string): Promise<string> {
|
||||
return argon2.hash(password, ARGON2_OPTIONS);
|
||||
}
|
||||
|
||||
export function verifyPassword(hash: string, password: string): Promise<boolean> {
|
||||
return argon2.verify(hash, password);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import fp from 'fastify-plugin';
|
||||
import { SESSION_COOKIE, loadSession } from './sessions';
|
||||
import { isProd, env } from '../env';
|
||||
import { ensureSuperadminFlag } from './superadmin';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
user?: {
|
||||
id: string;
|
||||
email: string;
|
||||
firmId: string | null;
|
||||
role: string;
|
||||
isSuperadmin: boolean;
|
||||
isSuspended: boolean;
|
||||
};
|
||||
}
|
||||
interface FastifyInstance {
|
||||
requireAuth: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
requireFirm: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
requireSuperadmin: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
setSessionCookie: (reply: FastifyReply, token: string, expiresAt: Date) => void;
|
||||
clearSessionCookie: (reply: FastifyReply) => void;
|
||||
}
|
||||
}
|
||||
|
||||
async function plugin(app: FastifyInstance) {
|
||||
app.addHook('onRequest', async (req) => {
|
||||
const token = req.cookies?.[SESSION_COOKIE];
|
||||
if (!token) return;
|
||||
|
||||
const session = await loadSession(token);
|
||||
if (!session) return;
|
||||
|
||||
// Auto-promote/demote based on SUPERADMIN_EMAILS env var, every request — cheap and self-healing.
|
||||
const isSuperadmin = await ensureSuperadminFlag(
|
||||
session.user.id,
|
||||
session.user.email,
|
||||
session.user.isSuperadmin,
|
||||
);
|
||||
|
||||
req.user = {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
firmId: session.user.firmId,
|
||||
role: session.user.role,
|
||||
isSuperadmin,
|
||||
isSuspended: session.user.isSuspended,
|
||||
};
|
||||
});
|
||||
|
||||
app.decorate('requireAuth', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
|
||||
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
|
||||
});
|
||||
|
||||
app.decorate('requireFirm', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
|
||||
if (req.user.isSuspended) return reply.code(403).send({ error: 'account_suspended' });
|
||||
if (!req.user.firmId) return reply.code(403).send({ error: 'no_firm' });
|
||||
});
|
||||
|
||||
app.decorate('requireSuperadmin', async (req: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!req.user) return reply.code(401).send({ error: 'unauthorized' });
|
||||
if (!req.user.isSuperadmin) return reply.code(403).send({ error: 'forbidden' });
|
||||
});
|
||||
|
||||
app.decorate('setSessionCookie', (reply: FastifyReply, token: string, expiresAt: Date) => {
|
||||
reply.setCookie(SESSION_COOKIE, token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProd,
|
||||
sameSite: 'lax',
|
||||
domain: env.COOKIE_DOMAIN || undefined,
|
||||
expires: expiresAt,
|
||||
signed: false,
|
||||
});
|
||||
});
|
||||
|
||||
app.decorate('clearSessionCookie', (reply: FastifyReply) => {
|
||||
reply.clearCookie(SESSION_COOKIE, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProd,
|
||||
sameSite: 'lax',
|
||||
domain: env.COOKIE_DOMAIN || undefined,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const authPlugin = fp(plugin, { name: 'auth' });
|
||||
@@ -0,0 +1,77 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { eq, lt } from 'drizzle-orm';
|
||||
import { getDb, sessions, users } from '@lawdesk/db';
|
||||
|
||||
const SESSION_BYTES = 32;
|
||||
const SESSION_TTL_DAYS = 30;
|
||||
|
||||
export const SESSION_COOKIE = 'sid';
|
||||
|
||||
export function generateSessionToken(): string {
|
||||
return crypto.randomBytes(SESSION_BYTES).toString('base64url');
|
||||
}
|
||||
|
||||
export function hashSessionToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
|
||||
export interface CreateSessionOpts {
|
||||
userId: string;
|
||||
ip?: string | null;
|
||||
userAgent?: string | null;
|
||||
}
|
||||
|
||||
export async function createSession(opts: CreateSessionOpts): Promise<{ token: string; expiresAt: Date }> {
|
||||
const token = generateSessionToken();
|
||||
const id = hashSessionToken(token);
|
||||
const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
await getDb().insert(sessions).values({
|
||||
id,
|
||||
userId: opts.userId,
|
||||
expiresAt,
|
||||
ip: opts.ip ?? null,
|
||||
userAgent: opts.userAgent ?? null,
|
||||
});
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export async function loadSession(token: string) {
|
||||
const id = hashSessionToken(token);
|
||||
const db = getDb();
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
session: sessions,
|
||||
user: users,
|
||||
})
|
||||
.from(sessions)
|
||||
.innerJoin(users, eq(sessions.userId, users.id))
|
||||
.where(eq(sessions.id, id))
|
||||
.limit(1);
|
||||
|
||||
const row = rows[0];
|
||||
if (!row) return null;
|
||||
if (row.session.expiresAt.getTime() <= Date.now()) {
|
||||
await db.delete(sessions).where(eq(sessions.id, id));
|
||||
return null;
|
||||
}
|
||||
|
||||
// Touch last_seen_at (best-effort, fire and forget)
|
||||
db.update(sessions)
|
||||
.set({ lastSeenAt: new Date() })
|
||||
.where(eq(sessions.id, id))
|
||||
.catch(() => {});
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
export async function destroySession(token: string): Promise<void> {
|
||||
const id = hashSessionToken(token);
|
||||
await getDb().delete(sessions).where(eq(sessions.id, id));
|
||||
}
|
||||
|
||||
export async function purgeExpiredSessions(): Promise<void> {
|
||||
await getDb().delete(sessions).where(lt(sessions.expiresAt, new Date()));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb, users } from '@lawdesk/db';
|
||||
import { env } from '../env';
|
||||
|
||||
export function isSuperadminEmail(email: string): boolean {
|
||||
return env.superadminEmails.includes(email.toLowerCase());
|
||||
}
|
||||
|
||||
// Promote any user whose email is on the SUPERADMIN_EMAILS list. Idempotent.
|
||||
// Called on signup/login so the assignment happens automatically as soon as the user shows up.
|
||||
export async function ensureSuperadminFlag(userId: string, email: string, currentFlag: boolean) {
|
||||
const shouldBe = isSuperadminEmail(email);
|
||||
if (shouldBe === currentFlag) return shouldBe;
|
||||
await getDb().update(users).set({ isSuperadmin: shouldBe, updatedAt: new Date() }).where(eq(users.id, userId));
|
||||
return shouldBe;
|
||||
}
|
||||
Reference in New Issue
Block a user