jwt-auth.js
4.55 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
/**
* 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;
}
}
/**
* 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,
};