queue-med/server/auth.ts
Hermes 81c6bccf8a security: Phase 1 hardening - rate limit, helmet, CORS, JWT, session persistence
- express-rate-limit: 100/15min global, 5/15min on auth.login + auth.register,
  3/hour reserved for password-reset endpoints; trust proxy enabled.
- helmet: enabled with contentSecurityPolicy + crossOriginEmbedderPolicy off
  to keep Vite dev and the SPA bundle working.
- CORS: explicit allowlist (https://attente.cosmolan.fr in prod, localhost in
  dev), credentials true, restricted methods/headers; same allowlist applied
  to socket.io.
- JWT_SECRET: must be set and >= 32 chars; assertAuthEnv() called from the
  server bootstrap so the process refuses to start without one. The insecure
  "changeme-in-production" fallback in docker-compose.yml is removed.
- qm_auth cookie: maxAge reduced from 30d to 7d, JWT expiry matches.
- WhatsApp sessions: path now driven by WHATSAPP_SESSION_DIR and defaults to
  /app/data/whatsapp-sessions; docker-compose.yml mounts a named app_data
  volume so credentials survive container restarts.
- scripts/backup-db.sh: timestamped, gzipped mysqldump into /app/data/backups
  with rotation (keeps last 7); Dockerfile installs mysql-client and bundles
  the script.
- .env.example refreshed with documented placeholders for every required var
  (DATABASE_URL, JWT_SECRET, WHATSAPP_SESSION_DIR, MYSQL_*, BACKUP_*).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 13:06:51 +00:00

126 lines
4.3 KiB
TypeScript

import bcrypt from "bcryptjs";
import jwt from "jsonwebtoken";
import type { Request, Response, NextFunction } from "express";
import { getUserById } from "./db.js";
import type { User } from "./schema.js";
const COOKIE_NAME = "qm_auth";
const COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret || secret.length < 32) {
throw new Error(
"JWT_SECRET is not set or is too short (>= 32 chars required). " +
"Generate one with `openssl rand -hex 64` and set it in the environment."
);
}
return secret;
}
/**
* Fail-fast validation called from the server bootstrap so a misconfigured
* deployment refuses to start instead of erroring lazily on the first login.
*/
export function assertAuthEnv(): void {
getJwtSecret();
}
export interface JwtPayload {
sub: number;
email: string;
role: "user" | "admin";
}
// ─── Password hashing ────────────────────────────────────────────────────────
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
// ─── JWT ─────────────────────────────────────────────────────────────────────
export function createToken(user: Pick<User, "id" | "email" | "role">): string {
const payload: JwtPayload = {
sub: user.id,
email: user.email,
role: user.role,
};
return jwt.sign(payload, getJwtSecret(), { expiresIn: "7d" });
}
export function verifyToken(token: string): JwtPayload | null {
try {
const decoded = jwt.verify(token, getJwtSecret());
if (typeof decoded === "string") return null;
if (
typeof decoded.sub !== "number" ||
typeof decoded.email !== "string" ||
(decoded.role !== "user" && decoded.role !== "admin")
) {
return null;
}
return { sub: decoded.sub, email: decoded.email, role: decoded.role };
} catch {
return null;
}
}
// ─── Cookie helpers ──────────────────────────────────────────────────────────
export function setAuthCookie(res: Response, token: string): void {
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: COOKIE_MAX_AGE_MS,
path: "/",
});
}
export function clearAuthCookie(res: Response): void {
res.clearCookie(COOKIE_NAME, { path: "/" });
}
export function readAuthCookie(req: Request): string | null {
const cookies = (req as Request & { cookies?: Record<string, string> }).cookies;
if (cookies && typeof cookies[COOKIE_NAME] === "string") return cookies[COOKIE_NAME];
const header = req.headers.authorization;
if (header && header.startsWith("Bearer ")) return header.slice(7);
return null;
}
// ─── User from request ───────────────────────────────────────────────────────
export async function getUserFromRequest(req: Request): Promise<User | null> {
const token = readAuthCookie(req);
if (!token) return null;
const payload = verifyToken(token);
if (!payload) return null;
return getUserById(payload.sub);
}
// ─── Express middleware ──────────────────────────────────────────────────────
export interface AuthedRequest extends Request {
user?: User;
}
export async function authMiddleware(
req: AuthedRequest,
_res: Response,
next: NextFunction
): Promise<void> {
const user = await getUserFromRequest(req);
if (user) req.user = user;
next();
}
export function requireAuth(req: AuthedRequest, res: Response, next: NextFunction): void {
if (!req.user) {
res.status(401).json({ error: "Unauthorized" });
return;
}
next();
}
export const AUTH_COOKIE_NAME = COOKIE_NAME;