在上期「譯見」系列文章《構建用戶管理微服務(五):使用 JWT 令牌和 Spring Security 來實現身份驗證》中,使用 Spring Security 添加了基於用戶名和密碼的身份驗證。但須要注意的是,JWT 令牌是在成功登陸後發出的,並驗證後續請求。創造長時間的 JWT 是不實際的,由於它們是相互獨立的,且沒有辦法撤銷它們。若是令牌被盜,其後果也沒法挽回。所以,我想用持久令牌添加經典的 remember-me 模式認證。Remember-me 令牌存儲在 Cookie 中 JWTs 做爲第一道防線, 可是它們也被保存到數據庫中, 而且追蹤它們的生命週期。前端
此次我想先演示一下運行的用戶管理應用程序是如何工做的, 而後再深刻細節。spring
諸如 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 將被重置, 而且數據庫中的持久會話也被標記爲 "已刪除"。
正如我在摘要中提到的,咱們將使用持久令牌來增長安全性,以便可以在任什麼時候候進行撤銷。咱們須要執行三個步驟,以確保用 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 也是此設置中的默認註銷處理程序,並在註銷時將數據庫中的令牌標記爲已刪除。
在 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 來實現身份驗證