NestJS 实战:JWT 登录认证、Token 刷新与安全退出


前言

前面几篇已经完成了项目架构、全局请求链路、TypeORM 数据库设计以及客户 CRUD。接下来要解决的是中后台系统最核心的入口:登录认证。

一个真实项目的登录并不只是"账号密码正确就返回 JWT"。还需要考虑:

  • 用户可以使用手机号或邮箱登录
  • 密码必须使用哈希进行校验
  • 连续登录失败后要限制账号
  • 用户状态异常时拒绝登录
  • Access Token 和 Refresh Token 如何分工
  • Refresh Token 如何防止重复使用
  • 退出登录后旧 Token 如何立即失效
  • CRM 与 SaaS 系统如何使用不同密钥
  • 登录后如何恢复用户、组织和团队上下文

本篇结合真实项目中的 LoginControllerLoginServiceJwtStrategyJwtTokenService 和 Redis Token 服务,梳理一条完整的 JWT 登录认证链路。


一、认证和授权先分清楚

认证和授权是两个不同概念:

复制代码
认证 Authentication
  └─ 你是谁?是否已经登录?

授权 Authorization
  └─ 你能做什么?是否拥有接口权限?

JWT 主要解决认证问题。用户通过 JWT Guard 后,只能说明身份有效,并不代表可以访问所有客户、订单和系统管理接口。

后续还需要 TeamGuard 和 PermissionGuard 继续完成授权判断。


二、真实登录模块的目录结构

项目的登录能力并不全部写在一个文件中,而是按职责拆分:

复制代码
modules/crm/auth/login/
├─ dto/
│  └─ login.dto.ts
├─ login.controller.ts
├─ login.service.ts
├─ login-limit.service.ts
└─ login.module.ts

common/jwt/
├─ jwt.guard.ts
├─ jwt.module.ts
├─ jwt.payload.ts
├─ jwt.service.ts
├─ jwt.strategy.ts
└─ public.decorator.ts

主要职责如下:

| 模块 | 职责 |<br>|---|---|<br>| LoginController | 登录、刷新令牌、退出登录 |<br>| LoginService | 查找用户、校验密码和账号状态 |<br>| LoginLimitService | 登录失败计数与账号锁定 |<br>| JwtTokenService | 签发、解析和验证 Token |<br>| JwtStrategy | 从请求中解析 JWT 并恢复用户 |<br>| JwtAuthGuard | 控制接口是否需要登录 |<br>| RedisTokenService | Refresh Token、黑名单和撤销状态 |


三、登录 DTO:限制输入边界

登录接口首先需要明确输入结构:

复制代码
export class LoginDto {
  @IsNotEmpty({ message: '账号不能为空' })
  @IsString()
  account: string

  @IsNotEmpty({ message: '密码不能为空' })
  @IsString()
  password: string
}

DTO 只负责输入格式校验,不能在 DTO 中查询用户或比较密码。

登录接口应避免返回过于具体的错误:

复制代码
不推荐:该邮箱不存在
不推荐:密码错误
推荐:账号或密码错误

统一提示可以减少攻击者枚举有效账号的机会。


四、LoginService:查找并校验用户

真实项目允许使用手机号或邮箱作为账号:

复制代码
async findLoginUser(loginDto: LoginDto) {
  const errMsg = '账号或密码错误'
  const where = {}

  if (isPhoneNumber(loginDto.account, 'CN')) {
    where['phone'] = loginDto.account
  }

  if (isEmail(loginDto.account)) {
    where['email'] = loginDto.account
  }

  const member = await this.memberRepo.findOneBy({
    ...where,
    type: 'user'
  })

  if (!member) {
    throw new BadRequestException(errMsg)
  }

  return member
}

需要注意:如果账号既不是合法手机号,也不是合法邮箱,应该尽早返回统一错误,避免生成空查询条件。


五、密码不能明文存储

数据库中不应该保存用户明文密码,而应该保存哈希值:

复制代码
用户输入密码
  ↓
bcrypt.compare()
  ↓
和数据库密码哈希比较
  ↓
返回 true / false

示例:

复制代码
const isSame = await this.passwordService.compare(
  loginDto.password,
  member.password || ''
)

if (!isSame) {
  throw new BadRequestException('账号或密码错误')
}

密码哈希应该使用 bcrypt、Argon2 等成熟算法,不要自行实现加密算法,也不要使用普通 MD5 保存密码。


六、登录失败锁定

如果接口允许无限尝试密码,攻击者就可以持续进行暴力破解。

项目通过 LoginLimitService 记录失败次数:

复制代码
const isLocked = await this.loginLimitService
  .isAccountLocked(member.uuid)

if (isLocked) {
  throw new BadRequestException(
    '由于登录失败次数过多,账号已被锁定'
  )
}

密码错误时增加失败次数:

复制代码
if (!isSame) {
  await this.loginLimitService
    .incrementFailedAttempts(member.uuid)

  throw new BadRequestException(errMsg)
}

登录成功后重置:

复制代码
await this.loginLimitService
  .resetFailedAttempts(member.uuid)

失败计数通常适合放在 Redis 中,并设置合理过期时间。


七、账号状态校验

密码正确也不代表一定允许登录:

复制代码
if (member.status !== 1) {
  throw new BadRequestException('账号状态异常')
}

常见账号状态包括:

  • 正常
  • 禁用
  • 待激活
  • 已离职
  • 已注销

账号状态必须由后端校验,前端隐藏登录入口没有安全意义。


八、登录接口的完整流程

Controller 中的登录接口可以保持简洁:

复制代码
@Public()
@Post()
async create(@Body() loginDto: LoginDto) {
  const member = await this.loginService
    .findLoginUser(loginDto)

  const { organizations, defaultOrgId } =
    await this.organizationContextService
      .resolveMemberOrganizations(member.uuid)

  const tokens = this.jwtTokenService
    .sign(member, defaultOrgId)

  return {
    ...tokens,
    organizations
  }
}

完整链路是:

复制代码
接收账号和密码
  ↓
查找用户
  ↓
检查登录锁定
  ↓
比较密码哈希
  ↓
检查账号状态
  ↓
解析用户组织
  ↓
签发 Access Token 与 Refresh Token

九、Access Token 和 Refresh Token

两个 Token 的职责不同:

| Token | 用途 | 建议有效期 |<br>|---|---|---|<br>| Access Token | 访问业务接口 | 较短 |<br>| Refresh Token | 换取新 Token | 较长 |

签发示例:

复制代码
sign(member: Member, organizationUuid?: string) {
  const data = {
    uuid: member.uuid,
    organizationUuid,
    openid: member.openid
  }

  const accessToken = this.jwtService.sign(data)

  const refreshToken = this.jwtService.sign(data, {
    secret: this.refreshSecret,
    expiresIn: this.refreshExpiresIn
  })

  return { accessToken, refreshToken }
}

不要把密码、手机号、完整角色权限列表等大量信息塞进 JWT。JWT Payload 应该保持精简。


十、Refresh Token 单次轮换

如果 Refresh Token 被窃取且可以无限重复使用,攻击者就可以持续刷新登录状态。

项目使用 Redis 保证 Refresh Token 只能使用一次:

复制代码
客户端提交 Refresh Token
  ↓
Redis 检查是否有效
  ↓
立即标记旧 Token 已使用
  ↓
验证签名并读取用户
  ↓
签发新 Access Token 和 Refresh Token
  ↓
保存新的 Refresh Token 状态

接口示例:

复制代码
@Public()
@Post('refresh')
async refresh(
  @Headers('x-refresh-token') refreshToken: string
) {
  if (!refreshToken) {
    throw new UnauthorizedException('缺少刷新token')
  }

  await this.redisTokenService
    .ensureRefreshTokenNotUsed(refreshToken)

  const payload = this.jwtTokenService.verify(
    refreshToken,
    this.jwtRefreshSecret
  )

  const member = await this.loginService
    .findMemberByUuid(payload.uuid)

  return this.jwtTokenService.sign(
    member,
    payload.organizationUuid
  )
}

这就是常见的 Refresh Token Rotation。


十一、JwtStrategy 如何恢复当前用户

Passport JWT Strategy 从请求头中读取 Bearer Token:

复制代码
super({
  jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
  ignoreExpiration: false,
  passReqToCallback: true
})

验证成功后,validate() 会查询当前用户:

复制代码
async validate(req: Request, payload: JwtPayload) {
  const type = req.url.split('/').filter(Boolean)[0]

  const user = await this.jwtTokenService
    .findUser(type as 'saas' | 'crm', payload.uuid)

  return {
    ...user,
    organizationUuid: payload.organizationUuid
  }
}

返回值会被挂载到:

复制代码
request.user

后续 Controller、TeamGuard 和 PermissionGuard 都可以读取当前用户。


十二、CRM 与 SaaS 使用不同密钥

项目包含 CRM 和 SaaS 两类接口,因此 JWT Strategy 会根据路径选择密钥:

复制代码
/crm/...  → jwt.crmSecret
/saas/... → jwt.saasSecret

这样可以避免不同系统的 Token 互相通用。

密钥必须来自安全配置,不能直接写在代码仓库中:

复制代码
jwt:
  crmSecret: ${JWT_CRM_SECRET}
  saasSecret: ${JWT_SAAS_SECRET}
  refreshSecret: ${JWT_REFRESH_SECRET}

十三、退出登录与 Token 黑名单

JWT 的一个特点是签发后可以独立验证。如果不增加额外状态,用户退出登录后,未过期的 Access Token 仍然可以使用。

项目通过 Redis 黑名单解决:

复制代码
@Public()
@Post('logout')
async logout(
  @AccessToken() accessToken: string,
  @Headers('x-refresh-token') refreshToken: string
) {
  if (accessToken) {
    const decoded = this.jwtTokenService.decode(accessToken)
    const ttl = decoded?.exp
      ? decoded.exp - Math.floor(Date.now() / 1000)
      : 900

    await this.redisTokenService
      .blacklistAccessToken(accessToken, ttl)
  }

  if (refreshToken) {
    await this.redisTokenService
      .revokeRefreshToken(refreshToken)
  }

  return '退出成功'
}

JwtStrategy 每次验证时检查 Access Token 是否已撤销。


十四、前端如何配合

前端登录后保存 Token,并在请求中携带:

复制代码
request.interceptors.request.use(config => {
  const token = authStore.accessToken

  if (token) {
    config.headers.Authorization = `Bearer ${token}`
  }

  return config
})

遇到 Access Token 过期时,可以尝试刷新:

复制代码
业务请求返回 401
  ↓
调用 refresh 接口
  ↓
刷新成功:保存新 Token 并重放原请求
  ↓
刷新失败:清理登录状态并跳转登录页

需要避免多个请求同时触发多次刷新,可以通过 Promise 队列或刷新锁进行合并。


十五、常见安全问题

15.1 JWT 中放入敏感信息

JWT 默认只是编码和签名,并不是加密。Payload 可以被客户端读取。

15.2 Access Token 有效期过长

有效期越长,泄露后的风险窗口越大。

15.3 Refresh Token 可以重复使用

应该通过 Redis 保存状态并执行单次轮换。

15.4 退出登录只清理前端 Token

后端应该撤销 Refresh Token,并将未过期的 Access Token 加入黑名单。

15.5 登录错误信息过于具体

统一使用"账号或密码错误",减少账号枚举风险。

15.6 没有登录失败限制

应该限制连续失败次数,并设置合理锁定时间。


十六、面试聚焦

16.1 JWT 和 Session 有什么区别?

JWT 可以独立验证,适合分布式接口;Session 通常由服务端保存状态。JWT 并不等于完全无状态,黑名单和刷新令牌仍可能依赖 Redis。

16.2 为什么需要 Refresh Token?

让 Access Token 保持较短有效期,同时避免用户频繁重新登录。

16.3 JWT 如何实现退出登录?

撤销 Refresh Token,并将未过期 Access Token 放入 Redis 黑名单。

16.4 为什么要查询数据库恢复用户?

因为用户可能被禁用、删除或修改角色,不能永久相信签发时的旧状态。

16.5 为什么 CRM 和 SaaS 使用不同密钥?

为了隔离不同系统的身份边界,避免一种 Token 被另一个系统接受。


十七、思考与练习

  1. 如何设计多个请求同时触发 Token 刷新的前端队列?
  2. Refresh Token 被重复使用时,后端应该如何处理?
  3. 用户被管理员禁用后,现有 Token 如何立即失效?
  4. JWT Payload 中应该放哪些字段?
  5. 为什么不能只在前端实现登录失败次数限制?

总结

  • JWT 解决身份认证,不能代替接口授权
  • 登录要校验账号、密码哈希、失败次数和账号状态
  • Access Token 负责访问接口,Refresh Token 负责续期
  • Refresh Token 应该执行单次轮换,防止重复使用
  • JwtStrategy 负责验证 Token 并恢复当前用户
  • CRM 与 SaaS 应该使用不同签名密钥
  • 退出登录需要撤销 Refresh Token,并处理 Access Token 黑名单
  • Redis 让 JWT 认证具备撤销、锁定和刷新状态管理能力
相关推荐
Icoolkj1 小时前
ICOOLKJ 前后端分离项目完整部署文档(MySQL8.4+Redis+JDK21+Nginx+HTTPS+自动同步演示库)
redis·nginx·https
数行拙笔2 小时前
Redis---set类型
数据库·redis·缓存
William Dawson2 小时前
【国产化改造实战|Spring Boot对接华为云MRS Redis Kerberos认证终极踩坑指南】
spring boot·redis·华为云
宠友信息4 小时前
从重复请求到订单同步看内容社区源码的处理思路
java·spring boot·redis·后端·mysql·spring
tellmewhoisi18 小时前
多版本共用redis的token有效期校验(过期重新登录)
java·redis·缓存
Wzx19801219 小时前
Redis&ES——Retriever的抽象实现
数据库·人工智能·redis·elasticsearch
吴声子夜歌20 小时前
Redis 3.x——哨兵API
redis·sentinel
宠友信息1 天前
Spring Boot与Redis ZSet实现仿小红书源码双列瀑布流推荐
java·大数据·前端·spring boot·redis·mysql·uni-app
梅头脑1 天前
Redis 核心原理:为什么快?五种数据结构 + 持久化双引擎
redis