/** * 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 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; } } /** * Middleware: require authentication (401 if not logged in) */ function requireAuth(req, res, 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) { 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; } } module.exports = { signAndSetCookie, clearCookie, requireAuth, requireAdmin, optionalAuth, extractUser, extractUserFromToken, JWT_SECRET, COOKIE_NAME, };