文章目录
OpenFeign访问需要OAuth2授权的服务
概述
Spring Cloud 微服务架构下使用feign组件进行服务间的调用,该组件使用http协议进行服务间的通信,同时整合了Ribbion使其具有负载均衡和失败重试的功能,微服务service-a调用需要授权的service-b的流程中大概流程 :
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gf1QUs9i-1618850710274)(https://img2018.cnblogs.com/blog/733995/201810/733995-20181031151136077-1818990556.png “微服务service-a调用需要授权的service-b流程图”)]
随着微服务安全性的增强,需要携带token才能访问其API,然而feign组件默认并不会将 token 放到 Header 中,那么如何使用OpenFeign实现自动设置授权信息并访问需要OAuth2授权的服务呢?
本文重点讲述如何通过RequestInterceptor
实现自动设置授权信息,并访问需要OAuth2的client模式授权的服务。需要重点理解下面两点:
- OAuth2.0配置
- OAuth2FeignRequestInterceptor
本文依赖:
- spring-boot-starter-parent:2.4.2
- spring-cloud-starter-openfeign:3.0.0
- spring-cloud-starter-oauth2:2.2.4.RELEASE
示例
OAuth2.0相关配置
引入依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
<version>2.2.4.RELEASE</version>
</dependency>
配置application.yml
auth.service:: http://localhost:8080
security:
oauth2:
client:
client-id: car-client
client-secret: 123456
grant-type: client_credentials
access-token-uri: ${auth.service}/oauth/token #请求令牌的地址
scope:
- all
resource:
jwt:
key-uri: ${auth.service}/oauth/token_key
user-info-uri: ${auth.service}/api/sso/user/me
配置资源服务器
@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {
@Override
public void configure(HttpSecurity http) throws Exception {
http.requestMatchers().antMatchers("/**")
.and().authorizeRequests()
.antMatchers("/**").permitAll()
.anyRequest().authenticated();
}
}
OAuth2FeignConfiguration
引入依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-openfeign</artifactId>
<version>3.0.2</version>
</dependency>
FeignClient使用
@Resource
private OAuth2FeignClient oAuth2FeignClient;
...
String vo = oAuth2FeignClient.getMemberInfo();
编写OAuth2FeignClient
oauth2.api.url: http://localhost:8081
@FeignClient(url = "${oauth2.api.url}", name = "oauth2FeignClient", configuration = OAuth2FeignConfiguration.class)
public interface OAuth2FeignClient {
@PostMapping("/car/info")
String getCarInfo();
}
编写OAuth2FeignConfiguration(重点)
public class OAuth2FeignConfiguration {
/** feign的OAuth2ClientContext */
private final OAuth2ClientContext feignOAuth2ClientContext = new DefaultOAuth2ClientContext();
@Resource
private ClientCredentialsResourceDetails clientCredentialsResourceDetails;
@Autowired
private ObjectFactory<HttpMessageConverters> messageConverters;
@Bean
public OAuth2RestTemplate clientCredentialsRestTemplate() {
return new OAuth2RestTemplate(clientCredentialsResourceDetails);
}
@Bean
public RequestInterceptor oauth2FeignRequestInterceptor() {
return new OAuth2FeignRequestInterceptor(feignOAuth2ClientContext, clientCredentialsResourceDetails);
}
@Bean
public Logger.Level feignLoggerLevel() {
return Logger.Level.FULL;
}
@Bean
public Retryer retry() {
// default Retryer will retry 5 times waiting waiting
// 100 ms per retry with a 1.5* back off multiplier
return new Retryer.Default(100, SECONDS.toMillis(1), 3);
}
@Bean
public Decoder feignDecoder() {
return new CustomResponseEntityDecoder(new SpringDecoder(this.messageConverters), feignOAuth2ClientContext);
}
/**
* Http响应成功 但是token失效,需要定制 ResponseEntityDecoder
* @author maxianming
* @date 2018/10/30 9:47
*/
class CustomResponseEntityDecoder implements Decoder {
private org.slf4j.Logger log = LoggerFactory.getLogger(CustomResponseEntityDecoder.class);
private Decoder decoder;
private OAuth2ClientContext context;
public CustomResponseEntityDecoder(Decoder decoder, OAuth2ClientContext context) {
this.decoder = decoder;
this.context = context;
}
@Override
public Object decode(final Response response, Type type) throws IOException, FeignException {
if (log.isDebugEnabled()) {
log.debug("feign decode type:{},reponse:{}", type, response.body());
}
if (isParameterizeHttpEntity(type)) {
type = ((ParameterizedType) type).getActualTypeArguments()[0];
Object decodedObject = decoder.decode(response, type);
return createResponse(decodedObject, response);
} else if (isHttpEntity(type)) {
return createResponse(null, response);
} else {
// custom ResponseEntityDecoder if token is valid then go to errorDecoder
String body = Util.toString(response.body().asReader(Util.UTF_8));
if (body.contains("401 Unauthorized")) {
clearTokenAndRetry(response, body);
}
return decoder.decode(response, type);
}
}
/**
* token失效 则将token设置为null 然后重试
* @param response response
* @param body body
* @author maxianming
* @date 2018/10/30 10:05
*/
private void clearTokenAndRetry(Response response, String body) throws FeignException {
log.error("接收到Feign请求资源响应,响应内容:{}", body);
context.setAccessToken(null);
throw new RetryableException(
response.status(),
"access_token过期,即将进行重试",
response.request().httpMethod(),
new Date(),
response.request());
}
private boolean isParameterizeHttpEntity(Type type) {
if (type instanceof ParameterizedType) {
return isHttpEntity(((ParameterizedType) type).getRawType());
}
return false;
}
private boolean isHttpEntity(Type type) {
if (type instanceof Class) {
Class c = (Class) type;
return HttpEntity.class.isAssignableFrom(c);
}
return false;
}
@SuppressWarnings("unchecked")
private <T> ResponseEntity<T> createResponse(Object instance, Response response) {
MultiValueMap<String, String> headers = new LinkedMultiValueMap<>();
for (String key : response.headers().keySet()) {
headers.put(key, new LinkedList<>(response.headers().get(key)));
}
return new ResponseEntity<>((T) instance, headers, HttpStatus.valueOf(response.status()));
}
}
@Bean
public ErrorDecoder errorDecoder() {
return new RestClientErrorDecoder(feignOAuth2ClientContext);
}
/**
* Feign调用HTTP返回响应码错误时候,定制错误的解码
* @author liudong
* @date 2018/10/30 9:45
*/
class RestClientErrorDecoder implements ErrorDecoder {
private org.slf4j.Logger logger = LoggerFactory.getLogger(RestClientErrorDecoder.class);
private OAuth2ClientContext context;
RestClientErrorDecoder(OAuth2ClientContext context) {
this.context = context;
}
@Override
public Exception decode(String methodKey, Response response) {
FeignException exception = errorStatus(methodKey, response);
logger.error("Feign调用异常,异常methodKey:{}, token:{}, response:{}", methodKey, context.getAccessToken(), response.body());
if (HttpStatus.UNAUTHORIZED.value() == response.status()) {
logger.error("接收到Feign请求资源响应401,access_token已经过期,重置access_token为null待重新获取。");
context.setAccessToken(null);
return new RetryableException(
response.status(),
"疑似access_token过期,即将进行重试",
response.request().httpMethod(),
exception,
new Date(),
response.request());
}
return exception;
}
}
}
OAuth2FeignConfiguration相关说明
- 使用
ClientCredentialsResourceDetails
(client_id、client-secret、jwt.key-uri/user-info-uri等信息配置在配置中心)初始化OAuth2RestTemplate
,用户请求创建token时候验证基本信息; - 主要定义了拦截器初始化了
OAuth2FeignRequestInterceptor
,使得Feign进行RestTemplate
调用请求前进行token拦截。如果不存在token则需要从auth-server中获取token; - 注意上下文对象
OAuth2ClientContext
建立后不放在Bean容器中:由于Spring mvc的前置处理器, 会复制用户的token到OAuth2ClientContext中,如果放在Bean容器中,用户的token会覆盖服务间的token,当两个token的权限不同时,将导致验证不通过; - 重新定义了
Decoder
,对RestTemple http调用的响应进行了解码,对token失效的情况进行了扩展:- 默认情况下:对于由于token失效返回401错误的http响应,导致进入
ErrorDecoder
的情况,在ErrorDecoder
中进行清空token操作,并返回RetryableException
,让Feign重试。 - 扩展后:对于接口200响应token失效的错误码的情况,将会走
Decoder
流程,所以对ResponseEntityDecoder
进行了扩展,如果响应无效token错误码,则清空token并重试。
- 默认情况下:对于由于token失效返回401错误的http响应,导致进入
扩展
OAuth2FeignRequestInterceptor
copyOAuth2RestTemplate
的获取token内容, 后者实现了获取token并存入context未超时时不会再次请求授权服务器,减轻了授权服务器的开销ClientCredentialsResourceDetails
可以拓展为其他3种授权模式的Details, 有兴趣的请移步至OAuth2ProtectedResourceDetails
的源码- Bean容器中的
OAuth2ClientContext
的token与服务间调用所需的token权限不同; 或者当前上下文中没有token,但后者调用需要token(Spring mvc的前置处理器, 会复制token到OAuth2ClientContext中); 这两种情况均可以建立不放入Bean容器中的OAuth2ClientContext
- 如果Bean容器中的
OAuth2ClientContext
的token与服务间调用所需的token权限相同, 可以注入Bean容器中的OAuth2ClientContext
; 也可以参考SpringCloud 中 Feign 调用添加 Oauth2 Authorization Header, 或者 feign之间传递oauth2-token的问题和解决 来实现, 其获取token的核心逻辑可以参考源码org.springframework.cloud.commons.security.AccessTokenContextRelay
; - (未解决)
OAuth2RestTemplate
在OAuth2FeignConfiguration
担任什么角色? 有什么作用? - (未解决)启动类不能配置
@EnableOAuth2Client
, 否则无法启动项目, 有木有大佬知道原因? 个人猜测和AccessTokenContextRelay
有关系 - (未解决)该文档学会了
OAuth2FeignRequestInterceptor
的用法, 那么BasicAuthRequestInterceptor
又用于什么场景呢? - (未解决)尝试使用
ResponseMappingDecoder
的设计思路实现CustomResponseEntityDecoder
- (未解决)
spring-cloud-starter-oauth2
于2020年8月1日发布了2.2.4.RELEASE
版本后一直没有更新, 如果其不维护又该怎么办呢? 有木有其他实现方式呢? - (未解决)
spring-boot-starter-oauth2-client
与spring-boot-starter-oauth2-resource-server
刚刚发布了2.4.5
版本, 其一直在更新, 是否可以用来实现OAuth2配置部分? 可以参考官方文档进行评估(前者支持配置多个APP的client信息并分别获取授权)