在spring boot中结合OAuth2使用JWT时,客户端通过 password
或 authorization_code
等方式获取 access token
和 refresh 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_token
和refresh_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,都是可以的。