在当今的前后端分离架构中,用户身份认证与权限控制是每一个后端服务绕不开的核心模块。传统的 Session-Cookie 方案虽然简单直观,但在分布式、跨域、移动端等场景下逐渐暴露出局限性。取而代之的是以 JWT(JSON Web Token)为代表的无状态鉴权方案。
本文继续实战深入剖析一个生产级的鉴权系统设计思路,涵盖登录流程、Token 颁发、双 Token 机制的设计考量、刷新逻辑、请求守卫与策略验证等多个层面。我们将一步步构建出既高性能又高可维护性的业务模块,让你在面对复杂业务时,依然能保持代码的整洁与自信,并且聚焦于背后的工程思想和最佳实践。
一、登录的本质:安全地确认“你是你”
当我们说“用户登录”,其实是在完成两个目标:
- 身份核验:确认用户名和密码匹配;
- 状态建立:为后续请求提供一种无需重复输入凭证的身份标识。
传统方式依赖服务器存储 session,客户端通过 cookie 携带 session ID 来维持会话。这种方式的问题在于:
- 服务器需要维护状态,难以水平扩展;
- 跨域共享困难;
- 移动端支持不佳。
于是,JWT 应运而生——它把用户信息打包成一段加密字符串,由客户端自行保管并随请求发送。服务器只需验证其有效性即可识别用户,真正实现了“无状态”。
登录时做了什么?
当用户提交账号密码后,服务端执行以下步骤:
- 查询数据库是否存在该用户;
- 使用单向哈希算法比对密码(如 bcrypt);
- 若通过,则生成包含用户关键信息的 JWT,并返回给客户端。
这里的关键点在于:永远不要明文存储密码。即使数据库泄露,攻击者也无法反推出原始密码。bcrypt 是目前最推荐的密码哈希算法之一,因为它内置盐值(salt),且计算成本可调,能有效抵御暴力破解。
if (!user || !await bcrypt.compare(password, user.password)) {
throw new UnauthorizedException('用户名或密码错误');
}
一旦验证成功,下一步就是颁发令牌。
二、为什么只用一个 Token 不够?双 Token 机制的必要性
很多项目初期采用“单一 JWT”的方式:登录后返回一个有效期较长的 token(比如7天)。看似简洁,实则埋下安全隐患。
单 Token 的风险
假设你的 access token 有效期为7天,一旦这个 token 在传输过程中被中间人截获(例如公共 Wi-Fi 下的嗅探),攻击者就可以冒充你进行操作,直到7天后自动过期。这期间的所有数据都面临威胁。
这就是典型的“长有效期 + 高频使用 = 高风险暴露面”问题。
双 Token 的设计哲学
为了解决这个问题,业界普遍采用了“双 Token 机制”,即同时发放两个令牌:
| Token 类型 | 用途 | 有效期 | 是否频繁使用 |
|---|---|---|---|
| Access Token | 日常接口鉴权 | 短(如15分钟) | 是 |
| Refresh Token | 用于获取新的 Access Token | 长(如7天) | 否 |
这种分工带来了几个显著优势:
1. 缩短高危暴露窗口
Access Token 即使被窃取,也仅能在15分钟内使用,极大降低了危害程度。
2. 减少敏感操作频率
Refresh Token 不参与日常请求,只有在 access token 过期时才触发一次调用,减少了被监听的机会。
3. 支持灵活吊销机制
虽然 JWT 本身是无状态的,但可以通过黑名单机制或存储 refresh token 的使用记录来实现主动失效(例如用户登出、更换设备等场景)。
4. 提升用户体验
用户不必频繁重新登录,只要 refresh token 有效,就能无缝续签。
三、如何实现双 Token 的生成与刷新?
1. Token 的签发
当用户登录成功后,服务端应并行生成两个 token。这里使用 Promise.all 是出于性能考虑:两个签名操作互不依赖,可以并发执行,减少整体延迟。
const [at, rt] = await Promise.all([
jwtService.signAsync(payload, { expiresIn: '15m' }),
jwtService.signAsync(payload, { expiresIn: '7d' })
]);
注意 payload 中通常包含:
sub(subject):用户唯一标识(建议用字符串 ID)name或其他非敏感信息
切记不要放入密码、手机号、邮箱等敏感字段,即使 JWT 是加密的,Base64 编码仍可被解码查看内容。
2. 刷新机制的工作流程
当客户端发现 access token 已过期(通常是收到 401 响应),会发起 /refresh 请求,携带 refresh token。
服务端收到后:
- 验证 refresh token 是否有效(签名、过期时间);
- 若有效,提取其中的用户信息;
- 重新生成一对新的 token 并返回;
- (可选)旧的 refresh token 可加入黑名单防止重放。
try {
const payload = await jwtService.verifyAsync(refresh_token, { secret });
return generateTokens(payload.sub, payload.name);
} catch (err) {
throw new UnauthorizedException('Refresh Token 已失效,请重新登录');
}
这样就完成了“无感刷新”的体验闭环。
四、如何保护特定接口?守卫(Guard)机制详解
并非所有接口都需要登录才能访问,比如首页、文章列表等公开资源。但对于创建文章、点赞、修改资料等操作,则必须确保用户已登录。
这就引出了“守卫(Guard)”的概念——它是一种拦截器,运行在路由处理函数之前,决定是否允许请求继续执行。
守卫的运作原理
我们可以将 Guard 看作一道安检门:
- 所有请求先经过这道门;
- 它检查请求头中的
Authorization: Bearer <token>; - 解析并验证 token;
- 如果有效,把解析出的用户对象附加到请求上下文中;
- 最终放行,交由业务逻辑处理。
如果验证失败(token 无效、过期、格式错误),直接返回 401,不再进入后续流程。
如何实现 JWT 守卫?
核心在于定义一套标准化的验证策略。常见的做法是基于 Passport.js 生态构建 JWT 策略。
1. 定义 JWT 验证策略
我们需要告诉系统:
- 从哪里提取 token? → HTTP 头部
Authorization: Bearer <token> - 用什么密钥验证? → 环境变量配置的 SECRET
- 验证通过后返回什么? → 用户身份对象
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.TOKEN_SECRET,
});
然后重写 validate 方法,在 token 被解析后回调:
async validate(payload) {
return { id: payload.sub, name: payload.name };
}
这个返回值会被挂载到 req.user 上,供后续控制器使用。
2. 应用守卫到接口
在需要保护的接口上添加装饰器即可:
@UseGuards(JwtAuthGuard)
@Post('posts')
createPost(@Body() dto) {
// 此时 req.user 已可用
}
此时若未携带有效 token,请求根本不会进入 createPost 方法,直接返回 401。
五、常见误区与最佳实践
❌ 错误做法:把 Refresh Token 当 Access Token 用
有些开发者为了让请求更“安全”,在每次 API 调用中都传 refresh token。这是极其危险的!
refresh token 的生命周期长,一旦泄露后果严重。它的唯一职责是换取新 token,绝不应出现在高频请求中。
✅ 正确做法:仅在 access token 失效时,单独发起 /refresh 请求。
❌ 错误做法:前端本地修改 token 过期时间
JWT 是 Base64Url 编码的,任何人都可以解码看到内容(包括过期时间 exp 字段)。但无法篡改,因为签名会校验失败。
不要试图在前端“延长 token 有效期”,这是无效且危险的操作。
✅ 正确做法:始终依赖服务端签发的新 token。
✅ 推荐实践:合理设置 Token 有效期
| 场景 | 推荐有效期 |
|---|---|
| 普通 Web 应用 | Access: 15~30min,Refresh: 7d |
| 敏感系统(金融类) | Access: 5 |
| 移动 App | 可适当延长 Refresh 至 14d,配合设备绑定 |
可以根据业务需求动态调整,甚至为不同角色设置不同策略。
✅ 推荐实践:支持 Token 主动失效
虽然 JWT 是无状态的,但我们可以通过以下方式实现“登出即失效”:
- 将 logout 时的 refresh token 加入 Redis 黑名单,有效期等于原 token 剩余时间;
- 每次 refresh 前先查黑名单;
- 或改为使用“短期可撤销 token + 长期授权码”模式。
这对安全性要求高的系统尤为重要。
六、总结:构建健壮鉴权系统的五大支柱
一个成熟的鉴权体系,离不开以下五个关键组成部分:
| 组件 | 作用 |
|---|---|
| 密码加密 | 使用 bcrypt 等抗 brute-force 算法 |
| 双 Token 机制 | 分离高频使用与长期授权,降低风险 |
| 守卫拦截 | 统一控制接口访问权限,避免重复判断 |
| 策略化验证 | 抽象 JWT 解析逻辑,便于复用和测试 |
| 刷新与失效机制 | 平衡安全与体验,支持主动注销 |
这些组件共同构成了现代 Web 应用的身份防线。它们不仅提升了系统的安全性,也让开发过程更加清晰可控。
结语
技术没有银弹,JWT 也不是万能的。它解决了无状态鉴权的问题,但也带来了 token 管理、吊销难等新挑战。双 Token 机制正是我们在实践中不断演进而来的折中方案——在安全、性能、体验之间找到了较好的平衡点。
作为开发者,我们要做的不仅是“会用某个库”,更要理解每一步背后的设计动机。当你清楚为何要短时效 access token、为何要分离 refresh token、为何要用 Guard 而不是手动 if-check,你才算真正掌握了这套体系。
希望这篇文章能帮你建立起对现代 Web 鉴权的系统性认知。下次你在设计登录模块时,不妨多问一句:“这样做是为了什么?有没有更好的选择?” —— 这才是工程师应有的思考方式。