PAYATHON 2026

Spring 如何判断 URL 是否设置 security="none"

支付小周

结论

不建议在 HandlerInterceptor.preHandle() 中判断某个 URL 是否配置了 security="none"。Spring MVC 拦截器和 Spring Security 过滤器链属于两套机制,拦截器没有稳定的公开 API,无法反向查询当前请求匹配了哪条安全配置。

也不要用 enabled、accountNonExpired 等账户状态字段表示“是否已经付款”。这些字段描述账户能否完成认证,付款状态则是业务授权条件。建议这样配置:

  1. 允许公开访问登录页、静态资源和错误页。
  2. payment page 只要求用户已登录。
  3. 其他付费功能要求用户拥有 SERVICE_PAID 权限,或通过自定义 AuthorizationManager 实时检查付款状态。
  4. 已登录但未付款时,由 AccessDeniedHandler 将用户重定向到 payment page。

为什么不应在拦截器里判断

security="none" 表示请求完全绕过对应的 Spring Security 过滤器链。它不是附加在 HttpServletRequest 上的属性,因此无法通过下面这种方式可靠判断:

request.getUserPrincipal() != null

这个判断只能说明当前请求是否存在认证主体,不能说明 URL 是否受到保护。例如:

  • 已登录用户访问公开 URL 时,仍然可能带有 Principal。
  • 未登录用户访问受保护 URL 时,可能使用匿名认证。
  • permitAll URL 仍会经过 Spring Security,只是不要求权限。
  • security="none" URL 完全绕过该过滤器链。

如果在 MVC 拦截器中另行维护一套 URL 排除规则,安全配置和拦截器配置很容易逐渐失去同步。以后新增登录页、错误页或静态资源路径时,还可能引发重定向循环。

推荐方案:把付款状态作为授权条件

下面的示例采用 SecurityFilterChain 和 authorizeHttpRequests 配置 Spring Security。不同 Spring Security 版本的接口细节可能稍有不同,但授权模型一致。

如果付款状态只需在用户下次登录时更新,可以在认证成功后为用户添加 SERVICE_PAID 权限:

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(authorize -> authorize
        .requestMatchers(
            "/login",
            "/error",
            "/css/**",
            "/js/**",
            "/images/**"
        ).permitAll()
        .requestMatchers("/payment/**").authenticated()
        .anyRequest().hasAuthority("SERVICE_PAID")
    );

    http.exceptionHandling(exceptions -> exceptions
        .accessDeniedHandler(paymentAccessDeniedHandler())
    );

    return http.build();
}

匹配规则有先后顺序,所以 /payment/** 必须写在要求 SERVICE_PAID 的规则前面,否则未付款用户连付款页面也无法访问。

拒绝处理器可以这样写:

@Bean
AccessDeniedHandler paymentAccessDeniedHandler() {
    return (request, response, exception) -> {
        Authentication authentication =
            SecurityContextHolder.getContext().getAuthentication();

        boolean loggedIn =
            authentication != null
                && authentication.isAuthenticated()
                && !(authentication instanceof AnonymousAuthenticationToken);

        boolean paid = loggedIn
            && authentication.getAuthorities().stream()
                .anyMatch(authority ->
                    "SERVICE_PAID".equals(authority.getAuthority()));

        if (loggedIn && !paid) {
            response.sendRedirect(
                request.getContextPath() + "/payment"
            );
            return;
        }

        response.sendError(HttpServletResponse.SC_FORBIDDEN);
    };
}

完成这些配置后:

  • 未登录用户由 Spring Security 引导进入登录流程。
  • 已登录但未付款的用户访问付费功能时,会被重定向到 /payment。
  • /payment 本身只要求登录,不要求用户已经付款。
  • 已付款但缺少其他业务权限的用户仍会收到 403 Forbidden,不会被错误地重定向。

付款状态需要实时生效时

如果用户支付成功后必须立即获得访问权限,只在登录时写入 Authentication 会造成状态过期。当前会话中的权限不会自动更新,除非用户重新认证或系统主动刷新 SecurityContext。

这种情况下,可以使用自定义 AuthorizationManager 查询付款服务:

@Component
public class PaymentAuthorizationManager
        implements AuthorizationManager<RequestAuthorizationContext> {

    private final PaymentService paymentService;

    public PaymentAuthorizationManager(PaymentService paymentService) {
        this.paymentService = paymentService;
    }

    @Override
    public AuthorizationDecision check(
            Supplier<Authentication> authenticationSupplier,
            RequestAuthorizationContext context) {

        Authentication authentication = authenticationSupplier.get();

        boolean loggedIn =
            authentication != null
                && authentication.isAuthenticated()
                && !(authentication instanceof AnonymousAuthenticationToken);

        if (!loggedIn) {
            return new AuthorizationDecision(false);
        }

        boolean paid =
            paymentService.hasPaid(authentication.getName());

        return new AuthorizationDecision(paid);
    }
}

然后将它应用到需要付款的 URL:

@Bean
SecurityFilterChain securityFilterChain(
        HttpSecurity http,
        PaymentAuthorizationManager paymentAuthorizationManager)
        throws Exception {

    http.authorizeHttpRequests(authorize -> authorize
        .requestMatchers(
            "/login",
            "/error",
            "/css/**",
            "/js/**",
            "/images/**"
        ).permitAll()
        .requestMatchers("/payment/**").authenticated()
        .requestMatchers("/service/**")
            .access(paymentAuthorizationManager)
        .anyRequest().authenticated()
    );

    http.exceptionHandling(exceptions -> exceptions
        .accessDeniedHandler(paymentAccessDeniedHandler())
    );

    return http.build();
}

如果 hasPaid() 每次都直接查询数据库,需要评估实际请求量。通常可以使用短期缓存,并在支付成功后主动清除或更新缓存。

是否可以把标志放进 Principal

可以在自定义 UserDetails 或 Principal 中添加 servicePaid 字段,但不能把它当作 enabled、accountNonExpired 等认证状态。

未付款用户仍然应该能够:

  • 正常登录;
  • 查看付款页面;
  • 发起支付;
  • 查询支付结果;
  • 退出登录。

如果把未付款映射为 enabled=false,用户可能无法登录,自然也无法进入付款流程。

自定义字段可以作为授权判断的数据来源,不过更符合 Spring Security 使用方式的做法,是将它转换为权限:

authorities.add(
    new SimpleGrantedAuthority("SERVICE_PAID")
);

这类权限通常在登录时生成。如果付款状态会在会话期间发生变化,就需要刷新当前 Authentication,或者改用实时查询的 AuthorizationManager。

关于 security="none" 与 permitAll

旧式 XML 配置中的:

<http pattern="/resources/**" security="none"/>

表示该路径不经过 Spring Security。现代配置通常用 WebSecurityCustomizer 的 ignoring() 表达类似含义,但除了纯静态资源外,不宜大量使用。

登录页、错误页和公开业务接口通常更适合配置为:

.requestMatchers("/login", "/error").permitAll()

permitAll 允许任何人访问,但请求仍会经过 Spring Security,所以安全响应头、SecurityContext 和其他过滤器行为仍能正常生效。

这个需求不必判断 URL 是否配置了 security="none"。直接在安全配置中定义三种访问级别即可:公开访问、登录后访问和付款后访问。付款检查、例外页面以及拒绝后的跳转,都由同一套授权规则管理。

备注:内容仅供参考。