← 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;