Liununu's blog

来去不同

跟踪分析从 Gateway 到 UAA

项目工程的新需求需要使用 Token 进行校验,记录下新知识。

00. 使用 Postman 模拟请求

抓取 Gateway 登录请求

主要携带了如下信息:

  1. 请求 Headers:
Key Value
Authorization Basic d2ViX2FwcDo=
Content-Type application/x-www-form-urlencoded

“Basic d2ViX2FwcDo=” 属于 HTTP 基本认证的一种登录方式。其中 “d2ViX2FwcDo=” 是由用户名追加一个冒号然后串接上密码,再通过 Base64 算法编码。

编码的目的并不是安全与隐私,而是为将用户名和密码中的不兼容的字符转换为均与 HTTP 协议兼容的字符集。

  1. 请求 Body:
Key Value
username admin
password admin
grant_type password
sysId 1cab27def8fb4c0f9486dcf844b783c0

请求返回结果

1
2
3
4
5
6
7
8
9
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzeXNJZCI6IjFjYWIyN2RlZjhmYjRjMGY5NDg2ZGNmODQ0Yjc4M2MwIiwidXNlcl9uYW1lIjoiYWRtaW4iLCJzY29wZSI6WyJvcGVuaWQiXSwiZXhwIjoxNTM2Nzc4Mzk3LCJhdXRob3JpdGllcyI6WyJST0xFX0FETUlOIl0sImp0aSI6IjFlZGZkZmUxLTdlYjEtNDM0Zi1hMDZjLWQ5YzIyNGRiYTdhNSIsImNsaWVudF9pZCI6IndlYl9hcHAifQ.N9NdmunBPiXc3dHYLofiNGVMPWrNhj6_KxaWI1Y_pIe_daoVMWiyoS-kZf0dgvbdueTxVPpjfh3Vq9Xtg6md6K05wiDVlVqZjH-T1E-6pSFfdhDG-lkPJCK9x9yVxL3Q1X6Xua1YOo_PoJJZ1_4-E3MEOoqbuY0airrjUQB2TtgVQSTpT7U0wkyQVPZo6InrQMlqg0ItDp3D97nUxCdAOKQxIIkftexX1VAUqPzB0ZTnz-DBiHYMnvXYLFg_UIBjmoCVwv3qBTfjofOFFz4F0QLmopb8RpcVwrMB845ifNjwInW4ZcIZeSafPs-x8jhzES9o_9QIa8_ojrDNyppHXQ",
"token_type": "bearer",
"refresh_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX25hbWUiOiJhZG1pbiIsInNjb3BlIjpbIm9wZW5pZCJdLCJhdGkiOiIxZWRmZGZlMS03ZWIxLTQzNGYtYTA2Yy1kOWMyMjRkYmE3YTUiLCJleHAiOjE1MzkzMjcxOTcsImF1dGhvcml0aWVzIjpbIlJPTEVfQURNSU4iXSwianRpIjoiMzI4ZmJmOWQtNTA1OC00NjIzLWFlMjgtZTQ4ZmJlN2UzYWI1IiwiY2xpZW50X2lkIjoid2ViX2FwcCJ9.dwMyX8wjEKdwn8owcxvNp353NmvTzbyZUhv4GJC2wr-ZY8io41OSx4s2JzRpalXs9O-raY48hUG0ixPGG_m3nRqCy77-ZDFSKlwkMsQJl64c2DHzJhqlkqUw9YgO1_atkTM7w216r23DrIqsn4kukjOKtImpuwU_nVd6s8_ixG2GNhvSxgZHozrwrRFVAMGpQtLXPkyW65VVXl190DxHYehDZwodvbMhqc-fUZuCUUZmlrP30hOyQtMx76GF85a-f0MIMg9AjGiymJM0Fv0N6djR1JnS1kfhjNs41V0yWv6TJfCdmnIXeDiEx7XKIxGOk9qDxaU4DciImS8z926DKw",
"expires_in": 43197,
"scope": "openid",
"sysId": "1cab27def8fb4c0f9486dcf844b783c0",
"jti": "1edfdfe1-7eb1-434f-a06c-d9c224dba7a5"
}

access_token:表示访问令牌

token_type:表示令牌类型

refresh_token:表示更新令牌,用来获取下一次的访问令牌

expires_in:表示过期时间,单位为秒

scope:表示权限范围

sysId:表示登入系统的唯一标识

jti:JWT ID,表示当前 Token 的唯一标识

01. 分析 Gateway

上述请求地址为 http://localhost:8080/uaa/oauth/token,请求需要经过 Gateway 转发到 UAA 的暴露接口 /oauth/token。

AccessControlFilter.java

请求经由 AccessControlFilter 过滤器,该过滤器在 shouldFilter 方法中获取请求地址等信息。将请求地址与配置文件中的白名单进行对比。因为接口 /oauth/token 符合白名单,所以通过此过滤器。

TokenExpirationFilter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
public Object run() {
Object accessToken = RequestContext.getCurrentContext().get(Constants.ACCESS_TOKEN);
if(accessToken!=null){
String accessTokenValue = accessToken.toString();
long currentOptTime = System.currentTimeMillis();
/* 第一次访问或者操作时间间隔超过一分钟时,更新当前操作时间 */
if(operationTimeMap.get(accessTokenValue)==null || (currentOptTime-operationTimeMap.get(accessTokenValue)>60*1000)){
operationTimeMap.put(accessTokenValue, currentOptTime);
tokenStore.updateTokenOperationTime(accessTokenValue, currentOptTime);
}
}
return null;
}

因为正在获取 Token,所以 “accessToken” 的值为 null,不记录操作时间。之后发送请求到 UAA 处。

02. 分析 UAA

AbstractAuthenticationProcessingFilter.java

根据 参靠链接 (3) 的文章介绍,该过滤器是验证请求路径是否为 /oauth/token,但在跟踪中发现 UAA 工程中并没有通过该过滤器。后面发现是因为在 UaaConfiguration.java 中未配置 allowFormAuthenticationForClients。

1
2
3
4
@Override
public void configure(AuthorizationServerSecurityConfigurer oauthServer) throws Exception {
oauthServer.tokenKeyAccess("permitAll()").checkTokenAccess("isAuthenticated()");
}

配置支持 allowFormAuthenticationForClients 且 url 中有 client_id 和 client_secret 的才会经由 AbstractAuthenticationProcessingFilter

配置不支持 allowFormAuthenticationForClients 或 url 中没有 client_id 和 client_secret 的通过 Basic 认证保护

BasicAuthenticationFilter.java

通过该过滤器时,在 doFilterInternal 方法中提取请求头,并判断是否存在或以 “Basic” 字符串开头。调用 extractAndDecodeHeader 方法,截取出子串 “d2ViX2FwcDo=” 并解码出字符串 “web_app:”,返回字符串数组。

tokens[0]:表示用户名

tokens[1]:表示密码

将字符串数组传入 UsernamePasswordAuthenticationToken 进行认证,成功则填充 SecurityContextHolder 的 Authentication。

TokenEndpoint.java

1
2
3
4
5
6
7
8
9
private Set<HttpMethod> allowedRequestMethods = new HashSet<HttpMethod>(Arrays.asList(HttpMethod.POST));
...
@RequestMapping(value = "/oauth/token", method=RequestMethod.GET)
public ResponseEntity<OAuth2AccessToken> getAccessToken(Principal principal, @RequestParam Map<String, String> parameters) throws HttpRequestMethodNotSupportedException {
if (!allowedRequestMethods.contains(HttpMethod.GET)) {
throw new HttpRequestMethodNotSupportedException("GET");
}
return postAccessToken(principal, parameters);
}

根据定义,仅支持 POST 请求方式,遇到 GET 请求时,直接抛出异常。

1
2
3
4
String clientId = getClientId(principal);
ClientDetails authenticatedClient = getClientDetailsService().loadClientByClientId(clientId);

TokenRequest tokenRequest = getOAuth2RequestFactory().createTokenRequest(parameters, authenticatedClient);

根据 clientId 获取 ClientDetails 对象实例,再根据 parameters(请求体内容) 和 authenticatedClient 获取 TokenRequest 对象实例。

1
OAuth2AccessToken token = getTokenGranter().grant(tokenRequest.getGrantType(), tokenRequest);

调用 AbstractTokenGranter 类的 grant 方法,生成 OAuth2AccessToken 对象,即请求需要返回的 Token 包装类。

AbstractTokenGranter.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public OAuth2AccessToken grant(String grantType, TokenRequest tokenRequest) {

if (!this.grantType.equals(grantType)) {
return null;
}

String clientId = tokenRequest.getClientId();
ClientDetails client = clientDetailsService.loadClientByClientId(clientId);
validateGrantType(grantType, client);

logger.debug("Getting access token for:" + clientId);

return getAccessToken(client, tokenRequest);

}

protected OAuth2AccessToken getAccessToken(ClientDetails client, TokenRequest tokenRequest) {
return tokenServices.createAccessToken(getOAuth2Authentication(client, tokenRequest));
}

提取 clientId 加载 ClientDetails 实例,进行验证,通过后调用 ResourceOwnerPasswordTokenGranter 类的 getOAuth2Authentication 方法并将返回值作为参数传递给 DefaultTokenServices 的 createAccessToken 方法。

ResourceOwnerPasswordTokenGranter.java

从 tokenRequest 中提取出请求体,将用户名和密码放入 LinkedHashMap 中。并且为了防止下游泄露密码,赋值后移除密码。在校验用户名和密码的正确性后,重新包装 OAuth2Request 实例并返回。

DefaultTokenServices.java

1
2
3
4
5
6
7
8
9
10
11
12
13
if (existingAccessToken != null) {
if (!existingAccessToken.isExpired()) {
this.tokenStore.storeAccessToken(existingAccessToken, authentication);
return existingAccessToken;
}

if (existingAccessToken.getRefreshToken() != null) {
refreshToken = existingAccessToken.getRefreshToken();
this.tokenStore.removeRefreshToken(refreshToken);
}

this.tokenStore.removeAccessToken(existingAccessToken);
}

进入 createAccessToken 方法后,先从 tokenStore 中取出已有 token。若存在,则依次判断是否过期,是否有 refreshToken 等。不存在时,生成新 token 并存入 tokenStore 中。

最后将 token 放入响应报文中。

03. 参考链接

  1. HTTP 基本认证 - 维基百科,自由的百科全书

  2. 理解 OAuth 2.0 - 阮一峰的网络日志

  3. Spring Security Oauth2 认证(获取 token / 刷新 token)流程(password 模式) - CSDN 博客

  4. 聊聊 spring security oauth2 的几个 endpoint 的认证 - 简书