发布于 2026-01-06 1 阅读
0

第 1/3 部分:如何在 NestJS 中实现带令牌轮换的刷新令牌

第 1/3 部分:如何在 NestJS 中实现带令牌轮换的刷新令牌

在本集中,我们将学习如何使用本地存储来实现刷新令牌,以此作为存储访问令牌和刷新令牌的策略。如果您想直接访问 GitHub 代码库,可以点击此处

先决条件

在深入学习本指南之前,建议您先熟悉 NestJS 以及在 NestJS 中实现 Passport 策略。如果您不熟悉这些主题,请参阅 NestJS 文档中的以下文章:

我们为什么要使用刷新令牌?

即使我们使用 HTTPS 加密网络流量,我们仍然可以采取其他措施来防止恶意用户通过社交工程或库攻击等方式窃取访问令牌。刷新令牌允许用户在长时间保持登录状态而无需再次登录(前提是他们是活跃用户)。如果用户在一段时间(例如六个月)后处于非活跃状态,我们可以将其注销。

为什么使用刷新令牌而不是有效期长或无过期时间的单一访问令牌?因为我们需要防止访问令牌被盗,而有效期短的访问令牌更容易被攻击者获取过期令牌。此外,刷新令牌还可以在不重置 JWT 签名密钥和注销所有用户的情况下撤销用户访问权限。

实施概述

首次登录时,我们会收到一个令牌对,其中包含访问令牌和刷新令牌。当访问令牌即将过期时,我们可以向身份验证服务器请求新的令牌对。服务器只有在收到刷新令牌后才会提供新的令牌对。

你觉得我为什么会提到要接收包含访问令牌和刷新令牌的新令牌对?如果我有一个有效期很长的刷新令牌,那只获取一个新的访问令牌不就足够了吗?其实,存储有效期较长的刷新令牌可以避免黑客获取可能过期的短有效期令牌。此外,这种方法也排除了实现“永久登录”用户功能的可能性,即使这些用户处于活跃状态。所以,我们的做法是,当请求新的令牌对时,通过一种叫做刷新令牌轮换的机制,立即使之前的刷新令牌失效。

刷新令牌轮换

刷新令牌轮换机制通过生成一个黑名单来实现,该黑名单会“强制失效”之前使用过的刷新令牌。当请求新的令牌对时,我们会使用一个刷新令牌,然后将该已使用的刷新令牌添加到黑名单中。这意味着,如果黑客获得了刷新令牌,而用户之前已经刷新过令牌对,那么该刷新令牌就已经失效了。

但如果黑客获得了新的有效刷新令牌怎么办?你有两种选择:如果你能立即发现攻击(虽然可能性不大),你可以快速获取新的令牌对,使黑客的刷新令牌失效。如果黑客使用了你的刷新令牌,并且该令牌被标记为无效,你就无能为力了。他们可能可以无限期地访问你的应用程序,直到你更改 JWT 签名密钥。为了防止这种情况,我们可以将刷新令牌存储在仅限 HTTP 的 cookie 中,并防范 CSRF 攻击。这样,即使你的应用程序存在 XSS 漏洞,黑客也无法读取刷新令牌。如果你对将刷新令牌存储在仅限 HTTP 的 cookie 中的文章感兴趣,请留言,我会尽快撰写。

入门

本文将着重介绍核心逻辑,并保持应用程序的简洁性。

从 GitHub 克隆项目并启动 Docker 容器:

yarn dc up
Enter fullscreen mode Exit fullscreen mode

预先在数据库中填充两个用户:

yarn dc-db-init
Enter fullscreen mode Exit fullscreen mode

访问 Swagger 并localhost:3000/docs登录。管理员用户请使用以下信息:
邮箱:admin@admin.com
密码:1234

我们的应用允许通过调用端点来刷新令牌对/refresh-tokens。当使用刷新令牌以 bearer 身份验证方式调用该端点时,会使之前的令牌失效。尝试使用同一个令牌调用两次端点,以查看401 Unauthorized error第二次调用的结果。

核心逻辑位于身份验证模块中。我们设置了三个保护机制:

  • 本地身份验证保护:用于使用电子邮件和密码进行初始身份验证。
  • JWT Auth Guard:全局保护所有应用程序路由,定义为APP_GUARDapp.module.ts使用访问令牌进行验证。
  • JWT 刷新身份验证守卫:保护/refresh-tokens endpoint刷新令牌进行验证。

这里的关键在于访问令牌和刷新令牌之间的交互,因此我将略过讨论本地身份验证守卫。对于 JWT 身份验证守卫,我们使用软件包中的 JWT 策略'passport-jwt'

以下部分将定义如何从请求中提取 JWT 以及我们在环境中设置的 JWT 签名密钥。在 validate 方法中,我们将接收 JWT 的有效负载,并使用该有效负载检索用户 ID,并在确认用户存在于数据库中后授予访问权限(如果存在)。

export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(
    private userService: UserService,
    configService: ConfigService<EnvironmentVariables>,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('jwtSecret'),
    });
  }

  async validate(payload: any): Promise<User | null> {
    const authUser = await this.userService.findOne(payload.sub);
    if (!authUser) {
      throw new UnauthorizedException();
    }
    return authUser;
  }
}
Enter fullscreen mode Exit fullscreen mode

同样,对于 JWT 刷新身份验证守卫,我们采用了软件包中的相同 JWT 策略'passport-jwt'。与 JWT 策略文件不同的是,我们使用不同的密钥生成 JWT 令牌,并且同时返回用户属性和刷新令牌的过期日期。此过期日期在后续流程中是必需的。

@Injectable()
export class JwtRefreshStrategy extends PassportStrategy(Strategy, 'jwt-refresh') {
  constructor(
    private userService: UserService,
    configService: ConfigService<EnvironmentVariables>,
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: configService.get('jwtRefreshSecret'),
    });
  }

  async validate(payload: any) {
    const authUser = await this.userService.findOne(payload.sub);
    if (!authUser) {
      throw new UnauthorizedException();
    }
    return {
      attributes: authUser,
      refreshTokenExpiresAt: new Date(payload.exp * 1000),
    };
  }
}
Enter fullscreen mode Exit fullscreen mode

在 authentication.controller 的登录方法中,我们可以看到我们调用了登录方法,而登录方法又调用了我们代码中的 generateTokenPair 方法AuthRefreshTokenService。值得注意的是,我们还实现了一个限流机制来限制登录路由上的请求数量,从而防止暴力破解攻击,每秒最多 2 个请求,每 60 秒最多 5 次登录尝试。

@Throttle({ short: { limit: 2, ttl: 1000 }, long: { limit: 5, ttl: 60000 } })
@ApiBody({ type: UserLoginDto })
@Public()
@UseGuards(LocalAuthGuard)
@Post('login')
login(@Request() req: any) {
  return this.authenticationService.login(req.user);
}
Enter fullscreen mode Exit fullscreen mode

在身份验证服务内部:

login(user: User) {
  return this.authRefreshTokenService.generateTokenPair(user);
}
Enter fullscreen mode Exit fullscreen mode

auth.refresh.token.service.ts 文件内容如下:

export class AuthRefreshTokenService {
  constructor(
    private jwtService: JwtService,
    private configService: ConfigService<EnvironmentVariables>,
    @InjectRepository(AuthRefreshToken)
    private authRefreshTokenRepository: Repository<AuthRefreshToken>,
  ) {}

  async generateRefreshToken(authUserId: number, currentRefreshToken?: string, currentRefreshTokenExpiresAt?: Date) {
    const newRefreshToken = this.jwtService.sign(
      { sub: authUserId },
      { secret: this.configService.get('jwtRefreshSecret'), expiresIn: '30d' },
    );

    if (currentRefreshToken && currentRefreshTokenExpiresAt) {
      if (await this.isRefreshTokenBlackListed(currentRefreshToken, authUserId)) {
        throw new UnauthorizedException('Invalid refresh token.');
      }

      await this.authRefreshTokenRepository.insert({
        refreshToken: currentRefreshToken,
        expiresAt: currentRefreshTokenExpiresAt,
        userId: authUserId,
      });
    }

    return newRefreshToken;
  }

  private isRefreshTokenBlackListed(refreshToken: string, userId: number) {
    return this.authRefreshTokenRepository.existsBy({ refreshToken, userId });
  }

  async generateTokenPair(user: User, currentRefreshToken?: string, currentRefreshTokenExpiresAt?: Date) {
    const payload = { email: user.email, sub: user.id };
    return {
      access_token: this.jwtService.sign(payload),
      refresh_token: await this.generateRefreshToken(user.id, currentRefreshToken, currentRefreshTokenExpiresAt),
    };
  }

  @Cron(CronExpression.EVERY_DAY_AT_6AM)
  async clearExpiredRefreshTokens() {
    await this.authRefreshTokenRepository.delete({ expiresAt: LessThanOrEqual(new Date()) });
  }
}
Enter fullscreen mode Exit fullscreen mode

查看该generateRefreshToken方法,我们会生成一个有效期为 30 天的新刷新令牌。如果我们没有收到可选的 currentRefreshToken 和 currentRefreshTokenExpiresAt 参数,我们会直接返回新创建的刷新令牌,这与成功登录后的预期结果一致。

检查refreshTokens身份验证控制器中的以下方法,我们注意到其中实现了一个限流机制:每秒最多 1 个请求,或每 60 秒最多 2 个请求。我们调用 generateTokenPair 方法,并传入用户属性、使用的刷新令牌及其过期日期:

@Throttle({
  short: { limit: 1, ttl: 1000 },
  long: { limit: 2, ttl: 60000 },
})
@ApiBearerAuth()
@Public()
@UseGuards(JwtRefreshAuthGuard)
@Post('refresh-tokens')
refreshTokens(@Request() req: ExpressRequest) {
  if (!req.user) {
    throw new InternalServerErrorException();
  }
  return this.authRefreshTokenService.generateTokenPair(
    (req.user as any).attributes,
    req.headers.authorization?.split(' ')[1],
    (req.user as any).refreshTokenExpiresAt,
  );
}
Enter fullscreen mode Exit fullscreen mode

当使用 `and`和 `or`调用generateTokenPair`method`时,它会检查当前令牌是否在黑名单中,如果重复使用则会抛出错误。首次使用时,它会将此令牌插入到我们的身份验证刷新令牌数据库表中,实际上就相当于一个黑名单。AuthRefreshTokenServicecurrentRefreshTokencurrentRefreshTokenExpiresAt

在我们的服务的最后一个方法中,我们有一个定时任务负责删除所有过期的刷新令牌,因为我们不再需要将它们保留在数据库中。

这是三集系列教程的第一部分。在下一集中,我将向您展示如何在 React 应用中轻松管理访问令牌和刷新令牌。在第三集中,我们将深入探讨如何将刷新令牌存储在仅限 HTTP 的 cookie 中,而不是本地存储中。即使您的应用存在 XSS 漏洞,这种方法也能防止攻击者读取刷新令牌。

如果您希望我介绍更多关于 Node.js 生态系统的有趣话题,欢迎在评论区留言。别忘了在rabbitbyte.club订阅我的新闻邮件,获取最新资讯!

帖子创建:
请查看本系列的第二部分,其中我们将把这个后端与 React 应用程序集成。

文章来源:https://dev.to/zenstok/how-to-implement-refresh-tokens-with-token-rotation-in-nestjs-1deg