jwt-auth.js 7.13 KB
/**
 * JWT Authentication Middleware
 *
 * - Signs JWT with { userId, email, role }
 * - Stores in httpOnly cookie "lemon_token" (7-day expiry)
 * - requireAuth: blocks unauthenticated requests
 * - optionalAuth: attaches user if present, doesn't block
 */

const jwt = require('jsonwebtoken');
const userDb = require('../services/user-db');

/**
 * 会话签名密钥。
 *
 * 兜底值只给本地开发用,而且**必须吵**:它提交在仓库里,任何读过仓库的人都能拿它
 * 签一个 `{userId: N}`。extractUser 会用 decoded.userId 回库重查(所以伪造
 * `role:'admin'` 这条路是堵住的),但 user-db 的 id 是自增小整数、不是 uuid,
 * 从 1 试起就能命中真实账号 —— 于是「已知密钥」直接等于「任意账号会话伪造」,
 * 命中的账号恰好是 admin 时就是完整的后台权限。
 *
 * 以前这里是静默降级:.env.example 里没有 JWT_SECRET 这一项,而 npm run setup
 * 就是从它生成 .env,所以照文档装出来的部署**必然**落在兜底值上,且启动时零告警。
 * 现在生产环境直接拒启(签名密钥缺失不是可以边跑边补的事),开发环境保留兜底但吼一声。
 */
const DEV_FALLBACK_SECRET = 'lemon-dev-secret-change-in-production';
const JWT_SECRET = process.env.JWT_SECRET || DEV_FALLBACK_SECRET;

if (!process.env.JWT_SECRET) {
  const lines = [
    'JWT_SECRET 未设置,会话正在用仓库里公开的兜底密钥签名。',
    '任何读过本仓库的人都能伪造任意用户的登录态(用户 id 是自增整数,可枚举)。',
    '生成一个并写进 .env:  openssl rand -hex 32',
  ];
  if (process.env.NODE_ENV === 'production') {
    throw new Error(`[jwt-auth] 拒绝以不安全配置启动 —— ${lines.join(' ')}`);
  }
  // 开发环境:不拦,但每次进程起来都提醒一次
  console.warn(`\n⚠️  [jwt-auth] ${lines.join('\n    ')}\n`);
}
const COOKIE_NAME = 'lemon_token';
const TOKEN_EXPIRY = '7d';

/**
 * Sign a JWT and set it as httpOnly cookie
 */
function signAndSetCookie(res, user) {
  const payload = {
    userId: user.id,
    email: user.email,
    role: user.role || 'user',
  };

  const token = jwt.sign(payload, JWT_SECRET, { expiresIn: TOKEN_EXPIRY });

  res.cookie(COOKIE_NAME, token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: '/',
  });

  return token;
}

/**
 * Clear the auth cookie
 */
function clearCookie(res) {
  res.clearCookie(COOKIE_NAME, { path: '/' });
}

/**
 * Extract and verify JWT from cookie or Authorization header
 */
function extractUser(req) {
  let token = null;

  // Cookie first
  if (req.cookies && req.cookies[COOKIE_NAME]) {
    token = req.cookies[COOKIE_NAME];
  }

  // Authorization: Bearer <token> fallback
  if (!token) {
    const auth = req.headers['authorization'];
    if (auth && auth.startsWith('Bearer ')) {
      token = auth.slice(7).trim();
    }
  }

  if (!token) return null;

  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    const user = userDb.getUserById(decoded.userId);
    if (!user) return null;
    return userDb.toPublicUser(user);
  } catch (err) {
    return null;
  }
}

/**
 * 38 号文档 Phase 1 落地时发现:仓库里已有 17 个测试文件直接拿 supertest 打
 * company.js/tasks.js/devloop.js/... 这些路由,从没设过登录 cookie——这些用例测的是
 * 业务逻辑(队列/归档/CRUD),不是鉴权本身,一个个改成先登录再打显然是本末倒置
 * (还会因为 bcrypt 而拖慢整个套件)。
 *
 * 所以测试环境默认让 requireAuth/requireAdmin 直接放行(`NODE_ENV=test` 是 vitest
 * 自动设的,不需要每个测试文件单独配置),补一条真正验证"锁真的挂上了"的守卫测试
 * (`server/tests/unit/route-auth-gate.test.js`),跑之前设 `FORCE_REAL_AUTH=1`
 * 关掉这里的放行,其余测试不受影响。**不是每次调用都读一遍 env**:中间件内联判断,
 * 保证同一个测试文件里前后设置的 env 值能立刻生效(不被模块加载时的一次性求值缓存)。
 */
function isTestAuthBypass() {
  return process.env.NODE_ENV === 'test' && process.env.FORCE_REAL_AUTH !== '1';
}
const TEST_BYPASS_USER = { id: -1, role: 'admin', display_name: 'test-bypass', email: 'test-bypass@local' };

/**
 * Middleware: require authentication (401 if not logged in)
 */
function requireAuth(req, res, next) {
  if (isTestAuthBypass()) {
    req.user = TEST_BYPASS_USER;
    return next();
  }
  const user = extractUser(req);
  if (!user) {
    return res.status(401).json({
      error: 'Unauthorized',
      message: 'Please log in to access this resource.',
    });
  }
  req.user = user;
  next();
}

/**
 * Middleware: optional authentication (attach user if present)
 */
function optionalAuth(req, res, next) {
  req.user = extractUser(req) || null;
  next();
}

/**
 * Middleware: require admin role (403 if not admin)
 */
function requireAdmin(req, res, next) {
  if (isTestAuthBypass()) {
    req.user = TEST_BYPASS_USER;
    return next();
  }
  const user = extractUser(req);
  if (!user) {
    return res.status(401).json({ error: 'Unauthorized' });
  }
  if (user.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden', message: 'Admin access required.' });
  }
  req.user = user;
  next();
}

/**
 * 直接从 token 字符串提取用户(供 WebSocket handshake 使用)
 */
function extractUserFromToken(token) {
  if (!token) return null;
  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    const user = userDb.getUserById(decoded.userId);
    return user ? userDb.toPublicUser(user) : null;
  } catch {
    return null;
  }
}

/**
 * 进程内令牌,只用于同一台机器上的服务端自环调用(agent-chat.js 挂 requireAuth 之后,
 * company.js:82/506、task-settle.js:452 这三处 `fetch('http://127.0.0.1:.../api/agent/...')`
 * 拿不到任何用户 cookie,会被 401 挡住——38 号文档 Phase 1)。
 *
 * 只在内存里生成、不落盘、不读环境变量:调用方和校验方永远在同一个 Node 进程里
 * (自环 HTTP 只是"本进程调本进程",不是真的跨进程/跨机器),进程重启两边一起换新值,
 * 不存在"改了忘同步"的问题,也不需要额外的密钥管理。
 */
const INTERNAL_TOKEN = require('crypto').randomBytes(24).toString('hex');
const INTERNAL_TOKEN_HEADER = 'x-internal-token';

/**
 * Middleware: 放行本进程自环调用(带对的 INTERNAL_TOKEN),其余情况退回 requireAuth。
 * 自环调用不代表任何真人用户,`req.user` 置一个可识别的系统身份,供下游判断。
 */
function requireAuthOrInternal(req, res, next) {
  if (req.headers[INTERNAL_TOKEN_HEADER] === INTERNAL_TOKEN) {
    req.user = { id: 0, role: 'system', display_name: 'system', internal: true };
    return next();
  }
  return requireAuth(req, res, next);
}

module.exports = {
  signAndSetCookie,
  clearCookie,
  requireAuth,
  requireAdmin,
  requireAuthOrInternal,
  optionalAuth,
  extractUser,
  extractUserFromToken,
  INTERNAL_TOKEN,
  INTERNAL_TOKEN_HEADER,
  JWT_SECRET,
  COOKIE_NAME,
};