用户身份认证是 Web 应用中最基本也是最容易出错的安全环节。Node.js 生态中,最常见的两种认证方案是 Session-Cookie 机制 和 JWT(JSON Web Token)。无论你选择 Express、Koa 还是 NestJS,它们都是构建用户系统的核心。本节会用真实的代码与场景,把两者的原理、用法和权衡说清楚。
14.1.1 Session-Cookie 机制
原理简述
Session-Cookie 是“服务端持有状态,客户端持有凭证”的模式。一次完整的登录流程如下:
- 用户提交登录表单,服务端验证账号密码。
- 验证通过后,服务端在内存或 Redis 中创建一个 Session 对象,存储用户 ID 等关键信息,并生成一个唯一 Session ID(通常是随机的 UUID 或哈希串)。
- 服务端通过
Set-Cookie响应头将 Session ID 写入浏览器 Cookie。 - 后续请求中,浏览器会自动携带该 Cookie,服务端根据其中的 Session ID 查找对应的 Session 数据,从而识别用户。
- 用户登出时,服务端删除对应 Session 数据,并清除客户端 Cookie。
这种模式的核心特征是 服务端有状态:用户登录状态完全由服务端维护,客户端只是一个“通行证”。
Express 中的实现
使用 express-session 中间件,几行代码就能搭建 Session 管理。
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const redis = require('redis').createClient();
const app = express();
app.use(session({
store: new RedisStore({ client: redis }), // 生产环境推荐用 Redis 存储
secret: 'your-secret-key', // 用于签名 Session ID cookie
resave: false, // 每次请求都重新保存 session 没有意义
saveUninitialized: false, // 只保存已修改的 session
cookie: {
httpOnly: true, // 禁止 JS 读取,防御 XSS
secure: process.env.NODE_ENV === 'production', // 仅 HTTPS 传输
maxAge: 1000 * 60 * 60 * 24 // 有效期 24 小时
}
}));
// 登录接口
app.post('/login', (req, res) => {
const { username, password } = req.body;
// 模拟验证用户
if (username === 'admin' && password === '123') {
req.session.user = { id: 1, username: 'admin' };
res.json({ message: '登录成功' });
} else {
res.status(401).json({ error: '账号或密码错误' });
}
});
// 受保护路由中间件
function requireAuth(req, res, next) {
if (req.session.user) {
next();
} else {
res.status(401).json({ error: '请先登录' });
}
}
app.get('/profile', requireAuth, (req, res) => {
res.json({ user: req.session.user });
});
// 登出
app.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) return res.status(500).json({ error: '登出失败' });
res.clearCookie('connect.sid'); // 清除客户端 Cookie
res.json({ message: '已登出' });
});
});
存储方式与生产优化
- 默认内存存储:开发时直接使用,但进程重启后所有 Session 都会丢失,且无法在多进程间共享。
- Redis 存储:将 Session 数据存入 Redis,配合
connect-redis或ioredis。多实例共享同一 Redis 即可实现集群级别的 Session 统一管理。 - 数据库存储:某些场景会将 Session 存入 MySQL/PostgreSQL,但性能不如 Redis,只适合低频认证。
安全风险与防护
- Session 固定攻击:用户登录前后如果 Session ID 不变,攻击者可能诱骗用户使用已知的 Session ID。解决方法是登录成功后调用
req.session.regenerate()重新生成 ID。 - XSS 窃取 Cookie:设置
httpOnly: true阻止 JS 读取document.cookie,但无法完全防御 XSS,仍需对用户输入严格转义。 - CSRF 攻击:由于浏览器自动携带 Cookie,恶意网站可能触发用户执行非预期的操作。防御措施包括使用 CSRF Token 或 SameSite Cookie 属性。
适用场景
Session-Cookie 非常适合传统的服务端渲染应用(如 Express + EJS/Pug),以及需要严格、集中控制用户状态的系统。其优势是服务端可以随时强制用户下线(删除 Session),缺点是需要额外的存储和状态管理。
14.1.2 JWT 令牌认证
原理简述
JWT 是一种无状态的认证方案。服务端不保存任何用户登录信息,而是将用户数据(claims)编码成一个自包含的 JSON 令牌发给客户端,客户端每次请求都携带该令牌,服务端通过验证签名来确认令牌的合法性和完整性。
一个 JWT 由三部分组成,以 . 分隔:header.payload.signature
- Header:声明令牌类型和签名算法,例如
{"alg":"HS256","typ":"JWT"}。 - Payload:存放声明(claims),如
{"userId":1,"role":"admin","iat":1516239022}。注意这部分只是 Base64 编码,并非加密,任何人都能解码查看其内容。 - Signature:使用密钥对
header + "." + payload进行哈希签名,保证令牌未被篡改。
常见的使用流程:
- 用户登录,服务端验证凭据,生成 JWT 并返回给客户端。
- 客户端将 JWT 存储在 localStorage 或 httpOnly Cookie 中。
- 后续请求中,客户端将 JWT 放在
Authorization: Bearer <token>头部。 - 服务端在每个请求中验证签名并解析 payload,直接获取用户信息,无需查询数据库。
- 令牌过期后,需要重新登录或使用 refresh token 续期。
Node.js 中的实现
使用 jsonwebtoken 库可以方便地生成和验证 JWT。
const jwt = require('jsonwebtoken');
const express = require('express');
const app = express();
const SECRET = 'your-256-bit-secret';
// 登录
app.post('/login', (req, res) => {
const { username, password } = req.body;
if (username === 'admin' && password === '123') {
const token = jwt.sign(
{ userId: 1, role: 'admin' }, // payload
SECRET,
{ expiresIn: '2h' } // 有效期
);
res.json({ token });
} else {
res.status(401).json({ error: '账号或密码错误' });
}
});
// 验证中间件
function authenticateToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // "Bearer <token>"
if (!token) return res.status(401).json({ error: '未提供令牌' });
jwt.verify(token, SECRET, (err, user) => {
if (err) return res.status(403).json({ error: '令牌无效或已过期' });
req.user = user; // 将 payload 挂载到请求对象
next();
});
}
app.get('/profile', authenticateToken, (req, res) => {
res.json({ user: req.user });
});
令牌存储与传输安全
- 存储位置:最常见的是前端用 localStorage 存储,但这容易受到 XSS 攻击(恶意脚本可读取并窃取)。更安全的方式是存储在 httpOnly、Secure 的 Cookie 中,同时设置 SameSite 策略以防止 CSRF,但 Cookie 的大小受限(4KB),且不能跨域携带。
- 传输安全:必须通过 HTTPS 传输,防止令牌被中间人截获。
- Payload 敏感信息:绝对不要在 payload 中放置密码或手机号等敏感数据,因为 payload 默认只需要 Base64 解码就能查看,除非额外使用 JWE(JSON Web Encryption)加密。
Token 过期与刷新机制
由于 JWT 是无状态的,一旦签发就无法在服务端单独使其失效(除非维护黑名单,这又回到了有状态)。通常的做法是:
- 短期 access token:有效期设为 15-30 分钟,用以访问资源。
- 长期 refresh token:有效期设为 7-30 天,只用来换取新的 access token,不直接用于资源访问。
- 客户端在 access token 过期时,用 refresh token 向
/refresh接口请求新令牌。服务端验证 refresh token(以及可能需要检查是否在数据库的撤销列表中),然后下发新的 access token 和可能的 refresh token 轮替。
示例 /refresh 接口:
app.post('/refresh', (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) return res.status(401).json({ error: '缺少刷新令牌' });
// 验证 refresh token(可能需要从数据库读取记录)
jwt.verify(refreshToken, REFRESH_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: '刷新令牌无效' });
// 生成新的 access token
const newAccessToken = jwt.sign({ userId: user.userId }, SECRET, { expiresIn: '15m' });
res.json({ accessToken: newAccessToken });
});
});
登出问题的处理
在纯 JWT 无状态模式下,服务端无法主动废止已签发的 access token。解决方案包括:
- 简短有效期:access token 有效期极短(如 5 分钟),登出后很快自然过期。
- 黑名单机制:将需要撤销的 token 的 jti 存入 Redis,设置一个与 token 过期时间一致的有效期,在验证中间件中额外检查黑名单。
- 不使用纯 JWT:结合服务端状态(如 Session)实现登出能力。
适用场景
JWT 特别适合前后端分离的 SPA、移动端应用、微服务间的无状态通信。因为不需要服务端存储状态,横向扩展非常容易,只要能拿到公钥或对称密钥就能验证令牌。
14.1.3 选择 Session 还是 JWT?
| 维度 | Session-Cookie | JWT |
|------|----------------|-----|
| 状态管理 | 有状态,服务端存储 | 无状态,客户端持有 |
| 扩展性 | 需要共享 Session 存储(Redis) | 天然适合分布式系统 |
| 性能 | 每次请求需查询存储 | 仅需本地计算验证签名 |
| 登出/失效 | 服务端直接删除 Session | 无法主动失效(除非用黑名单) |
| XSS 风险 | Cookie httpOnly 可防窃取 | 若存 localStorage,XSS 可窃取 |
| CSRF 风险 | 需额外防御 | 若从头部发送,无 CSRF 风险 |
| 移动端兼容 | 需处理 Cookie 管理 | 天然适合原生 App |
| 综合推荐 | 传统服务端渲染应用 | SPA、移动 App、微服务 API |
在实际项目中,你完全可以混合使用:核心 API 用 JWT 认证(利用其无状态和通用性),管理后台用 Session-Cookie(利用服务端可控性)。关键在于评估你的应用架构、安全需求以及团队维护成本。
14.1.4 通用安全实践(两种方案都适用)
- 密码存储:永远不要明文存储密码。使用
bcrypt进行哈希加盐。 - HTTPS 强制:生产环境绝对不允许 HTTP 明文传输身份凭证。
- 最小权限原则:Token/Session 中只包含最少必要信息,如用户 ID,不要放角色详情(可从数据库动态获取)。
- 定期轮换密钥:JWT 签名密钥和 Session 密钥应定期更换,并允许多密钥并存以平滑过渡。
- 接口限流:对登录、注册等认证接口应用限流(
express-rate-limit),防止暴力破解。
掌握这两种认证方式,你就可以为几乎所有 Node.js 项目搭建安全、可靠的身份系统。在后续章节中,我们会进一步结合 RBAC 权限模型,构建完整的用户访问控制体系。