详解Spring Boot中的JWT令牌管理策略

为了安全起见,使用无状态JWT令牌时可以使用短时限TTL(1分钟)策略,然后这些令牌会在其生存时间内及时刷新。如果服务器不知道用户何时注销,那么可以继续刷新已注销用户的令牌。本文将提供针对这个问题的一种解决方案,使之在保持水平扩展性的同时确保安全性能不受影响。

详解Spring Boot中的JWT令牌管理策略_第1张图片

架构设计

详解Spring Boot中的JWT令牌管理策略_第2张图片

从图中展示的体系架构可见,每个微服务都有自己的数据库。被撤销的令牌和用户都需要单一(身份)信息源(Single Source of Truth,简称“SSOT”)。数据库需要具有高可用性,包括多主机、热备份及数据库的其他功能。其中,撤销的令牌数据库只需要两个表:一个用于用户注销时来缓存撤销的令牌,此令牌由负责缓存撤销令牌表中内容的微服务每90秒调用一次;另一个用于用户登录。每次注销后,微服务都会在定义的行生存时间内更新撤销的令牌表,并且登录是有速度限制的。因此,上述体系结构减少了吊销令牌数据库的负载,使其能够扩展到更大的部署。被撤销令牌的单一(身份)信息源要求是必要的,因为每个用户请求都可以在任何微服务上处理,并且需要在那里检查撤销的令牌。需要通过用户表来支持微服务登录用户。这样一来就可以将安全检查的负载分散到各个微服务。该架构中,JWT令牌是在微服务的内存中进行检查的,而且只增加了一点CPU损耗,不要求使用IO负载。

实现代码分析

为了验证上述结论,我开发了一个MovieManager项目来实现撤销令牌的处理。首先,我们来看登录部分实现代码。

登录操作

为了支持已撤销的令牌,登录时首先要检查用户当前已撤销令牌的数量,并降低登录速度,以限制用户可以生成的已撤销令牌的数量。这一部分功能是在UserDetailsMgmt服务中完成的,其中关键部分代码如下:

private UserDto loginHelp(Optional entityOpt, String passwd) {
   UserDto user = new UserDto();
   Optional myRole = entityOpt.stream()
      .flatMap(myUser -> Arrays.stream(Role.values())
   .filter(role1 -> Role.USERS.equals(role1))
           .filter(role1 -> 
              role1.name().equals(myUser.getRoles()))).findAny();
   if (myRole.isPresent() && entityOpt.get().isEnabled()
    && this.passwordEncoder.matches(passwd, entityOpt.get().getPassword())) {
   Callable callableTask = () -> this.jwtTokenService
           .createToken(entityOpt.get()
              .getUsername(), Arrays.asList(myRole.get()), Optional.empty());
        try {
      String jwtToken = executorService
              .schedule(callableTask, 3, TimeUnit.SECONDS).get();
      user = this.jwtTokenService
             .userNameLogouts(entityOpt.get().getUsername()) > 2 ? 
                user : this.userMapper.convert(entityOpt.get(), 
                   jwtToken, 0L);
   } catch (InterruptedException | ExecutionException e) {
      LOG.error("Login failed.", e);
   }
   }
   return user;
}

在上述代码中,首先筛选出用户实体User的可选角色,然后检查用户实体User是否存在以及其是否具有Users角色,是否已启用,以及密码是否匹配。

然后,创建一个Callable来为用户创建JWT令牌。该令牌中包含有Username和UUID,用于在注销时标识每个令牌。在不同的线程池上以3秒的延迟执行Callable,以限制用户在更新撤销的令牌缓存之间可以进行的注销次数。

接下来,检查是否为用户缓存了超过2个已撤销的令牌。如果为真,则拒绝登录。

总之,上述两项检查确保可以限制用户可生成的已撤销令牌数量以及登录时的负载。

为了实现水平可扩展性,必须将数据表移动到RevokedToken数据库中。

注销操作

注销操作是在UserDetailsMgmt服务中实现,代码如下:

public Boolean logout(String bearerStr) {
   if (!this.jwtTokenService.validateToken(
      this.jwtTokenService.resolveToken(bearerStr).orElse(""))) {
   throw new AuthenticationException("Invalid token.");
   }
   String username = this.jwtTokenService.getUsername(
      this.jwtTokenService
        .resolveToken(bearerStr).orElseThrow(() -> 
           new AuthenticationException("Invalid bearer string.")));
   String uuid = this.jwtTokenService
      .getUuid(this.jwtTokenService.resolveToken(bearerStr)
    .orElseThrow(() -> 
             new AuthenticationException("Invalid bearer string.")));
   this.userRepository.findByUsername(username).orElseThrow(() -> 
      new ResourceNotFoundException("Username not found: " + username));
   long revokedTokensForUuid = this.revokedTokenRepository.findAll().stream()
   .filter(myRevokedToken -> myRevokedToken.getUuid().equals(uuid)
      && myRevokedToken.getName().equalsIgnoreCase(username)).count();
   if (revokedTokensForUuid == 0) {
      this.revokedTokenRepository.save(new RevokedToken(username, uuid,  
         LocalDateTime.now()));
   } else {
      LOG.warn("Duplicate logout for user {}", username);
   }
   return Boolean.TRUE;
}

上述代码中,首先检查JWT令牌是否有效。接下来,从JWT令牌中读取用户名和UUID。然后,使用令牌中的用户名检查用户表Users中的用户数据。接下来,使用相同的UUID和UserID调用revokedTokens以进行记录检查。如果找到对应的记录,将记录一条关于重复注销尝试的警告。如果是第一次注销JWT令牌,则会在吊销的令牌表中创建一个新的包含当前用户名、UUID及当前时间等数据的RevokedToken实体。

为了实现水平扩展性,也必须将数据表移动到RevokedToken数据库。

撤销令牌缓存更新问题

撤销的令牌缓存任务是使用CronJobs组件进行更新的:

@Scheduled(fixedRate = 90000)
public void updateLoggedOutUsers() {
   LOG.info("Update logged out users.");
   this.userService.updateLoggedOutUsers();
}

每隔90秒从表中读取一次数据。更新操作在UserDetailsMgmt服务中处理:

public void updateLoggedOutUsers() {
   final List revokedTokens =
      new ArrayList(this.revokedTokenRepository.findAll());
   this.jwtTokenService.updateLoggedOutUsers(
      revokedTokens.stream().filter(myRevokedToken -> 
         myRevokedToken.getLastLogout() == null || 
         !myRevokedToken.getLastLogout()
         .isBefore(LocalDateTime.now()
         .minusSeconds(LOGOUT_TIMEOUT))).toList());
   this.revokedTokenRepository.deleteAll(
     revokedTokens.stream().filter(myRevokedToken -> 
        myRevokedToken.getLastLogout() != null && myRevokedToken
         .getLastLogout().isBefore(LocalDateTime.now()
            .minusSeconds(LOGOUT_TIMEOUT))).toList());          
}

上述代码中,首先从列表中读取所有被撤销的令牌。然后,删除早于LOGOUT_TIMEOUT(185秒)的记录;其他的则缓存在JwtTokenService中。

JwtTokenService负责管理撤销令牌的缓存:

public record UserNameUuid(String userName, String uuid) {}
private final List loggedOutUsers = 
   new CopyOnWriteArrayList<>();

public void updateLoggedOutUsers(List revokedTokens) {
   this.loggedOutUsers.clear();
   this.loggedOutUsers.addAll(revokedTokens.stream()
     .map(myRevokedToken -> new UserNameUuid(myRevokedToken.getName(), 
   myRevokedToken.getUuid())).toList());
}

上述代码中,UserNameUuid记录数据中包含具有标识令牌的值。loggedOutUsers列表中提供了已注销用户或者已撤销令牌的用户名UUID。其中,CopyOnWriteArrayList是线程安全的。

接下来,UpdateLogeDoutUsers方法获取已撤销令牌的当前列表,清除并更新loggedOutUsers列表——该列表用于令牌验证。

JWT令牌验证

JWT令牌中包含了一个用户名和哈希值,这些都需要进行验证。现在,JWT令牌也会根据loggedOutUsers列表进行检查,以检查注销情况。这部分任务是在下面的JWT令牌过滤器部分完成的:

@Override
public void doFilter(ServletRequest req, ServletResponse res, 
   FilterChain filterChain) throws IOException, ServletException {
   String token = jwtTokenProvider.resolveToken((HttpServletRequest) req);
   if (token != null && jwtTokenProvider.validateToken(token)) {
      Authentication auth = token != null ?  
         jwtTokenProvider.getAuthentication(token) : null;
      SecurityContextHolder.getContext().setAuthentication(auth);
   }
   filterChain.doFilter(req, res);
}

上述代码中,在处理请求前先调用JwtTokenFilter。首先,从HTTP头部数据中读出令牌。然后,检查令牌是否已找到且有效(validateToken(…)。最后,在SecurityContextHolder中创建并设置身份认证。

实现令牌验证的Java代码如下所示:

public boolean validateToken(String token) {
   try {
      Jws claimsJws = Jwts.parserBuilder()
        .setSigningKey(this.jwtTokenKey).build().parseClaimsJws(token);
      String subject = Optional.ofNullable(
        claimsJws.getBody().getSubject()).orElseThrow(() -> 
           new AuthenticationException("Invalid JWT token"));
      String uuid = Optional.ofNullable(claimsJws.getBody()
         .get(JwtUtils.UUID, String.class)).orElseThrow(() -> 
             new AuthenticationException("Invalid JWT token"));
      return this.loggedOutUsers.stream().noneMatch(myUserName -> 
         subject.equalsIgnoreCase(myUserName.userName) && 
         uuid.equals(myUserName.uuid));
   } catch (JwtException | IllegalArgumentException e) {
      throw new AuthenticationException("Expired or invalid JWT token",e);
   }
}

在上述代码中,首先解析令牌,检查签名密钥,读取声明:否则,抛出异常。然后,读取主题(userName)和UUID。接下来,根据loggedOutUsers检查令牌。如果所有检查都正常,则令牌有效,请求得到处理。

小结

在上述方案中,令牌的生存时间为60秒。注销令牌写入撤销令牌表后,缓存每90秒更新一次。被撤销的令牌在表中保留185秒。这意味着,每个令牌在所有缓存中都需要刷新。然后,刷新将失败,令牌从而不再有效。登录的速度限制确保用户可以在撤销的令牌表中创建的条目数量有限。所有这些都限制了RevokedToken数据库上的负载,从而增加它可以处理的微服务的数量。

因此,有了这样的体系结构便可降低令牌丢失的风险。同时,基于JWT令牌身份验证的分布式安全检查可让大部分可拓展性优势得以保持。

作为补充,对于微服务中的同步时钟,可以使用NTP技术实现。文章​ ​《Ubuntu中的同步技术》​ ​提供了有关实现此技术的操作指南。另外,文章​ ​《基于Spring+Angular的JWT自刷新解决方案》​ ​中也展示了一个基于Angular前端如何处理令牌的例子。

译者介绍

朱先忠,51CTO社区编辑,51CTO专家博客、讲师,潍坊一所高校计算机教师,自由编程界老兵一枚。早期专注各种微软技术(编著成ASP.NET AJX、Cocos 2d-X相关三本技术图书),近十多年投身于开源世界(熟悉流行全栈Web开发技术),了解基于OneNet/AliOS+Arduino/ESP32/树莓派等物联网开发技术与Scala+Hadoop+Spark+Flink等大数据开发技术。

你可能感兴趣的