为什么使用 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 签发
确认sys_oauthclient_details
表中 client ogsp 对应的authorized_grant_types
(授权类型)字段中包含client_credentials
pig 客户端模式使用
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 ())); }
通过客户端模式申请 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
使用获取到的 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 拦截替换
添加 oauth2-client
依赖
1 2 3 4 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-oauth2-client</artifactId > </dependency >
添加 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 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; }
注册 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); 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 if (CollUtil.isNotEmpty(fromHeader) && (fromHeader.contains(SecurityConstants.FROM_IN) || fromHeader.contains( SecurityConstants.FROM_CLIENT))) { return ; }
后台请求调用 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())) { return null ; } 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)) { 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 ; } Instant expiresAt = accessToken.getExpiresAt(); if (expiresAt != null && expiresAt.isAfter(Instant.now())) { OAuth2Authorization authorization = authorizationService.findByToken(accessToken.getTokenValue(), OAuth2TokenType.ACCESS_TOKEN); if (authorization != null ) { return false ; } log.debug("Token {} not found in Redis, treating as expired" , accessToken.getTokenValue()); } return true ; } }