​構建使用者管理微服務(六):新增持久 JWT 令牌的 remember me 身份驗證

DaoCloud發表於2017-08-08

​構建使用者管理微服務(六):新增持久 JWT 令牌的 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 來實現身份驗證


​構建使用者管理微服務(六):新增持久 JWT 令牌的 remember me 身份驗證


相關文章