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相关说明
  1. 使用ClientCredentialsResourceDetails(client_id、client-secret、jwt.key-uri/user-info-uri等信息配置在配置中心)初始化OAuth2RestTemplate,用户请求创建token时候验证基本信息;
  2. 主要定义了拦截器初始化了OAuth2FeignRequestInterceptor,使得Feign进行RestTemplate调用请求前进行token拦截。如果不存在token则需要从auth-server中获取token;
  3. 注意上下文对象OAuth2ClientContext建立后不放在Bean容器中:由于Spring mvc的前置处理器, 会复制用户的token到OAuth2ClientContext中,如果放在Bean容器中,用户的token会覆盖服务间的token,当两个token的权限不同时,将导致验证不通过;
  4. 重新定义了Decoder,对RestTemple http调用的响应进行了解码,对token失效的情况进行了扩展:
    1. 默认情况下:对于由于token失效返回401错误的http响应,导致进入ErrorDecoder的情况,在ErrorDecoder中进行清空token操作,并返回RetryableException,让Feign重试。
    2. 扩展后:对于接口200响应token失效的错误码的情况,将会走Decoder流程,所以对ResponseEntityDecoder进行了扩展,如果响应无效token错误码,则清空token并重试。

扩展

  • OAuth2FeignRequestInterceptor copy OAuth2RestTemplate 的获取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;
  • (未解决)OAuth2RestTemplateOAuth2FeignConfiguration担任什么角色? 有什么作用?
  • (未解决)启动类不能配置@EnableOAuth2Client, 否则无法启动项目, 有木有大佬知道原因? 个人猜测和AccessTokenContextRelay有关系
  • (未解决)该文档学会了OAuth2FeignRequestInterceptor的用法, 那么BasicAuthRequestInterceptor又用于什么场景呢?
  • (未解决)尝试使用ResponseMappingDecoder的设计思路实现CustomResponseEntityDecoder
  • (未解决)spring-cloud-starter-oauth2于2020年8月1日发布了2.2.4.RELEASE版本后一直没有更新, 如果其不维护又该怎么办呢? 有木有其他实现方式呢?
  • (未解决)spring-boot-starter-oauth2-clientspring-boot-starter-oauth2-resource-server刚刚发布了2.4.5版本, 其一直在更新, 是否可以用来实现OAuth2配置部分? 可以参考官方文档进行评估(前者支持配置多个APP的client信息并分别获取授权)

参考

Logo

权威|前沿|技术|干货|国内首个API全生命周期开发者社区

更多推荐