You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

Spring Boot 3.x升级后跨服务调用401认证失败响应体为空的问题咨询

Spring Boot 3.x升级后跨服务调用401认证失败响应体为空的问题咨询

问题背景

我们将Spring Boot从2.7.0升级到3.5.6后,遇到了服务间调用的错误响应异常:

  • ServiceAServiceB 发起POST请求
  • ServiceB 处理认证逻辑,认证失败时通过自定义RestAuthFailureHandler生成包含errorIdmessage的JSON响应体,并返回401状态码
  • 升级前(Spring Boot 2.7.x)该流程正常,ServiceA能正确获取401响应的body;升级后ServiceA收到的401响应body为空,只能展示通用错误

从日志可以看到:

  • ServiceB的RestAuthFailureHandler已触发并成功写入响应体
  • ServiceA的日志显示401响应的body为[no body]

可能原因分析

该问题核心与Spring Security 6.x的行为变化相关,主要涉及以下几点:

1. 认证失败响应的后续处理逻辑变化

Spring Security 6中,ExceptionTranslationFilter或其他过滤器可能会在自定义AuthenticationFailureHandler执行后,对响应进行二次修改(例如清空响应体、覆盖状态码)。即使你在handler中写入了响应体,后续组件可能会重置响应内容。

2. 响应提交机制的差异

Spring Boot 3.x中,Servlet容器或Spring Security对响应的提交(commit)逻辑有调整。虽然你的handler中检查了response.isCommitted()false并写入了body,但仅调用response.getWriter().flush()可能不足以确保响应体被真正提交到客户端,后续的过滤器可能会拦截并修改响应。

3. 表单登录配置的隐性变化

Spring Security 6对formLogin的配置逻辑做了精简,若未明确覆盖默认的认证失败处理链,可能会触发默认的AuthenticationEntryPoint,覆盖你自定义的响应内容。

排查步骤

  1. 直接验证ServiceB的响应是否包含body
    使用curl/postman调用ServiceB的/login接口,输入错误的认证信息,查看返回的401响应是否有JSON body:

    curl -v -X POST https://serviceb-domain/login -d "username=wrong&password=wrong"
    
    • 如果响应无body:问题出在ServiceB的响应生成逻辑
    • 如果响应有body:问题出在ServiceA的RestTemplate处理或服务间通信环节
  2. 确认ServiceB响应体的提交状态
    RestAuthFailureHandler中写入body后,添加日志打印响应的Content-Length和提交状态:

    // 写入body后添加
    log.info("Response Content-Length: {}", response.getContentLength());
    log.info("Response committed after writing: {}", response.isCommitted());
    
  3. 检查ServiceA的响应解析逻辑
    在ServiceA捕获HttpClientErrorException时,打印响应体的字节数组长度,确认是否真的未接收到body:

    catch (HttpClientErrorException hcee) {
        byte[] bodyBytes = hcee.getResponseBodyAsByteArray();
        logger.info("Response body byte length: {}", bodyBytes.length);
        if (bodyBytes.length > 0) {
            logger.info("Actual response body: {}", new String(bodyBytes, StandardCharsets.UTF_8));
        }
        // 原有日志逻辑
    }
    

解决方案建议

1. 确保ServiceB的响应体被强制提交

修改RestAuthFailureHandler,写入body后主动触发响应缓冲区刷新,避免后续组件修改响应:

@Override
public void onAuthenticationFailure(HttpServletRequest request,
                                    HttpServletResponse response,
                                    AuthenticationException exception)
        throws IOException {
    log.info("Entered onAuthenticationFailure");

    boolean committed = response.isCommitted();
    log.info("Response committed state: {}", committed);

    log.info("Exception type: {}, message: {}", exception.getClass().getName(), exception.getMessage());

    if (committed) {
        log.warn("Response already committed, unable to write error body");
        return;
    }

    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    response.setCharacterEncoding("UTF-8");

    ErrorInfo errorInfo;
    if (exception instanceof RestAuthenticationException restAuthenticationException) {
        errorInfo = restAuthenticationException.getErrorInfo();
        log.info("Using ErrorInfo from RestAuthenticationException: {}", errorInfo);
    } else {
        errorInfo = new ErrorInfo(
                ErrorCategory.APPLICATION_ERROR.name(),
                UserAPIErrorCode.AUTHENTICATION.code(),
                exception.getMessage()
        );
        log.info("Constructed new ErrorInfo: {}", errorInfo);
    }

    try {
        log.info("Writing ErrorInfo to response");
        String errorJson = mapper.writeValueAsString(errorInfo);
        // 设置Content-Length,避免容器自动截断
        response.setContentLength(errorJson.getBytes(StandardCharsets.UTF_8).length);
        response.getWriter().write(errorJson);
        response.getWriter().close(); // 关闭writer确保内容写入
        response.flushBuffer(); // 强制提交响应缓冲区
        log.info("Successfully wrote ErrorInfo to response, Content-Length: {}", response.getContentLength());
    } catch (IOException e) {
        log.error("IOException while writing ErrorInfo to response", e);
        throw e;
    }
}

2. 明确覆盖Spring Security 6的默认认证失败处理

在ServiceB的Security配置中,明确关闭默认的认证入口点,确保自定义failureHandler完全接管响应:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .formLogin(form -> form
                .loginProcessingUrl(LOGIN_PATH)
                .usernameParameter("username")
                .passwordParameter("password")
                .successHandler(restAuthSuccessHandler)
                .failureHandler(restAuthFailureHandler)
                .permitAll()
        )
        // 明确配置异常处理,避免默认逻辑干扰
        .exceptionHandling(exception -> exception
                .authenticationEntryPoint((req, res, authEx) -> {
                    // 若未通过formLogin触发的认证失败,可复用RestAuthFailureHandler逻辑
                    restAuthFailureHandler.onAuthenticationFailure(req, res, authEx);
                })
        )
        // 服务间调用可关闭CSRF
        .csrf(csrf -> csrf.disable());
    return http.build();
}

3. 检查ServiceA RestTemplate的配置

确保BufferingClientHttpRequestFactory正确配置,且未添加拦截器修改响应:

  • 确认BufferingClientHttpRequestFactory已启用(你当前的配置是正确的),它允许多次读取响应体
  • 若使用了自定义ClientHttpRequestInterceptor,检查是否在拦截器中清空了响应体

额外注意事项

  • Spring Security 6中,过滤器链的执行顺序有调整,确保formLoginfailureHandler优先级高于其他异常处理组件
  • 若ServiceB启用了响应压缩(server.compression.enabled=true),需确保压缩逻辑不会丢弃401响应的body
  • 检查ServiceB的日志,确认在RestAuthFailureHandler执行后,没有其他组件(如异常解析器、过滤器)修改响应状态或清空body

火山引擎 最新活动