Spring Boot ClientHttpRequestInterceptor 重新发送 401

Posted

技术标签:

【中文标题】Spring Boot ClientHttpRequestInterceptor 重新发送 401【英文标题】:Spring boot ClientHttpRequestInterceptor resend on 401 【发布时间】:2019-07-23 05:36:33 【问题描述】:

所以我有下面的场景来实现使用Spring boot rest template 来消费REST-API(涉及令牌认证机制)。为了执行测试,我在 Spring Boot 中创建了简单的模拟 REST API。这是过程,

来自我的 API 消费者应用,

使用rest-template 发送请求以使用受保护的API,此API 要求Authorization: Bearer <token> 标头出现在请求中。 如果此令牌有问题(缺少标头、无效令牌),受保护的 API 将返回 HTTP-Unauthorized (401)。 当这种情况发生时,消费者 API 应该向另一个受保护的 API 发送另一个请求,该 API 返回一个有效的访问令牌,这个受保护的 API 需要存在 Authorization: Basic <token> 标头。新的访问令牌将存储在一个静态字段中,并将用于所有其他请求进行身份验证。

这可以通过在RestTemplate 消费者方法(postForObject) 中简单地捕获401-HttpClientErrorException 来实现,但想法是将它与REST-API 消费者类解耦。为了实现它,我尝试使用ClientHttpRequestInterceptor

这是我目前尝试过的代码。

拦截器类

public class AuthRequestInterceptor implements ClientHttpRequestInterceptor 

private static final Logger LOGGER = LoggerFactory.getLogger(AuthRequestInterceptor.class);
private static final String BASIC_AUTH_HEADER_PREFIX = "Basic ";
private static final String BEARER_AUTH_HEADER_PREFIX = "Bearer ";

//stores access token
private static String accessToken = null;

@Value("$app.mife.apiKey")
private String apiKey;

@Autowired
private GenericResourceIntegration resourceIntegration; // contains methods of rest template

@Override
public ClientHttpResponse intercept(
        HttpRequest request,
        byte[] body,
        ClientHttpRequestExecution execution
) throws IOException 
    LOGGER.info("ReqOn|URI:[], Headers|, Body|", request.getMethod(), request.getURI(), request.getHeaders(), new String(body));
    request.getHeaders().add(ACCEPT, APPLICATION_JSON_VALUE);
    request.getHeaders().add(CONTENT_TYPE, APPLICATION_JSON_VALUE);
    try 
        //URI is a token generate URI, request
        if (isBasicUri(request)) 
            request.getHeaders().remove(AUTHORIZATION);
            //sets BASIC auth header
            request.getHeaders().add(AUTHORIZATION, (BASIC_AUTH_HEADER_PREFIX + apiKey));
            ClientHttpResponse res = execution.execute(request, body);
            LOGGER.info("ClientResponse:[], status|", "BASIC", res.getStatusCode());
            return res;
        

        //BEARER URI, protected API access
        ClientHttpResponse response = null;
        request.getHeaders().add(AUTHORIZATION, BEARER_AUTH_HEADER_PREFIX + getAccessToken());
        response = execution.execute(request, body);
        LOGGER.info("ClientResponse:[], status|", "BEARER", response.getStatusCode());

        if (unauthorized(response)) 
            LOGGER.info("GetToken Res|", response.getStatusCode());
            String newAccessToken = generateNewAccessCode();
            request.getHeaders().remove(AUTHORIZATION);
            request.getHeaders().add(AUTHORIZATION, (BEARER_AUTH_HEADER_PREFIX + newAccessToken));
            LOGGER.info("NewToken|", newAccessToken);
            return execution.execute(request, body);
        

        if (isClientError(response) || isServerError(response)) 
            LOGGER.error("Error[Client]|statusCode|, body|", response.getStatusCode(), CommonUtills.streamToString(response.getBody()));
            throw new AccessException(response.getStatusText(),
                    ServiceMessage.error().code(90).payload(response.getRawStatusCode() + ":" + response.getStatusText()).build());
        

        return response;
     catch (IOException exception) 
        LOGGER.error("AccessError", exception);
        throw new AccessException("Internal service call error",
                ServiceMessage.error().code(90).payload("Internal service call error", exception.getMessage()).build()
        );
     finally 
        LOGGER.info("ReqCompletedOn|", request.getURI());
    


private String generateNewAccessCode() 
    Optional<String> accessToken = resourceIntegration.getAccessToken();
    setAccessToken(accessToken.get());
    return getAccessToken();


private static void setAccessToken(String token) 
    accessToken = token;


private static String getAccessToken() 
    return accessToken;


private boolean isClientError(ClientHttpResponse response) throws IOException 
    return (response.getRawStatusCode() / 100 == 4);


private boolean isServerError(ClientHttpResponse response) throws IOException 
    return (response.getRawStatusCode() / 100 == 5);


private boolean unauthorized(ClientHttpResponse response) throws IOException 
    return (response.getStatusCode().value() == HttpStatus.UNAUTHORIZED.value());


private boolean isBasicUri(HttpRequest request) 
    return Objects.equals(request.getURI().getRawPath(), "/apicall/token");


private boolean isMifeRequest(HttpRequest request) 
    return request.getURI().toString().startsWith("https://api.examplexx.com/");

令牌生成方法-在resourceIntegration

public Optional<String> getAccessToken() 
    ResponseEntity<AccessTokenResponse> res = getRestTemplate().exchange(
            getAccessTokenGenUrl(),
            HttpMethod.POST,
            null,
            AccessTokenResponse.class
    );
    if (res.hasBody()) 
        LOGGER.info(res.getBody().toString());
        return Optional.of(res.getBody().getAccess_token());
     else 
        return Optional.empty();
    

另一个示例受保护的 API 调用方法

public Optional<String> getMobileNumberState(String msisdn) 
    try 
        String jsonString = getRestTemplate().getForObject(
                getQueryMobileSimImeiDetailsUrl(),
                String.class,
                msisdn
        );
        ObjectNode node = new ObjectMapper().readValue(jsonString, ObjectNode.class);
        if (node.has("PRE_POST")) 
            return Optional.of(node.get("PRE_POST").asText());
        
        LOGGER.debug(jsonString);
     catch (IOException ex) 
        java.util.logging.Logger.getLogger(RestApiConsumerService.class.getName()).log(Level.SEVERE, null, ex);
    
    return Optional.empty();

问题

这是 mock API 的日志,

//first time no Bearer token, this returns 401 for API /simulate/unauthorized
accept:text/plain, application/json, application/*+json, */*
authorization:Bearer null
/simulate/unauthorized


//then it sends Basic request to get a token, this is the log
accept:application/json, application/*+json
authorization:Basic M3ZLYmZQbE1ERGhJZWRHVFNiTEd2Vlh3RThnYTp4NjJIa0QzakZUcmFkRkVOSEhpWHNkTFhsZllh
Generated Token:: 57f21374-1188-4c59-b5a7-370eac0a0aed
/apicall/token


//finally consumer API sends the previous request to access protected API and it contains newly generated token in bearer header
accept:text/plain, application/json, application/*+json, */*
authorization:Bearer 57f21374-1188-4c59-b5a7-370eac0a0aed
/simulate/unauthorized

问题是即使模拟 API 日志有正确的流程,消费者 API 没有得到第三次调用的任何响应,这是它的日志(省略了不必要的日志)。

RequestInterceptor.intercept() - ReqOn|URI:[GET]http://localhost:8080/simulate/unauthorized?x=GlobGlob, Headers|Accept=[text/plain, application/json, application/*+json, */*], Content-Length=[0], Body|
RequestInterceptor.intercept() - ClientResponse:[BEARER], status|401 UNAUTHORIZED

RequestInterceptor.intercept() - GetToken Res|401 UNAUTHORIZED
RequestInterceptor.intercept() - ReqOn|URI:[POST]http://localhost:8080/apicall/token?grant_type=client_credentials, Headers|Accept=[application/json, application/*+json], Content-Length=[0], Body|
RequestInterceptor.intercept() - ClientResponse:[BASIC], status|200 OK
RequestInterceptor.intercept() - ReqCompletedOn|http://localhost:8080/apicall/token?grant_type=client_credentials

RestApiConsumerService.getAccessToken() - |access_token2163b0d4-8d00-4eba-92d0-7e0bb609b982,scopeam_application_scope default,token_typeBearer,expires_in34234|
RequestInterceptor.intercept() - NewToken|2163b0d4-8d00-4eba-92d0-7e0bb609b982
RequestInterceptor.intercept() - ReqCompletedOn|http://localhost:8080/simulate/unauthorized?x=GlobGlob

http://localhost:8080/simulate/unauthorized 第三次没有返回任何响应,但模拟 API 日志说它命中了请求。我做错了什么?,是否有可能使用这种技术来完成这项任务?还是有其他替代方法可以做到这一点?非常感谢任何帮助。

【问题讨论】:

【参考方案1】:

我试过这个:

添加拦截器ClientHttpRequestInterceptor

    import java.io.IOException;

    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.data.redis.core.RedisTemplate;
    import org.springframework.http.HttpRequest;
    import org.springframework.http.HttpStatus;
    import org.springframework.http.client.ClientHttpRequestExecution;
    import org.springframework.http.client.ClientHttpRequestInterceptor;
    import org.springframework.http.client.ClientHttpResponse;
    import org.springframework.util.StringUtils;






    public class RequestResponseHandlerInterceptor implements ClientHttpRequestInterceptor 


        @Autowired
        private TokenService tokenService;

        @Autowired
        private RedisTemplate<String, String> redisTemplate;

        private static final String AUTHORIZATION = "Authorization";

        /**
         * This method will intercept every request and response and based on response status code if its 401 then will retry 
         * once
         */

        @Override
        public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException 
            ClientHttpResponse response = execution.execute(request, body);
            if (HttpStatus.UNAUTHORIZED == response.getStatusCode()) 
                String accessToken = tokenService.getAccessToken();
                if (!StringUtils.isEmpty(accessToken)) 
                    request.getHeaders().remove(AUTHORIZATION);
                    request.getHeaders().add(AUTHORIZATION, accessToken);
//retry                    
response = execution.execute(request, body);
                
            
            return response;
        

    

除此之外,您还需要覆盖 RestTemplate 初始化。

@Bean
    public RestTemplate restTemplate() 
        RestTemplate restTemplate = new RestTemplate();
        restTemplate.setInterceptors(Collections.singletonList(new RequestResponseHandlerInterceptor()));
        return restTemplate;
    

【讨论】:

我也试试这个 以这种方式使用拦截器时要注意一点:在intercept 方法中第二次调用execution.execute() 不会导致新请求沿整个执行链向下传递。如果你在这个 restTemplate 上有其他拦截器(除了RequestResponseHandlerInterceptor),这些拦截器已经被调用,请求的第二次调用将不会通过这些。

以上是关于Spring Boot ClientHttpRequestInterceptor 重新发送 401的主要内容,如果未能解决你的问题,请参考以下文章

为啥 Spring Boot 应用程序 pom 同时需要 spring-boot-starter-parent 和 spring-boot-starter-web?

《02.Spring Boot连载:Spring Boot实战.Spring Boot核心原理剖析》

spring-boot-quartz, 依赖spring-boot-parent

spring-boot系列:初试spring-boot

Spring Boot:Spring Boot启动原理分析

Spring Boot:Spring Boot启动原理分析