這段時間在學習搭建基於spring boot的spring oauth2 和jwt整合。
說實話挺折騰的。使用jwt做使用者鑑權,難點在於token的重新整理和登出。
當然登出的難度更大,網上的一些方案也沒有很出色的。這個功能基本讓我放棄了jwt(滑稽笑~)。
所以今天我單純的先記錄jwt token的重新整理。
Token重新整理
jwt token重新整理方案可以分為兩種:一種是校驗token前重新整理,第二種是校驗失敗後重新整理。
我們先來說說第二種方案
驗證失效後,Oauth2框架會把異常資訊傳送到OAuth2AuthenticationEntryPoint類裡處理。這時候我們可以在這裡做jwt token重新整理並跳轉。
網上大部分方案也是這種:失效後,使用refresh_token獲取新的access_token。並將新的access_token設定到response.header然後跳轉,前端接收並無感更新新的access_token。
這裡就不多做描述,可以參考這兩篇:
https://www.cnblogs.com/xuchao0506/p/13073913.html
https://blog.csdn.net/m0_37834471/article/details/83213002
接著說第一種,其實兩種方案的程式碼我都寫過,最終使用了第一種。原因是相容其他token重新整理方案。
我在使用第二種方案並且jwt token重新整理功能正常使用後,想換一種token方案做相容。
切換成memory token的時候,發現OAuth2AuthenticationEntryPoint裡面拿不到舊的token資訊導致重新整理失敗。
我們翻一下原始碼
DefaultTokenServices.java
public OAuth2Authentication loadAuthentication(String accessTokenValue) throws AuthenticationException, InvalidTokenException { OAuth2AccessToken accessToken = tokenStore.readAccessToken(accessTokenValue); if (accessToken == null) { throw new InvalidTokenException("Invalid access token: " + accessTokenValue); } else if (accessToken.isExpired()) { // 失效後accessToken即被刪除 tokenStore.removeAccessToken(accessToken); throw new InvalidTokenException("Access token expired: " + accessTokenValue); } // 忽略部分程式碼 return result; }
可以看到JwtTokenStore的removeAccessToken:它是一個空方法,什麼也沒做。所以我們在OAuth2AuthenticationEntryPoint依然能拿到舊的token並作處理。
但是其他的token策略在token過期後,被remove掉了。一點資訊都沒留下,巧婦難為無米之炊。所以,我之後選擇選擇了第一種方案,在token校驗remove前做重新整理處理。
jwt token重新整理的方案是這樣的:
客戶端傳送請求大部分只攜帶access_token,並不攜帶refresh_token、client_id及client_secret等資訊。所以我是先把refresh_token、client_id等資訊放到access_token裡面。
因為jwt並不具有續期的功能,所以在判斷token過期後,立刻使用refresh_token重新整理。並且在response的header裡面新增標識告訴前端你的token實際上已經過期了需要更新。
當然,其他的類似memory token、redis token可以延期的,更新策略就沒這麼複雜:直接延長過期時間並且不需要更新token。
說了這麼多,放token重新整理相關程式碼:
首先,我們需要把refresh_token、client_id、client_secret放入到access_token中,以便重新整理。所以我們需要重寫JwtAccessTokenConverter的enhance方法。
OauthJwtAccessTokenConverter.java
public class OauthJwtAccessTokenConverter extends JwtAccessTokenConverter { private JsonParser objectMapper = JsonParserFactory.create(); public OauthJwtAccessTokenConverter(SecurityUserService userService) { // 使用SecurityContextHolder.getContext().getAuthentication()能獲取到User資訊 super.setAccessTokenConverter(new OauthAccessTokenConverter(userService)); } @Override public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) { DefaultOAuth2AccessToken result = new DefaultOAuth2AccessToken(accessToken); Map<String, Object> info = new LinkedHashMap<String, Object>(accessToken.getAdditionalInformation()); String tokenId = result.getValue(); if (!info.containsKey(TOKEN_ID)) { info.put(TOKEN_ID, tokenId); } else { tokenId = (String) info.get(TOKEN_ID); } // access_token 包含自動重新整理過期token需要的資料(client_id/secret/refresh_token) Map<String, Object> details = (Map<String, Object>) authentication.getUserAuthentication().getDetails(); if (!Objects.isNull(details) && details.size() > 0) { info.put(OauthConstant.OAUTH_CLIENT_ID, details.getOrDefault("client_id", details.get(OauthConstant.OAUTH_CLIENT_ID))); info.put(OauthConstant.OAUTH_CLIENT_SECRET, details.getOrDefault("client_secret", details.get(OauthConstant.OAUTH_CLIENT_SECRET))); } OAuth2RefreshToken refreshToken = result.getRefreshToken(); if (refreshToken != null) { DefaultOAuth2AccessToken encodedRefreshToken = new DefaultOAuth2AccessToken(accessToken); encodedRefreshToken.setValue(refreshToken.getValue()); // Refresh tokens do not expire unless explicitly of the right type encodedRefreshToken.setExpiration(null); try { Map<String, Object> claims = objectMapper .parseMap(JwtHelper.decode(refreshToken.getValue()).getClaims()); if (claims.containsKey(TOKEN_ID)) { encodedRefreshToken.setValue(claims.get(TOKEN_ID).toString()); } } catch (IllegalArgumentException e) { } Map<String, Object> refreshTokenInfo = new LinkedHashMap<String, Object>( accessToken.getAdditionalInformation()); refreshTokenInfo.put(TOKEN_ID, encodedRefreshToken.getValue()); // refresh token包含client id/secret, 自動重新整理過期token時用到。 if (!Objects.isNull(details) && details.size() > 0) { refreshTokenInfo.put(OauthConstant.OAUTH_CLIENT_ID, details.getOrDefault("client_id", details.get(OauthConstant.OAUTH_CLIENT_ID))); refreshTokenInfo.put(OauthConstant.OAUTH_CLIENT_SECRET, details.getOrDefault("client_secret", details.get(OauthConstant.OAUTH_CLIENT_SECRET))); } refreshTokenInfo.put(ACCESS_TOKEN_ID, tokenId); encodedRefreshToken.setAdditionalInformation(refreshTokenInfo); DefaultOAuth2RefreshToken token = new DefaultOAuth2RefreshToken( encode(encodedRefreshToken, authentication)); if (refreshToken instanceof ExpiringOAuth2RefreshToken) { Date expiration = ((ExpiringOAuth2RefreshToken) refreshToken).getExpiration(); encodedRefreshToken.setExpiration(expiration); token = new DefaultExpiringOAuth2RefreshToken(encode(encodedRefreshToken, authentication), expiration); } result.setRefreshToken(token); info.put(OauthConstant.OAUTH_REFRESH_TOKEN, token.getValue()); } result.setAdditionalInformation(info); result.setValue(encode(result, authentication)); return result; } }
資訊準備好了,就要開始處理重新整理。就是改寫DefaultTokenServices的loadAuthentication方法。
OauthTokenServices.java
public class OauthTokenServices extends DefaultTokenServices { private static final Logger logger = LoggerFactory.getLogger(OauthTokenServices.class); private TokenStore tokenStore; // 自定義的token重新整理處理器 private TokenRefreshExecutor executor; public OauthTokenServices(TokenStore tokenStore, TokenRefreshExecutor executor) { super.setTokenStore(tokenStore); this.tokenStore = tokenStore; this.executor = executor; } @Override public OAuth2Authentication loadAuthentication(String accessTokenValue) throws AuthenticationException, InvalidTokenException { OAuth2AccessToken accessToken = tokenStore.readAccessToken(accessTokenValue); executor.setAccessToken(accessToken); // 是否重新整理token if (executor.shouldRefresh()) { try { logger.info("refresh token."); String newAccessTokenValue = executor.refresh(); // token如果是續期不做remove操作,如果是重新生成則刪除舊的token if (!newAccessTokenValue.equals(accessTokenValue)) { tokenStore.removeAccessToken(accessToken); } accessTokenValue = newAccessTokenValue; } catch (Exception e) { logger.error("token refresh failed.", e); } } return super.loadAuthentication(accessTokenValue); } }
類裡面的TokenRefreshExecutor就是我們的重點。這個類定義了兩個比較重要的介面。
shouldRefresh:是否需要重新整理
refresh:重新整理
TokenRefreshExecutor.java
public interface TokenRefreshExecutor { /** * 執行重新整理 * @return * @throws Exception */ String refresh() throws Exception; /** * 是否需要重新整理 * @return */ boolean shouldRefresh(); void setTokenStore(TokenStore tokenStore); void setAccessToken(OAuth2AccessToken accessToken); void setClientService(ClientDetailsService clientService); }
然後我們來看看jwt重新整理器,
OauthJwtTokenRefreshExecutor.java
public class OauthJwtTokenRefreshExecutor extends AbstractTokenRefreshExecutor { private static final Logger logger = LoggerFactory.getLogger(OauthJwtTokenRefreshExecutor.class); @Override public boolean shouldRefresh() { // 舊token過期才重新整理 return getAccessToken() != null && getAccessToken().isExpired(); } @Override public String refresh() throws Exception{ HttpServletRequest request = ServletUtil.getRequest(); HttpServletResponse response = ServletUtil.getResponse(); MultiValueMap<String, Object> parameters = new LinkedMultiValueMap<>(); // OauthJwtAccessTokenConverter中存入access_token中的資料,在這裡使用 parameters.add("client_id", TokenUtil.getStringInfo(getAccessToken(), OauthConstant.OAUTH_CLIENT_ID)); parameters.add("client_secret", TokenUtil.getStringInfo(getAccessToken(), OauthConstant.OAUTH_CLIENT_SECRET)); parameters.add("refresh_token", TokenUtil.getStringInfo(getAccessToken(), OauthConstant.OAUTH_REFRESH_TOKEN)); parameters.add("grant_type", "refresh_token"); // 傳送重新整理的http請求 Map result = RestfulUtil.post(getOauthTokenUrl(request), parameters); if (Objects.isNull(result) || result.size() <= 0 || !result.containsKey("access_token")) { throw new IllegalStateException("refresh token failed."); } String accessToken = result.get("access_token").toString(); OAuth2AccessToken oAuth2AccessToken = getTokenStore().readAccessToken(accessToken); OAuth2Authentication auth2Authentication = getTokenStore().readAuthentication(oAuth2AccessToken); // 儲存授權資訊,以便全域性呼叫 SecurityContextHolder.getContext().setAuthentication(auth2Authentication); // 前端收到該event事件時,更新access_token response.setHeader("event", "token-refreshed"); response.setHeader("access_token", accessToken); // 返回新的token資訊 return accessToken; } private String getOauthTokenUrl(HttpServletRequest request) { return String.format("%s://%s:%s%s%s", request.getScheme(), request.getLocalAddr(), request.getLocalPort(), Strings.isNotBlank(request.getContextPath()) ? "/" + request.getContextPath() : "", "/oauth/token"); } }
類寫完了,開始使用。
@Configuration public class TokenConfig { @Bean public TokenStore tokenStore(AccessTokenConverter converter) { return new JwtTokenStore((JwtAccessTokenConverter) converter); // return new InMemoryTokenStore(); } @Bean public AccessTokenConverter accessTokenConverter(SecurityUserService userService) { JwtAccessTokenConverter accessTokenConverter = new OauthJwtAccessTokenConverter(userService); accessTokenConverter.setSigningKey("sign_key"); return accessTokenConverter; /*DefaultAccessTokenConverter converter = new DefaultAccessTokenConverter(); DefaultUserAuthenticationConverter userTokenConverter = new DefaultUserAuthenticationConverter(); userTokenConverter.setUserDetailsService(userService); converter.setUserTokenConverter(userTokenConverter); return converter;*/ } @Bean public TokenRefreshExecutor tokenRefreshExecutor(TokenStore tokenStore, ClientDetailsService clientService) { TokenRefreshExecutor executor = new OauthJwtTokenRefreshExecutor(); // TokenRefreshExecutor executor = new OauthTokenRefreshExecutor(); executor.setTokenStore(tokenStore); executor.setClientService(clientService); return executor; } @Bean public AuthorizationServerTokenServices tokenServices(TokenStore tokenstore, AccessTokenConverter accessTokenConverter, ClientDetailsService clientService, TokenRefreshExecutor executor) { OauthTokenServices tokenServices = new OauthTokenServices(tokenstore, executor); // 非jwtConverter可註釋setTokenEnhancer tokenServices.setTokenEnhancer((TokenEnhancer) accessTokenConverter); tokenServices.setSupportRefreshToken(true); tokenServices.setClientDetailsService(clientService); tokenServices.setReuseRefreshToken(true); return tokenServices; } }
然後是認證伺服器相關程式碼
@Configuration @EnableAuthorizationServer public class AuthorizationServerConfiguration extends AuthorizationServerConfigurerAdapter { @Autowired private AuthenticationManager manager; @Autowired private SecurityUserService userService; @Autowired private TokenStore tokenStore; @Autowired private AccessTokenConverter tokenConverter; @Autowired private AuthorizationServerTokenServices tokenServices; @Override public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception { endpoints.tokenStore(tokenStore) .authenticationManager(manager) .allowedTokenEndpointRequestMethods(HttpMethod.GET, HttpMethod.POST) .userDetailsService(userService) .accessTokenConverter(tokenConverter) .tokenServices(tokenServices); } @Override public void configure(AuthorizationServerSecurityConfigurer security) throws Exception { security.tokenKeyAccess("permitAll()") //url:/oauth/token_key,exposes public key for token verification if using JWT tokens .checkTokenAccess("isAuthenticated()") //url:/oauth/check_token allow check token .allowFormAuthenticationForClients(); } @Override public void configure(ClientDetailsServiceConfigurer clients) throws Exception { clients.withClientDetails(clientDetailsService()); } public ClientDetailsService clientDetailsService() { return new OauthClientService(); } }
接著是前端處理, 用的axios。
service.interceptors.response.use(res => { // 快取自動重新整理生成的新token if (res.headers['event'] && "token-refreshed" === res.headers['event']) { setToken(res.headers['access_token']) store.commit('SET_TOKEN', res.headers['access_token']) } // 忽略部分程式碼 }
這樣就做到了jwt無感重新整理。
講完了jwt的token重新整理,多嘴說說memory token的重新整理。
上面講了,memory token重新整理策略比較簡單,每次請求過來直接給token延期即可。
OauthTokenRefreshExecutor.java
public class OauthTokenRefreshExecutor extends AbstractTokenRefreshExecutor { private int accessTokenValiditySeconds = 60 * 60 * 12; @Override public boolean shouldRefresh() { // 與jwt不同,因為每次請求都需要延長token失效時間,所以這裡是token未過期時就需要重新整理 return getAccessToken() != null && !getAccessToken().isExpired(); } @Override public String refresh() { int seconds; if (getAccessToken() instanceof DefaultOAuth2AccessToken) { // 獲取client中的過期時間, 沒有則預設12小時 if (getClientService() != null) { OAuth2Authentication auth2Authentication = getTokenStore().readAuthentication(getAccessToken()); String clientId = auth2Authentication.getOAuth2Request().getClientId(); ClientDetails client = getClientService().loadClientByClientId(clientId); seconds = client.getAccessTokenValiditySeconds(); } else { seconds = accessTokenValiditySeconds; } // 只修改token失效時間 ((DefaultOAuth2AccessToken) getAccessToken()).setExpiration(new Date(System.currentTimeMillis() + (seconds * 1000l))); } // 返回的還是舊的token return getAccessToken().getValue(); } }
然後修改TokenConfig相關bean註冊即可。
好了,Token重新整理這塊差不多就這樣了。Token登出暫時沒有好的思路。
如果Token重新整理有更好的方案可以告知,也歡迎分享Token登出方案。