← back to Lawyer Directory Builder
src/server/auth.ts
169 lines
/**
* Auth — cookie-session, bcrypt password hashing, role-gated middleware.
* Sessions live in PG (`app_sessions`); the cookie just holds the session id.
*/
import bcrypt from 'bcryptjs';
import crypto from 'node:crypto';
import cookie from 'cookie';
import type { Request, Response, NextFunction } from 'express';
import { query } from '../db/pool.ts';
const COOKIE_NAME = 'sid';
const SESSION_TTL_DAYS = 30;
const BCRYPT_ROUNDS = 10;
export type AppUser = {
id: number;
email: string;
full_name: string | null;
role: 'user' | 'admin';
plan: 'free' | 'premium' | 'pro';
status: 'active' | 'suspended' | 'deleted';
organization_id: number | null;
professional_id: number | null;
bar_number: string | null;
firm_name: string | null;
website: string | null;
phone: string | null;
created_at: string;
last_login_at: string | null;
tier?: 'guest' | 'client' | 'lawyer' | 'admin' | null;
};
declare module 'express-serve-static-core' {
interface Request {
user?: AppUser;
}
}
export async function hashPassword(plain: string): Promise<string> {
return bcrypt.hash(plain, BCRYPT_ROUNDS);
}
export async function verifyPassword(plain: string, hash: string): Promise<boolean> {
return bcrypt.compare(plain, hash);
}
// Real bcrypt hash (cost=10) of a known canary string. NEVER matches a real
// password but takes ~65ms to compare — same as a real password verify, so
// login response time doesn't leak account existence (audit 2026-05-04 tick 25).
const DUMMY_HASH = '$2a$10$Jqugk5R4m/Rd5KMiBsRjOOsTRNTJk/GqwTxaV0GHwNVdMBETjr8iO';
/**
* Constant-time email+password verify. Returns the user row on match, null on
* mismatch OR no-such-user. Either way bcrypt.compare runs once — response
* time doesn't leak account existence.
*
* Use this instead of `findUserByEmail` + `verifyPassword` separately in any
* /login route that's reachable by unauthenticated callers.
*/
export async function findUserAndVerifyPassword(
email: string,
password: string
): Promise<{ id: number; status: string } | null> {
const u = await findUserByEmail(email);
const hashToCheck = u?.password_hash || DUMMY_HASH;
const ok = await bcrypt.compare(password, hashToCheck);
if (!ok || !u) return null;
return { id: u.id, status: u.status };
}
export async function createUser(opts: {
email: string; password: string;
full_name?: string; role?: 'user' | 'admin';
bar_number?: string; firm_name?: string; website?: string; phone?: string;
}) {
const password_hash = await hashPassword(opts.password);
const r = await query<{ id: number }>(`
INSERT INTO app_users (email, password_hash, full_name, role, bar_number, firm_name, website, phone)
VALUES (LOWER($1), $2, $3, COALESCE($4,'user'), $5, $6, $7, $8)
RETURNING id
`, [
opts.email.trim(), password_hash,
opts.full_name || null, opts.role || 'user',
opts.bar_number || null,
opts.firm_name || null, opts.website || null, opts.phone || null,
]);
return r.rows[0].id;
}
export async function findUserByEmail(email: string): Promise<{ id: number; password_hash: string; status: string } | null> {
const r = await query<{ id: number; password_hash: string; status: string }>(
`SELECT id, password_hash, status FROM app_users WHERE LOWER(email) = LOWER($1) LIMIT 1`, [email.trim()]);
return r.rows[0] || null;
}
export async function findUserById(id: number): Promise<AppUser | null> {
const r = await query<AppUser>(`
SELECT id, email, full_name, role, plan, status, organization_id, professional_id,
bar_number, firm_name, website, phone, created_at, last_login_at, tier
FROM app_users WHERE id = $1 LIMIT 1
`, [id]);
return r.rows[0] || null;
}
export async function createSession(userId: number, ip: string | null, ua: string | null): Promise<string> {
const id = crypto.randomBytes(32).toString('hex');
const expires = new Date(Date.now() + SESSION_TTL_DAYS * 86400000);
await query(
`INSERT INTO app_sessions (id, user_id, expires_at, ip, user_agent) VALUES ($1, $2, $3, $4, $5)`,
[id, userId, expires, ip, ua?.slice(0, 200) || null]);
await query(`UPDATE app_users SET last_login_at = NOW() WHERE id = $1`, [userId]);
return id;
}
export async function destroySession(id: string) {
await query(`DELETE FROM app_sessions WHERE id = $1`, [id]);
}
export async function getSessionUser(sid: string | undefined): Promise<AppUser | null> {
if (!sid) return null;
const r = await query<{ user_id: number }>(`
SELECT user_id FROM app_sessions WHERE id = $1 AND expires_at > NOW() LIMIT 1
`, [sid]);
if (r.rowCount === 0) return null;
const u = await findUserById(r.rows[0].user_id);
if (!u || u.status !== 'active') return null;
return u;
}
export function setSessionCookie(res: Response, sid: string) {
res.setHeader('Set-Cookie', cookie.serialize(COOKIE_NAME, sid, {
httpOnly: true,
secure: true, // we're behind nginx+LE in prod; localhost works because chromium permits localhost
sameSite: 'lax',
path: '/',
maxAge: SESSION_TTL_DAYS * 86400,
}));
}
export function clearSessionCookie(res: Response) {
res.setHeader('Set-Cookie', cookie.serialize(COOKIE_NAME, '', {
httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 0,
}));
}
export async function authMiddleware(req: Request, _res: Response, next: NextFunction) {
const cookies = cookie.parse(req.headers.cookie || '');
const sid = cookies[COOKIE_NAME];
const user = await getSessionUser(sid);
if (user) req.user = user;
next();
}
export function requireUser(req: Request, res: Response, next: NextFunction) {
if (!req.user) return res.redirect(302, '/login?next=' + encodeURIComponent(req.originalUrl));
next();
}
export function requireAdmin(req: Request, res: Response, next: NextFunction) {
if (!req.user) return res.redirect(302, '/login?next=' + encodeURIComponent(req.originalUrl));
// Either legacy role='admin' OR new tier='admin' grants admin access.
const isAdmin = req.user.role === 'admin' || (req.user as any).tier === 'admin';
if (!isAdmin) return res.status(403).send('Forbidden — admin only.');
next();
}
export const PLANS = ['free', 'premium', 'pro'] as const;
export const STATUSES = ['active', 'suspended', 'deleted'] as const;