構建用戶管理微服務(六):添加持久 JWT 令牌的 remember me 身份驗證


在上期「譯見」系列文章《構建用戶管理微服務(五):使用 JWT 令牌和 Spring Security 來實現身份驗證》中,使用 Spring Security 添加了基於用戶名和密碼的身份驗證。但須要注意的是,JWT 令牌是在成功登陸後發出的,並驗證後續請求。創造長時間的 JWT 是不實際的,由於它們是相互獨立的,且沒有辦法撤銷它們。若是令牌被盜,其後果也沒法挽回。所以,我想用持久令牌添加經典的 remember-me 模式認證。Remember-me 令牌存儲在 Cookie 中 JWTs 做爲第一道防線, 可是它們也被保存到數據庫中, 而且追蹤它們的生命週期。前端

此次我想先演示一下運行的用戶管理應用程序是如何工做的, 而後再深刻細節。spring


有關 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 基礎架構。bash


在應用程序運行時只使用臨時令牌cookie


當應用程序在瀏覽器中打開時,它會在每一個 XHR 請求的受權頭文件中發送臨時 JWT 令牌。然而,當應用程序從新加載時,臨時令牌將丟失。session


爲了簡單起見,這裏使用 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 來實現身份驗證


相關文章
相關標籤/搜索