PAYATHON 2026

Spring Security 导致 POST 请求返回 HTTP 405

支付小周

结论

/salecomplete 的 POST 映射大概率没有问题。内部请求能够成功,说明 Spring MVC 可以找到对应的处理方法。

问题通常在 Spring Security 层。银行回调是来自外部、未经认证的跨站 POST 请求,不会携带系统页面生成的 CSRF Token,也可能没有有效的登录会话。请求被安全过滤器拦截后,可能又被转发到登录页、拒绝访问页,或者其他只支持 GET 的地址,最终由 Spring MVC 报出:

Request method 'POST' not supported

因此,405 通常只是表面现象。真正需要排查的是认证、CSRF 和安全异常的处理流程。

为什么内部 POST 正常

系统内部提交一般具备这些条件:

  • 浏览器已经登录,并携带有效的 Session Cookie。
  • 表单包含 Spring Security 生成的 CSRF Token。
  • 请求来源、路径和安全规则符合预期。

银行 IPG 回调的情况不同:

  • 回调由银行页面或银行服务器发起。
  • 银行无法获取系统生成的 CSRF Token。
  • 回调不应依赖用户是否仍处于登录状态。
  • 跨站 POST 还可能受到 Cookie 的 SameSite 属性影响。
  • 银行可能通过浏览器表单回传,也可能由服务器直接调用,具体方式需要查看银行接口文档。

所以,同一个 Controller 地址能在系统内部访问,不代表外部回调也能通过 Spring Security。

安全配置方式

支付回调地址通常需要允许匿名访问,并且只对这个地址忽略 CSRF 检查。不要因为一个支付回调而全局关闭 CSRF。

Spring Security 6 / Spring Boot 3 风格的配置示例:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/salecomplete").permitAll()
                .anyRequest().authenticated()
            )
            .csrf(csrf -> csrf
                .ignoringRequestMatchers("/salecomplete")
            );

        return http.build();
    }
}

如果项目使用较早版本的 Spring Security,可以采用等价配置:

@Override
protected void configure(HttpSecurity http) throws Exception {
    http
        .authorizeRequests()
            .antMatchers("/salecomplete").permitAll()
            .anyRequest().authenticated()
        .and()
        .csrf()
            .ignoringAntMatchers("/salecomplete");
}

XML 配置可以根据项目版本使用类似规则:

<http>
    <intercept-url pattern="/salecomplete" access="permitAll"/>
    <csrf request-matcher-ref="csrfRequestMatcher"/>
</http>

各个 Spring Security 版本支持的 API 并不完全相同,应以项目实际使用的版本为准。

Controller 建议

可以继续使用 POST 映射,但最好显式声明参数,也不要仅凭浏览器会话确认订单:

@PostMapping("/salecomplete")
public String salecomplete(
        @RequestParam("orderId") String orderId,
        @RequestParam("status") String status,
        @RequestParam("signature") String signature,
        Model model) {

    // 1. 根据银行协议验证 signature
    // 2. 向银行服务端查询或验证交易状态
    // 3. 校验金额、商户号、订单号和交易号
    // 4. 以幂等方式更新订单状态

    model.addAttribute("orderId", orderId);
    return "salecomplete";
}

如果银行要求返回固定文本,而不是渲染页面,可以使用:

@PostMapping("/salecomplete")
@ResponseBody
public String salecomplete(HttpServletRequest request) {
    // 验签、核对交易并更新订单
    return "OK";
}

具体返回内容必须符合银行 IPG 的协议,不能自行认定银行会接受 "OK"。

如何确认请求在哪里被拦截

先在浏览器开发者工具的 Network 面板中查看完整的请求链,重点检查:

  1. 银行实际提交的地址是不是 /salecomplete。
  2. 请求是否先收到 302,然后跳转到登录页。
  3. 405 对应的地址是 /salecomplete,还是 /login、/error、/access-denied 等其他地址。
  4. 请求地址是否包含应用所需的 context path。
  5. 回调过程中是否发生了 HTTP 到 HTTPS、域名或端口跳转。
  6. 表单的 method 是否确实为 POST。

也可以临时开启安全日志:

logging.level.org.springframework.security=DEBUG
logging.level.org.springframework.web=DEBUG

如果日志显示 CSRF 校验失败,常见响应通常是 403 Forbidden。现在返回 405,可能是因为失败请求又被自定义的 AccessDeniedHandler、错误页或转发规则送到了一个不接受 POST 的 Controller。需要检查这类配置:

.exceptionHandling(exception -> exception
    .accessDeniedPage("/access-denied")
)

同时确认 /error、/login 和 /access-denied 对应的映射是否只支持 GET。

支付回调的安全注意事项

允许匿名访问并忽略 CSRF,不等于可以直接信任回调中的参数。支付回调必须使用银行协议规定的安全校验机制,例如:

  • 验证数字签名、MAC 或银行提供的 Token。
  • 核对商户号、订单号、支付金额和币种。
  • 必要时主动调用银行查询接口,确认交易状态。
  • 使用银行交易流水号进行幂等处理。
  • 防止同一个回调被重复提交。
  • 不要仅凭请求中的 status=success 就把订单标记为已支付。
  • 不要依赖当前登录用户或浏览器 Session 判断订单归属。

可以先把 /salecomplete 配置为允许匿名访问,并且只对这个回调路径忽略 CSRF。然后结合完整请求链和 Spring Security 日志,继续排查登录跳转、错误转发或回调 URL 不准确等问题。

备注:内容仅供参考。