- 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>
126 lines
4.3 KiB
TypeScript
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;
|