spring oauth2+JWT後端自動重新整理access_token

Braska。發表於2020-07-24

這段時間在學習搭建基於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登出方案。

 

相關文章