jwt-auth.js
7.82 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
/**
* 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)。
*
* 只在内存里生成、不落盘:调用方和校验方永远在同一台机器的进程树里,进程重启两边一起
* 换新值,不存在"改了忘同步"的问题,也不需要额外的密钥管理。
*
* 写进 process.env 是为了让 `bin/lemon.js` 也能用上这个 token——它是 agent 用来
* `task report`/`task dispatch` 自我汇报的 CLI,跑在 claude CLI 子进程(乃至更深的 Bash
* 子进程)里,跟本进程不共享内存,没法直接 import 这个常量;但 spawn 默认把 process.env
* 整份传给子进程(ClaudeCliProtocol.js/ProcessLauncher.js 都是 `{...process.env}`),
* 写这一行就等于免费传给了所有后代进程,不用改任何 launcher 代码。之前只顾了「同进程内
* require 拿常量」这一种自环调用,漏了「agent 子进程里跑 CLI 工具」这一种,就是这次
* bug 的成因(commit b7b5096 之后 bin/lemon.js 的 task report/dispatch 全部 401)。
*/
const INTERNAL_TOKEN = require('crypto').randomBytes(24).toString('hex');
const INTERNAL_TOKEN_HEADER = 'x-internal-token';
process.env.LEMON_INTERNAL_TOKEN = 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,
};