在spring boot中结合OAuth2使用JWT时,刷新token时refresh token一直变化的原因

在spring boot中结合OAuth2使用JWT时,客户端通过 passwordauthorization_code 等方式获取 access tokenrefresh token,并通过 refresh token 来进行续约。但当客户端刷新token时,我们发现认证服务总是返回新的refresh token,这是什么原因呢?

一、场景展现

1. 获取token

curl -u barClientIdPassword:secret http://localhost:8081/oauth/token -d grant_type=password -d username=user -d password=password -d scope=bar+read

返回的结果如下:

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyLXJlc291cmNlIl0sImNyZWF0ZV90aW1lIjoxNTEwNDE2MDAwMDAwLCJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYmFyIiwicmVhZCJdLCJleHAiOjE1MTA1OTM2MjUsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiI2NGM0ZDQzYi0zNmNjLTQ1ZTQtOGViMy1iMGM1MmY0M2U0MTQiLCJjbGllbnRfaWQiOiJiYXJDbGllbnRJZFBhc3N3b3JkIn0.E0aQP27ccVrABvukElcYrcp5gpTEED2YLxNn1P_bkdIQOzdo3BEb30s0WP3cUEOrz56VHYmQ0_hC9xD1seF5zDk9zt1DJBvNsx-czZ3Rprg_v6l5MosoljZzhQjMX2LUVRW5WBX9sHF348yL3WZzpofgycxDdPOYgiDdxTRmCBJLYq-Jed5vIF94OVGbFrwHeXHWPqp7IKDS33uaSu1ISnXSJPHze5KPW237R83DmLCihG14GqNF4c6W9db4ODPLCUavXUUQGxN6YwM0FOQJKxRupj61HsTePYNIKdd2D0O6orUvxx2o-op-U2_ImmTBYDOElM2Raep3CnmBhofvNg",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyLXJlc291cmNlIl0sImNyZWF0ZV90aW1lIjoxNTEwNDE2MDAwMDAwLCJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYmFyIiwicmVhZCJdLCJhdGkiOiI2NGM0ZDQzYi0zNmNjLTQ1ZTQtOGViMy1iMGM1MmY0M2U0MTQiLCJleHAiOjE1MTA1OTM2MjMsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiI5ZTE1MDhjMy1mMDcxLTRiZmMtODQ4ZC0xNWIxMzRmZjZiYmYiLCJjbGllbnRfaWQiOiJiYXJDbGllbnRJZFBhc3N3b3JkIn0.bpHO-2xx322dE5cZvdrEpw2L1LXMNE1hnC8wr3dA_cwaHhuHe9MTaJRS_itfTTIQIEnxQAYnZIj60C9fTUhB166n9bm996-b10zWREqqM-tgrU_RurG2bwqLawx6OheJms_sK1-vMneT1EqhZ54GXhtm5ulxuINjVxxYNnkJtRF2dMJPk4vSd6ay-XhSmxaEabqePN9RR5i15PfcF8apLn4ENY1TeVOGLecWle5c5AXL98iKiiE8AtuWWJ1WNWQ0CdtMzdBEf__sQ_T4dU6VE6G7aHG0s_l_fE7sbgzhOUMq39so51FiF0IUk3B83q6MnPpEALwj0BQUWRMqEdy88w",
    "expires_in": 35690,
    "scope": "bar read",
    "create_time": 1510416000000,
    "jti": "64c4d43b-36cc-45e4-8eb3-b0c52f43e414"
}

其中:

  • create_time 为自定义属性
  • access_tokenrefresh_token 是经过签名并Base64编码后的一个字符串。

我们来看下 refresh token 在编码前的结构,它包含的内容大致如下:

{
    aud:[oauth2-resource],  // token所对应的resourceid
    create_time:1510416000000, // 自定义的属性
    user_name:user, 
    scope:[bar, read], 
    ati:64c4d43b-36cc-45e4-8eb3-b0c52f43e414, // access token id
    exp:1510593623, 
    authorities:[ROLE_USER], 
    jti:9e1508c3-f071-4bfc-848d-15b134ff6bbf, // token id
    client_id:barClientIdPassword
}

2. 刷新token

在第1步我们获取到了 refresh token,这时我们拿着 refresh token向认证服务器申请新的 access token

curl -u barClientIdPassword:secret http://localhost:8081/oauth/token -d grant_type=refresh_token -d refresh_token=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyLXJlc291cmNlIl0sImNyZWF0ZV90aW1lIjoxNTEwNDE2MDAwMDAwLCJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYmFyIiwicmVhZCJdLCJhdGkiOiI2NGM0ZDQzYi0zNmNjLTQ1ZTQtOGViMy1iMGM1MmY0M2U0MTQiLCJleHAiOjE1MTA1OTM2MjMsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiI5ZTE1MDhjMy1mMDcxLTRiZmMtODQ4ZC0xNWIxMzRmZjZiYmYiLCJjbGllbnRfaWQiOiJiYXJDbGllbnRJZFBhc3N3b3JkIn0.bpHO-2xx322dE5cZvdrEpw2L1LXMNE1hnC8wr3dA_cwaHhuHe9MTaJRS_itfTTIQIEnxQAYnZIj60C9fTUhB166n9bm996-b10zWREqqM-tgrU_RurG2bwqLawx6OheJms_sK1-vMneT1EqhZ54GXhtm5ulxuINjVxxYNnkJtRF2dMJPk4vSd6ay-XhSmxaEabqePN9RR5i15PfcF8apLn4ENY1TeVOGLecWle5c5AXL98iKiiE8AtuWWJ1WNWQ0CdtMzdBEf__sQ_T4dU6VE6G7aHG0s_l_fE7sbgzhOUMq39so51FiF0IUk3B83q6MnPpEALwj0BQUWRMqEdy88w

返回结果:

{
    "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyLXJlc291cmNlIl0sImNyZWF0ZV90aW1lIjoxNTEwNDE2MDAwMDAwLCJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYmFyIiwicmVhZCJdLCJleHAiOjE1MTA1OTM5OTMsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiI5ZmUwZjRmZC1iMDI4LTQxODktYjVlZS1lOWQxYjgzYzU3NDIiLCJjbGllbnRfaWQiOiJiYXJDbGllbnRJZFBhc3N3b3JkIn0.gWs0uXa20k-WcfBcXWmz4xbZ-_VtyxTcIxDHlvNm0ziB3vvh0BxaaV7wEqVXePgA10Hm5Z42J-wAIinx7BmuRIs58pm_5t7FqEA_4XTL1bcMkvJpLEqhH5q6VFnqUxp4URC0l4l7jG8JfdZaD4Xr0Y6M7Aviu5OlWRf4jS57SeVxt41hbZvoSQjoQL-4hxDsPyNkSuBcIL237p4HqvAON-1eU0o1OYlj2u2OE4hSs4pnutWTb_ooNo0JSvcm4y9xPDKTzPg0Gb0F9UWdYRNwTd_LtD10VgutDQUqTZ5R_2r-kzUGqyT2ynVoXLc8iXjZ26g7552L3-R6pUMsrCq9FQ",
    "token_type": "bearer",
    "refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJhdWQiOlsib2F1dGgyLXJlc291cmNlIl0sImNyZWF0ZV90aW1lIjoxNTEwNDE2MDAwMDAwLCJ1c2VyX25hbWUiOiJ1c2VyIiwic2NvcGUiOlsiYmFyIiwicmVhZCJdLCJhdGkiOiI5ZmUwZjRmZC1iMDI4LTQxODktYjVlZS1lOWQxYjgzYzU3NDIiLCJleHAiOjE1MTA1OTM2MjMsImF1dGhvcml0aWVzIjpbIlJPTEVfVVNFUiJdLCJqdGkiOiI5ZTE1MDhjMy1mMDcxLTRiZmMtODQ4ZC0xNWIxMzRmZjZiYmYiLCJjbGllbnRfaWQiOiJiYXJDbGllbnRJZFBhc3N3b3JkIn0.ZPLpF--gqunfjQTi4TrrP6Mw043U-dz9iuHwLOEVdjog1lmWmWVcMbvdDJnw2Vh_zBRAlJFEchUpySacQyyBmpqJTxTzOKoYYDkhHe7L-BpPbgNmucbIIVEwP9GnMWnAQb8t_kJCTJXc3h31j6lnTVqhcX-rVpON-L9gk-qvSIstwPN8B39ESOVQ6gb8LgkawRwtIP7fDiDoVon3bdWsI8CKZLj4dKwoXDj6MXDj-2IISbnNCM_E-H_b5TnFjB-gRX3cqwh3VkIdXc5o9048zVlxgRJxzXJgxhTzU0zZFVRPPzVA90gnrXuZBLLMJSPRDpCnhgSU8TaeRkedJLGt8g",
    "expires_in": 32300,
    "scope": "bar read",
    "create_time": 1510416000000,
    "jti": "9fe0f4fd-b028-4189-b5ee-e9d1b83c5742"
}

我们可以看到,返回结果中的 refresh token 串和请求时的refresh token发生了变化,这和我们预期的不一样,到底是什么原因呢?

二. 原因分析

2.1 代码分析

首先来看认证服务器的配置代码:

@EnableAuthorizationServer
@Configuration
public class OAuth2AuthorizationServerConfig extends AuthorizationServerConfigurerAdapter {

    @Autowired
    private DataSource dataSource;
    // 注入认证管理器
    @Autowired
    private AuthenticationManager authenticationManager;

    @Override
    public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
        // 使用jdbc存储客户端信息
        clients.withClientDetails(clientDetails());
    }

    @Override
    public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
        //指定认证管理器
        endpoints.authenticationManager(authenticationManager);
        //指定token存储位置
        endpoints.tokenStore(tokenStore());
        // 自定义token生成方式
        TokenEnhancerChain tokenEnhancerChain = new TokenEnhancerChain();
        tokenEnhancerChain.setTokenEnhancers(Arrays.asList(customerEnhancer(), accessTokenConverter()));
        endpoints.tokenEnhancer(tokenEnhancerChain);

        // 配置TokenServices参数
        DefaultTokenServices tokenServices = (DefaultTokenServices) endpoints.getDefaultAuthorizationServerTokenServices();
        tokenServices.setTokenStore(endpoints.getTokenStore());
        tokenServices.setSupportRefreshToken(true);
        // 复用refresh token
        tokenServices.setReuseRefreshToken(true); 
        tokenServices.setClientDetailsService(endpoints.getClientDetailsService());
tokenServices.setTokenEnhancer(endpoints.getTokenEnhancer());
        tokenServices.setAccessTokenValiditySeconds((int) TimeUnit.DAYS.toSeconds(1)); // 1天
        endpoints.tokenServices(tokenServices);

        super.configure(endpoints);
    }

    @Bean
    public JwtAccessTokenConverter accessTokenConverter() {
        final JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
        KeyStoreKeyFactory keyStoreKeyFactory =
                new KeyStoreKeyFactory(new ClassPathResource("mytest.jks"), "mypass".toCharArray());
        converter.setKeyPair(keyStoreKeyFactory.getKeyPair("mytest"));
        return converter;
    }

因为用到了TokenEnhancer和JwtAccessTokenConverter,顺藤摸瓜找到关键代码

关键代码一

org.springframework.security.oauth2.provider.token.DefaultTokenServices类:

    private OAuth2AccessToken createAccessToken(OAuth2Authentication authentication, OAuth2RefreshToken refreshToken) {
        DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString());
        int validitySeconds = getAccessTokenValiditySeconds(authentication.getOAuth2Request());
        if (validitySeconds > 0) {
            token.setExpiration(new Date(System.currentTimeMillis() + (validitySeconds * 1000L)));
        }
        token.setRefreshToken(refreshToken);
        token.setScope(authentication.getOAuth2Request().getScope());

        return accessTokenEnhancer != null ? accessTokenEnhancer.enhance(token, authentication) : token;
    }

关键代码二

org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter类:

    // 此方法在获取token或刷新token时都会被调用
    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);
        }
        result.setAdditionalInformation(info);
        result.setValue(encode(result, authentication));
        
        // 1. 在获取token时,取得的refreshToken时为服务端刚生成的原始的jti值(格式类似9e1508c3-f071-4bfc-848d-15b134ff6bbf)
        // 2. 在刷新token时,取得的refreshToken为客户端发过来的加密之后refresh token串
        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);
            
            // 假设前面取得的refreshToken为客户端发过来的加密后的token,尝试解密并从里面取原始的jti,这里有两种可能:
            // 1. 在获取token时,解密失败,抛出异常,所以refreshToken还是为原始的jti;
            // 2. 在刷新token时,解密成功,取得原始的jti
            try {
                Map<String, Object> claims = objectMapper
                        .parseMap(JwtHelper.decode(refreshToken.getValue()).getClaims());
                if (claims.containsKey(TOKEN_ID)) {
                    // 取得原始的jti,并赋值给encodedRefreshToken
                    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()); // 原始的token(jti)保持不变
            refreshTokenInfo.put(ACCESS_TOKEN_ID, tokenId); // 新的access token id(ati)
            
            encodedRefreshToken.setAdditionalInformation(refreshTokenInfo);
            DefaultOAuth2RefreshToken token = new DefaultOAuth2RefreshToken(
                    encode(encodedRefreshToken, authentication));
            if (refreshToken instanceof ExpiringOAuth2RefreshToken) {
                Date expiration = ((ExpiringOAuth2RefreshToken) refreshToken).getExpiration();
                encodedRefreshToken.setExpiration(expiration);
                // 加密生成新的refresh token串(其实里面的jti还是原来的值)
                token = new DefaultExpiringOAuth2RefreshToken(encode(encodedRefreshToken, authentication), expiration);
            }
            result.setRefreshToken(token);
        }
        return result;
    }

2.2 结论

刷新token时,token id(jti)其实是保持不变的,只是因为重新生成了access token(即ati),所以加密之后的refresh token串看起来和原来的不一样了,实际上里面的jti还是一样的。

此时我们可以发现,只要refresh token没有过期,不管是使用原来的refresh token还是新的refresh token去刷新token,都是可以的。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 213,992评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,212评论 3 388
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,535评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,197评论 1 287
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,310评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,383评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,409评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,191评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,621评论 1 306
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,910评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,084评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,763评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,403评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,083评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,318评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,946评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,967评论 2 351