构建用户管理微服务(六):添加持久 JWT 令牌的 remember me 身份验证

《构建用户管理微服务》系列文章已经连载五期,即将接近尾声。本期的「译见」将带你用持久令牌添加经典的 remember-me 模式认证,先演示一下运行的用户管理应用程序是如何工作的, 然后再深入细节。

译见

在上期「译见」系列文章《构建用户管理微服务(五):使用 JWT 令牌和 Spring Security 来实现身份验证》中,使用 Spring Security 添加了基于用户名和密码的身份验证。但需要注意的是,JWT 令牌是在成功登录后发出的,并验证后续请求。创造长时间的 JWT 是不实际的,因为它们是相互独立的,且没有办法撤销它们。如果令牌被盗,其后果也无法挽回。因此,我想用持久令牌添加经典的 remember-me 模式认证。Remember-me 令牌存储在 Cookie 中 JWTs 作为第一道防线, 但是它们也被保存到数据库中, 并且追踪它们的生命周期。

这次我想先演示一下运行的用户管理应用程序是如何工作的, 然后再深入细节。

有关 Token | 船长导语

诸如 Facebook,Github,Twitter 等大型网站都在使用基于 Token 的身份验证。相比传统的身份验证方法,Token 的扩展性更强,也更安全,非常适合用在 Web 应用或者移动应用上。我们将 Token 翻译成令牌,也就意味着,你能依靠这个令牌去通过一些关卡,来实现验证。

验证流程

基本上, 在用户使用用户名/密码进行验证时所发生的情况, 致使他们表示希望应用程序记住他们的意图 (持久会话)。大多数情况下, 用户界面上会有一个附加的复选框来实现。但由于应用程序还没有开发一个用户界面, 我们便使用 cURL 来实现这一切。

登录

curl -D- -c cookies.txt -b cookies.txt \
 -XPOST http://localhost:5000/auth/login \
 -d '{ "username":"test", "password": "test", "rememberMe": true }'

 HTTP/1.1 200
 ... Set-Cookie: remember-me=eyJhbGciOiJIUzUxMiJ9...;Max-Age=1209600;path=/;HttpOnly X-Set-Authorization-Bearer: eyJhbGciOiJIUzUxMiJ9...

成功认证后,PersistentJwtTokenBasedRememberMeServices 会创建一个持久会话,将其保存到数据库并将其转换为 JWT 令牌。它负责将此持久会话存储在客户端的一个 cookie(Set-Cookie)上,并且还发送新创建的临时令牌。后者旨在在单页前端的使用寿命内使用,并使用非标准 HTTP 头(X-Set-Authorization-Bearer)进行发送。

当 rememberMe 标记为错误时,只创建一个无状态的 JWT 令牌,并且完全绕过 remember-me 基础架构。

在应用程序运行时只使用临时令牌

当应用程序在浏览器中打开时,它会在每个 XHR 请求的授权头文件中发送临时 JWT 令牌。然而,当应用程序重新加载时,临时令牌将丢失。

为了简单起见,这里使用 GET / users / {id}来演示正常的请求。

curl -D- -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...' \
  -XGET http://localhost:5000/users/524201457797040

HTTP/1.1 200
...
{
  "id" : 524201457797040,
  "screenName" : "test",
  "contactData" : {
  "email" : "test@springuni.com",
  "addresses" : [ ]
  },
  "timezone" : "AMERICA_LOS_ANGELES",
  "locale" : "en_US"
}

持久令牌与临时令牌一起进行使用

当用户在第一种情况下选择 remember-me 认证时,会发生这种情况。

curl -D- -c cookies.txt -b cookies.txt \
  -H 'Authorization: Bearer  eyJhbGciOiJIUzUxMiJ9...' \
  -XGET http://localhost:5000/users/524201457797040

HTTP/1.1 200
...
{
  "id" : 524201457797040,
  "screenName" : "test",
  "contactData" : {
    "email" : "test@springuni.com",
    "addresses" : [ ]
  },
  "timezone" : "AMERICA_LOS_ANGELES",
  "locale" : "en_US"
}

在这种情况下,临时 JWT 令牌和一个有效的 remember-me cookie 都是同时发送的。只要单页应用程序正在运行,就使用临时令牌。

使用持久令牌进行初始化

当前端在浏览器中加载时, 不知道是否存在任何临时 JWT 令牌。所能做的就是通过尝试执行一个正常的请求来测试持久的记住我的 cookie。

curl -D- -c cookies.txt -b cookies.txt \
  -XGET http://localhost:5000/users/524201457797040

HTTP/1.1 200
...Set-Cookie: remember-me=eyJhbGciOiJIUzUxMiJ9...;Max-Age=1209600;path=/;HttpOnlyX-Set-Authorization-Bearer: eyJhbGciOiJIUzUxMiJ9...

{
  "id" : 524201457797040,
  "screenName" : "test",
  "contactData" : {
    "email" : "test@springuni.com",
    "addresses" : [ ]
  },
  "timezone" : "AMERICA_LOS_ANGELES",
  "locale" : "en_US"
}

如果持久令牌 (cookie) 仍然有效, 它将在数据库中进行更新, 使其在上次使用时保持的记录在浏览器中也会得到更新。另外一个重要的步骤也是执行, 用户无需给他们的用户名/密码和一个新的临时令牌就会自动获得身份验证。从现在开始, 只要它在运行, 应用程序就会使用临时令牌。

注销

虽然注销操作看起来似乎很简单, 但还是有一些需要我们注意的细节。只要用户已通过身份验证, 前端就仍然无法发送无状态的 JWT 令牌,否则用户界面上的注销按钮就不会被提供, 后端也不知道如何注销。

curl -D- -c cookies.txt -b cookies.txt \
  -H 'Authorization: Bearer eyJhbGciOiJIUzUxMiJ9...' \
  -XPOST http://localhost:5000/auth/logout

HTTP/1.1 302 
Set-Cookie: remember-me=;Max-Age=0;path=/
Location: http://localhost:5000/login?logout

在此请求之后, remember-me cookie 将被重置, 并且数据库中的持久会话也被标记为 “已删除”。

实现 Remember-me 身份验证

正如我在摘要中提到的,我们将使用持久令牌来增加安全性,以便能够在任何时候进行撤销。我们需要执行三个步骤,以确保用 Spring Security 处理 remember-me。

实现 UserDetailsService

在第一篇文章中,我决定使用 DDD 开发模型,因此它不能依赖于任何框架特定的类。实际上,它甚至不依赖于任何第三方框架或库。大多数教程通常直接实现 UserDetailsService,并且业务逻辑和用于构建应用程序的框架之间没有额外的层。

UserServices 在该项目的第二部分就已经被添加,因此我们的任务非常简单,因为现在我们需要的是一个框架特定的组件,它将 UserDetailsService 的任务委托给现有的逻辑。

public class DelegatingUserService implements UserDetailsService {  private final UserService userService;  public DelegatingUserService(UserService userService) {    this.userService = userService;
  }

  @Override  public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    Long userId = Long.valueOf(username);
    UsernameNotFoundException usernameNotFoundException = new UsernameNotFoundException(username);    return userService.findUser(userId)
        .map(DelegatingUser::new)
        .orElseThrow(() -> usernameNotFoundException);
  }

}

它只是围绕 UserService 的简单包装, 最终将返回的 User 模型对象转换为框架特定的 UserDetails 实例。此外, 在这个项目中, 我们不直接使用用户的登录名 (电子邮件地址或屏幕名称)。相反, 他们的用户 id 在各处被传递。

实现 PersistentTokenRepository

幸运的是,我们在添加 PersistentTokenRepository 实现方面同样容易,因为领域模型已经包含SessionService 和 Session 。

public class DelegatingPersistentTokenRepository implements PersistentTokenRepository {  private static final Logger LOGGER =
      LoggerFactory.getLogger(DelegatingPersistentTokenRepository.class);  private final SessionService sessionService;  public DelegatingPersistentTokenRepository(SessionService sessionService) {    this.sessionService = sessionService;
  }

  @Override  public void createNewToken(PersistentRememberMeToken token) {
    Long sessionId = Long.valueOf(token.getSeries());
    Long userId = Long.valueOf(token.getUsername());
    sessionService.createSession(sessionId, userId, token.getTokenValue());
  }

  @Override  public void updateToken(String series, String tokenValue, Date lastUsed) {
    Long sessionId = Long.valueOf(series);    try {
      sessionService.useSession(sessionId, tokenValue, toLocalDateTime(lastUsed));
    } catch (NoSuchSessionException e) {
      LOGGER.warn("Session {} doesn't exists.", sessionId);
    }
  }

  @Override  public PersistentRememberMeToken getTokenForSeries(String seriesId) {
    Long sessionId = Long.valueOf(seriesId);    return sessionService
        .findSession(sessionId)
        .map(this::toPersistentRememberMeToken)
        .orElse(null);
  }

  @Override  public void removeUserTokens(String username) {
    Long userId = Long.valueOf(username);
    sessionService.logoutUser(userId);
  }  private PersistentRememberMeToken toPersistentRememberMeToken(Session session) {
    String username = String.valueOf(session.getUserId());
    String series = String.valueOf(session.getId());
    LocalDateTime lastUsedAt =
        Optional.ofNullable(session.getLastUsedAt()).orElseGet(session::getIssuedAt);    return new PersistentRememberMeToken(
        username, series, session.getToken(), toDate(lastUsedAt));
  }

}

它的情况与 UserDetailsService 大致相同,包装器会在 PersistentRememberMeToken 和Session 之间进行转换 。唯一需要特别注意的是 PersistentRememberMeToken 中的日期字段。在会话中,我分离了两个日期字段(ie. issuedAt 和 lastUsedAt),当用户在 remember-me 令牌的帮助下首次登录时, 后者获取其第一个值。因此有可能它是空的,而这时,issuedAt 的值将会作为替代。

实现 RememberMeServices

public class PersistentJwtTokenBasedRememberMeServices extends
    PersistentTokenBasedRememberMeServices {  private static final Logger LOGGER =
      LoggerFactory.getLogger(PersistentJwtTokenBasedRememberMeServices.class);  public static final int DEFAULT_TOKEN_LENGTH = 16;  public PersistentJwtTokenBasedRememberMeServices(
      String key, UserDetailsService userDetailsService,
      PersistentTokenRepository tokenRepository) {    super(key, userDetailsService, tokenRepository);
  }

  @Override  protected String[] decodeCookie(String cookieValue) throws InvalidCookieException {    try {
      Claims claims = Jwts.parser()
          .setSigningKey(getKey())
          .parseClaimsJws(cookieValue)
          .getBody();      return new String[] { claims.getId(), claims.getSubject() };
    } catch (JwtException e) {
      LOGGER.warn(e.getMessage());      throw new InvalidCookieException(e.getMessage());
    }
  }

  @Override  protected String encodeCookie(String[] cookieTokens) {
    Claims claims = Jwts.claims()
        .setId(cookieTokens[0])
        .setSubject(cookieTokens[1])
        .setExpiration(new Date(currentTimeMillis() + getTokenValiditySeconds() * 1000L))
        .setIssuedAt(new Date());    return Jwts.builder()
        .setClaims(claims)
        .signWith(HS512, getKey())
        .compact();
  }

  @Override  protected String generateSeriesData() {    long seriesId = IdentityGenerator.generate();    return String.valueOf(seriesId);
  }

  @Override  protected String generateTokenData() {    return RandomUtil.ints(DEFAULT_TOKEN_LENGTH)
        .mapToObj(i -> String.format("%04x", i))
        .collect(Collectors.joining());
  }

  @Override  protected boolean rememberMeRequested(HttpServletRequest request, String parameter) {    return Optional.ofNullable((Boolean)request.getAttribute(REMEMBER_ME_ATTRIBUTE)).orElse(false);
  }

}

在这一点上, 我们重新使用 PersistentTokenBasedRememberMeServices,并为手头的任务进行自定义, 这取决于 UserDetailsService 和 PersistentTokenRepository 已然被实现。

此特定实现使用 JWT 令牌作为实例化窗体, 用于在 cookie 中存储 remember-me 令牌。Spring Security 的默认格式已经很好了,但 JWT 增加了一个额外的安全层。用于检查 remember-me 令牌,默认实现没有签名,每个请求最终都是数据库中的一个查询。

JWT 防止了这种情况的发生,尽管解析它并验证其签名需要更多的 CPU 周期。

将他们组合在一起

@Configurationpublic class AuthSecurityConfiguration extends SecurityConfigurationSupport {

  ...

  @Bean  public UserDetailsService userDetailsService(UserService userService) {    return new DelegatingUserService(userService);
  }

  @Bean  public PersistentTokenRepository persistentTokenRepository(SessionService sessionService) {    return new DelegatingPersistentTokenRepository(sessionService);
  }

  @Bean  public RememberMeAuthenticationFilter rememberMeAuthenticationFilter(
      AuthenticationManager authenticationManager, RememberMeServices rememberMeServices,
      AuthenticationSuccessHandler authenticationSuccessHandler) {

    RememberMeAuthenticationFilter rememberMeAuthenticationFilter =        new ProceedingRememberMeAuthenticationFilter(authenticationManager, rememberMeServices);

    rememberMeAuthenticationFilter.setAuthenticationSuccessHandler(authenticationSuccessHandler);    return rememberMeAuthenticationFilter;
  }

  @Bean  public RememberMeServices rememberMeServices(
      UserDetailsService userDetailsService, PersistentTokenRepository persistentTokenRepository) {

    String secretKey = getRememberMeTokenSecretKey().orElseThrow(IllegalStateException::new);    return new PersistentJwtTokenBasedRememberMeServices(
        secretKey, userDetailsService, persistentTokenRepository);
  }

  ...

  @Override  protected void customizeRememberMe(HttpSecurity http) throws Exception {
    UserDetailsService userDetailsService = lookup("userDetailsService");
    PersistentTokenRepository persistentTokenRepository = lookup("persistentTokenRepository");
    AbstractRememberMeServices rememberMeServices = lookup("rememberMeServices");
    RememberMeAuthenticationFilter rememberMeAuthenticationFilter =
        lookup("rememberMeAuthenticationFilter");

    http.rememberMe()
        .userDetailsService(userDetailsService)
        .tokenRepository(persistentTokenRepository)
        .rememberMeServices(rememberMeServices)
        .key(rememberMeServices.getKey())
        .and()
        .logout()
        .logoutUrl(LOGOUT_ENDPOINT)
        .and()
        .addFilterAt(rememberMeAuthenticationFilter, RememberMeAuthenticationFilter.class);
  }

  ...

}

其效果在最后部分是显而易见的。基本上,这是关于使用 Spring Security 注册组件,并启用 remember-me 服务的全部过程。AbstractRememberMeServices 也是此设置中的默认注销处理程序,并在注销时将数据库中的令牌标记为已删除。

Gotchas

在 POST 请求正文中接收用户凭据和 remember-me 标记为 Json 数据

默认情况下, UsernamePasswordAuthenticationFilter 会将凭据作为 POST 请求的 HTTP 请求参数,相对的,我们希望发送的是 JSON 文档。进一步的,AbstractRememberMeServices 还会检查是否存在 remember-me 标志作为请求参数。为了解决这个问题,LoginFilter 将 remember-me 标志设置为请求属性,并将 PersistentTokenBasedRememberMeServices 的决定委派给 remember-me 的身份验证是否需要启动。

使用 RememberMeServices 处理登录成功

RememberMeAuthenticationFilter 不会继续进入过滤器链中的下一个过滤器,但如果设置了AuthenticationSuccessHandler 它将停止其执行 。

登录

public class ProceedingRememberMeAuthenticationFilter extends RememberMeAuthenticationFilter {  private static final Logger LOGGER =
      LoggerFactory.getLogger(ProceedingRememberMeAuthenticationFilter.class);  private AuthenticationSuccessHandler successHandler;  public ProceedingRememberMeAuthenticationFilter(
      AuthenticationManager authenticationManager, RememberMeServices rememberMeServices) {    super(authenticationManager, rememberMeServices);
  }

  @Override  public void setAuthenticationSuccessHandler(AuthenticationSuccessHandler successHandler) {    this.successHandler = successHandler;
  }

  @Override  protected void onSuccessfulAuthentication(
      HttpServletRequest request, HttpServletResponse response, Authentication authResult) {    if (successHandler == null) {      return;
    }    try {
      successHandler.onAuthenticationSuccess(request, response, authResult);
    } catch (Exception e) {
      LOGGER.error(e.getMessage(), e);
    }
  }

}

ProceedingRememberMeAuthenticationFilter 是原始过滤器的自定义版本,当认证成功时,该过滤器不会停止。

下期预告:构建用户管理微服务(七):合而为一

原文链接:https://www.springuni.com/user-management-microservice-part-6

往期回顾

译见|构建用户管理微服务(一):定义领域模型和 REST API

译见|构建用户管理微服务(二):实现领域模型

译见|构建用户管理微服务(三):实现和测试存储库

译见|构建用户管理微服务(四):实现 REST 控制器

译见|构建用户管理微服务(五):使用 JWT 令牌和 Spring Security 来实现身份验证