在上期「譯見」系列文章《構建使用者管理微服務(五):使用 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
譯見|構建使用者管理微服務(五):使用 JWT 令牌和 Spring Security 來實現身份驗證