Pig 适配 OAuth 2.0 客户端模式

为什么使用 OAuth 客户端模式

pig 框架内部服务间免鉴权采用的是 @Inner 注解 + FROM_IN header 的方式:

  • 外部请求携带 token 经过 Gateway ,token 逐层传递
  • 外部请求未携带 token (用户注册等)经过 Gateway ,通过security.oauth2.client.ignore-urls配置跳过鉴权
  • 内部请求携带 token 通过 feign 访问 ,token 逐层传递
  • 内部请求未携带 token(定时任务等),通过@RequestHeader(SecurityConstants.FROM),在过滤器中跳过验证携带特定 header 且使用了 @Inner (vlaue=true)注解的接口请求

在 Spring 体系下的服务可以兼容这套流程,但是其他语言框架下的服务如果想通过 OAuth 鉴权(如原子能力获取用户信息)的话,就需要重复造轮子以适配最后一种场景的逻辑

微服务内部之间流量安全的情况下,免鉴权可以解决大部分场景需求。但是基于零信任原则,每个服务的接口安全性不应该依赖于内部网络配置,每次微服务通信都应该经过身份验证

每个服务单独配置的免鉴权接口安全性难以保证,后期不易维护,增加开发人员心智负担

Inner注解

Feign

什么是 OAuth 客户端模式

1
2
3
4
5
6
7
+---------+                                  +---------------+
| | | |
| |>--(A)- Client Authentication --->| Authorization |
| Client | | Server |
| |<--(B)---- Access Token ---------<| |
| | | |
+---------+ +---------------+

当客户端请求访问它所控制的,或者事先与授权服务器协商(所采用的方法超出了本规范的范围)的其他资源所有者的受保护资源,客户端可以只使用它的客户端凭据(或者其他受支持的身份验证方法)请求访问令牌。

https://datatracker.ietf.org/doc/html/rfc6749#section-4.4

客户端模式可以解决的问题

  • 定时任务、异步请求等方法跨服务调用时的 token 签发
  • 异构技术框架下的微服务鉴权统一入口(gateway),无需单独配置 api-key 或者各自实现鉴权逻辑
  • 开发新服务接口时无需要考虑权限,由业务调用来源(用户 or 服务端)决定 token 类型
  • 避免伪造 header 之类的攻击,增强系统安全性
  • 解决下游原子服务之间(可能存在)的鉴权需求

客户端模式存在的风险

  • 请求发起方和接收方都需要通过 OAuth 鉴权,会出现单点故障
  • 由于鉴权增加的网络开销,网络请求会变成 2n 次
  • 客户端模式签发的 token 不包含 refresh_token,需要根据需求自动延期

启用客户端模式

Client Credentials Token 签发

  1. 确认sys_oauthclient_details表中 client ogsp 对应的authorized_grant_types(授权类型)字段中包含client_credentials

pig 客户端模式使用

  1. AuthorizationServerConfiguration 中添加 Spring 官方的客户端模式转换器 OAuth2ClientCredentialsAuthenticationConverter()
1
2
3
4
5
6
7
8
private AuthenticationConverter accessTokenRequestConverter() {  
return new DelegatingAuthenticationConverter(
Arrays.asList(
new OAuth2ClientCredentialsAuthenticationConverter(),
new OAuth2ResourceOwnerPasswordAuthenticationConverter(),
new OAuth2ResourceOwnerSmsAuthenticationConverter(),
new OAuth2AuthorizationCodeRequestAuthenticationConverter()));
}
  1. 通过客户端模式申请 token
1
2
3
4
curl --location --request POST 'http://127.0.0.1:3000/oauth2/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--header 'Authorization: Basic dGVzdDp0ZXN0' \
--data-urlencode 'grant_type=client_credentials'
  • 客户端模式申请 token 时通过 form 传参,pig 密码鉴权通过 url 传参的方式不符合 rfc 标准

https://gitee.com/log4j/pig/issues/I9FUG9

  • Basic 认证时使用sys_oauth_client_details表中配置的client_idclient_secret作为身份认证

  • scope 参数作为 token 的权限范围,暂未使用

  1. 使用获取到的 access_token 继续调用其他服务
1
2
3
4
5
6
{
"access_token": "cb5132d5-1815-4ada-9998-803d4148cdfb",
"scope": "server",
"token_type": "Bearer",
"expires_in": "43199"
}

Feign token 拦截替换

  1. 添加 oauth2-client 依赖
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
  1. 添加 Spring Security 客户端信息配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
spring:
security:
oauth2:
client:
registration:
ogsp:
provider: spring
client-id: ogsp
client-secret: ogsp
scope: server
authorization-grant-type: client_credentials
provider:
spring:
token-uri: http://127.0.0.1:9999/auth/oauth2/token
  1. 注册客户端模式接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
private final OAuth2AuthorizedClientService oAuth2AuthorizedClientService;
private final ClientRegistrationRepository clientRegistrationRepository;

@Bean
public OAuth2AuthorizedClientManager authorizedClientManager() {
OAuth2AuthorizedClientProvider authorizedClientProvider = OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build();

AuthorizedClientServiceOAuth2AuthorizedClientManager authorizedClientManager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(clientRegistrationRepository, oAuth2AuthorizedClientService);
authorizedClientManager.setAuthorizedClientProvider(authorizedClientProvider);
return authorizedClientManager;
}
  1. 注册 Feign 拦截器替换 token
1
2
3
4
@Bean
public RequestInterceptor oauthFeignRequestInterceptor(OAuth2AuthorizedClientManager authorizedClientManager) {
return new OgspFeignRequestClientCredentialsInterceptor(authorizedClientManager);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@RequiredArgsConstructor
public class OgspFeignRequestClientCredentialsInterceptor implements RequestInterceptor {

public static final String CLIENT_ID = "ogsp";

private final OAuth2AuthorizedClientManager authorizedClientManager;

@Override
public void apply(RequestTemplate template) {

Collection<String> fromHeader = template.headers().get(SecurityConstants.FROM);
// 客户端认证替换 Authorization
if (CollUtil.isNotEmpty(fromHeader) && fromHeader.contains(SecurityConstants.FROM_CLIENT)) {
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest.withClientRegistrationId(CLIENT_ID)
.principal(CLIENT_ID)
.build();

OAuth2AuthorizedClient authorizedClient = this.authorizedClientManager.authorize(authorizeRequest);

if (authorizedClient != null && authorizedClient.getAccessToken() != null) {
template.header("Authorization", "Bearer " + authorizedClient.getAccessToken().getTokenValue());
}
}
}
}
1
2
3
4
5
// 带from 请求直接跳过
if (CollUtil.isNotEmpty(fromHeader) && (fromHeader.contains(SecurityConstants.FROM_IN) || fromHeader.contains(
SecurityConstants.FROM_CLIENT))) {
return;
}
  • 继承原 pig 使用 from header 判断请求来源的方式

  • from=C 表示客户端请求

  • 通过 OAuth 获取客户端模式 token 替换请求 token

  1. 后台请求调用 Feign 接口时通过SecurityConstants.FROM参数传递 token
1
R<SysLDAPEntry> entryR = remoteLDAPService.getLDAPAuth(sysLDAPEntry, SecurityConstants.FROM_CLIENT);

其他兼容调整

由于项目中使用 Redis 存储 token,而 Spring Security Oauth2 客户端的实现是使用 InMemoryOAuth2AuthorizedClientService 从缓存中获取 token 复用,这样的话如果误删 Redis 中的 token,会有内存和 Redis 中 token 状态不一致而导致内存中获取的 token 短期失效的问题。

具体定位到复用的代码在 ClientCredentialsOAuth2AuthorizedClientProvider 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@Override
@Nullable
public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) {
Assert.notNull(context, "context cannot be null");
ClientRegistration clientRegistration = context.getClientRegistration();
if (!AuthorizationGrantType.CLIENT_CREDENTIALS.equals(clientRegistration.getAuthorizationGrantType())) {
return null;
}
OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient();
if (authorizedClient != null && !hasTokenExpired(authorizedClient.getAccessToken())) {
// If client is already authorized but access token is NOT expired than no
// need for re-authorization
return null;
}
// As per spec, in section 4.4.3 Access Token Response
// https://tools.ietf.org/html/rfc6749#section-4.4.3
// A refresh token SHOULD NOT be included.
//
// Therefore, renewing an expired access token (re-authorization)
// is the same as acquiring a new access token (authorization).
OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
clientRegistration);
OAuth2AccessTokenResponse tokenResponse = getTokenResponse(clientRegistration, clientCredentialsGrantRequest);
return new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(),
tokenResponse.getAccessToken());
}

这里的 hasTokenExpired() 方法会判断如果内存中获取的 client token 在有效期内则直接返回继续使用。

所以尝试重写这个 ClientCredentialsOAuth2AuthorizedClientProvider,在判断有效期之后再确认一遍 Redis 中是否存在(具体实现在 findByToken 中):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@Slf4j  
@RequiredArgsConstructor
public class OgspRedisOAuth2AuthorizedClientProvider implements OAuth2AuthorizedClientProvider {
private final OgspRedisOAuth2AuthorizationService authorizationService;
private final OAuth2AccessTokenResponseClient<OAuth2ClientCredentialsGrantRequest> accessTokenResponseClient = new DefaultClientCredentialsTokenResponseClient();

@Override
@Nullable public OAuth2AuthorizedClient authorize(OAuth2AuthorizationContext context) {
Assert.notNull(context, "context cannot be null");
ClientRegistration clientRegistration = context.getClientRegistration();

if (!AuthorizationGrantType.CLIENT_CREDENTIALS.equals(clientRegistration.getAuthorizationGrantType())) {
return null;
}

OAuth2AuthorizedClient authorizedClient = context.getAuthorizedClient();
if (authorizedClient != null && !hasTokenExpired(authorizedClient)) {
// 缓存 token 未过期则直接返回
return authorizedClient;
}

OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest = new OAuth2ClientCredentialsGrantRequest(
clientRegistration);
OAuth2AccessTokenResponse tokenResponse = getTokenResponse(clientRegistration, clientCredentialsGrantRequest);
return new OAuth2AuthorizedClient(clientRegistration, context.getPrincipal().getName(),
tokenResponse.getAccessToken());
}

private OAuth2AccessTokenResponse getTokenResponse(ClientRegistration clientRegistration,
OAuth2ClientCredentialsGrantRequest clientCredentialsGrantRequest) {
try {
return this.accessTokenResponseClient.getTokenResponse(clientCredentialsGrantRequest);
}
catch (OAuth2AuthorizationException ex) {
throw new ClientAuthorizationException(ex.getError(), clientRegistration.getRegistrationId(), ex);
}
}

private boolean hasTokenExpired(OAuth2AuthorizedClient authorizedClient) {
OAuth2AccessToken accessToken = authorizedClient.getAccessToken();
if (accessToken == null) {
return true;
}

// 检查内存中 token 过期时间
Instant expiresAt = accessToken.getExpiresAt();
if (expiresAt != null && expiresAt.isAfter(Instant.now())) {
// 检查 Redis 防止状态不同步
OAuth2Authorization authorization = authorizationService.findByToken(accessToken.getTokenValue(), OAuth2TokenType.ACCESS_TOKEN);
// token 有效
if (authorization != null) {
return false;
}
log.debug("Token {} not found in Redis, treating as expired", accessToken.getTokenValue());
}
return true;
}
}