☰
Spring Security前后端分离实战:跨域与401响应问题排查
2026/10/24 5:01:54 网站建设 项目流程

做前后端分离项目,最让人挠头的往往不是业务逻辑本身,而是把 Spring Security 和整条调用链理顺。我手头这个项目后台是 Spring Boot + Spring Security,前端用的 Vue,认证走 JWT。联调没几天,两个经典问题先后冒了出来:一是浏览器不停报跨域错误,接口用 curl 明明能通,页面里就是拿不到数据;二是 token 过期之后,后端明明已经在过滤器里拦住了请求,前端却始终拿不到预想中的 401 状态码,收到的要么是 HTML 片段,要么干脆是 200。这两类问题看着不相干,最后排查下来,根源全部指向同一条链路——Spring Security 的过滤器链。这篇文章就从这两个问题出发,把从配置到排错的完整过程分享出来,给同样在前后端分离项目里跟 Security 较劲的朋友做个参考。

1. 前后端分离中的认证与跨域:问题的根源藏在过滤器链里

1.1 前后端分离架构对安全框架的三重冲击

传统服务端渲染模式下,页面和后端在同一个源里,浏览器会自动带上 Session Cookie,Spring Security 的默认行为都是围绕 Session 设计的。前后端分离之后,这套默认行为全部失效,至少出现三个变化。

第一,Session 不再是默认状态存储。前端页面和后端接口通常不在同一个域名下,接口调用没法依赖浏览器自动携带 Cookie 完成身份识别,所以请求头里得额外带一个 token 字段。于是 Spring Security 的配置必须改成无状态模式,Session 不创建、不使用,每次请求都从 token 里恢复用户身份。

第二,跨域不再是一个"帮个忙"的小事,而是请求能不能到达后端的关键前提。浏览器对跨域请求有严格的同源策略,前端页面从 5173 端口请求 8080 端口的接口,这种跨源请求在真正执行之前会先发一个 OPTIONS 预检请求,问后端"我能不能这么调、能带哪些头"。如果后端没有正确回应 CORS 信息,浏览器直接把请求掐死在门口,业务代码连执行机会都没有。

第三,异常响应的形态变了。Spring Security 默认把未认证请求重定向到登录页,这个设计在前后端分离项目里就是一个灾难,因为前端根本没有登录页,收到的是一段重定向响应或 HTML,而不是约定好的 JSON 错误信息。认证失败的响应必须由后端显式改写成 JSON + 明确的状态码。

1.2 两个问题为什么总是成双出现

跨域和 token 失效响应异常,表面上是两件事,实际上都发生在同一批过滤器里。Spring Security 从接到请求到进入 Controller,中间要过一条过滤器链,大致有 SecurityContextHolderFilter、CorsFilter、CSRF Filter、ExceptionTranslationFilter、AuthorizationFilter 等。

跨域如果在这一层处理不当,浏览器连响应都收不到,更不用说后面 token 校验的 401 了。token 如果失效,JWT 解析失败的异常会在这条链上某个节点被捕获或吞掉,最终决定"用户收到什么"的,是链上的异常处理入口。

所以我把两个问题放在一起解决,不分开修,因为只改一处往往会让另一处冒出来。比如你只加了 CORS 过滤器但不理 token 异常,跨域是通了,可 token 失效时前端还是会收到混乱响应;你只改了 401 JSON,跨域预检请求却进不了过滤器链,页面依然报跨域。

我给这种问题总结了一个排查思路:别急着改业务代码,先把过滤器链的配置完整过一遍,确保每一环都有明确交代。

2. 跨域问题从"配了没用"到"一次配通":CorsFilter 的注册时机决定一切

2.1 传统方案为何失效:@CrossOrigin 与全局 CORS 配置的局限

很多刚接触前后端分离的开发者,第一反应是在 Controller 上加 @CrossOrigin,或者在 WebMvcConfigurer 里配置 addCorsMappings。这两个方法不是不能用,而是在 Spring Security 接管请求后体现出一个共同的问题:它们都工作在 Spring MVC 层面,也就是 DispatcherServlet 之后,而安全过滤器在这之前就已经跑完了。

用一个图景来类比:CorsFilter 是小区门口的保安,Spring Security 是大楼里的门禁。你只在门禁(MVC 层)装了门牌识别,门口的保安却把你的访客当闲杂人等拦掉了。请求连楼都进不去,楼里的门牌识别自然没机会工作。

三种方案的差异我看下来是这样的:

方案作用位置与 Security 过滤器链的关系适合场景
@CrossOrigin 注解Controller 方法在安全过滤器之后少量接口、临时联调
WebMvcConfigurer 全局 CORSSpring MVC 层在安全过滤器之后MVC 项目、无 Security
CorsConfigurationSource + CorsFilterServlet 过滤器层在安全过滤器之前前后端分离 + Security,推荐

如果你项目里引入了 Spring Security 但没配 CORS,最直观的现象就是:跨域请求在安全链上被识别为未认证,返回 403,而浏览器因为没看到 CORS 响应头,只会告诉你"跨域失败",根本不会让你看到后端的真实响应。这也是前端同学反复改代码、后端同学反复说"我自己调没问题"的经典冲突来源。

2.2 标准解法:CorsConfigurationSource + CorsFilter + http.cors()

我当时采用的方案,是注册一个 CorsConfigurationSource 类型的 Bean,再用它生成 CorsFilter。核心代码如下:

@Configuration public class CorsConfig { @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); // 注意:不要和 allowCredentials(true) 同时使用 "*" config.setAllowedOrigins(Arrays.asList("http://localhost:5173", "https://your-frontend.com")); config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(Collections.singletonList("*")); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } @Bean public CorsFilter corsFilter(CorsConfigurationSource corsConfigurationSource) { return new CorsFilter(corsConfigurationSource); } }

这段配置里几个容易出错的点,我一一说明。

setAllowedOrigins 不要天真地写 "*" 然后又开 allowCredentials(true),因为浏览器规范不允许"通配源 + 携带凭证"的组合,Spring 启动阶段就会直接抛异常。你可以在开发环境写死具体前端地址,生产环境再换成正式域名,或者用 setAllowedOriginPatterns 做更灵活的通配。

setAllowedMethods 必须包含 OPTIONS,这点后面单独展开。setAllowedHeaders 建议直接用 "*",因为前端不知道会在请求头里加什么自定义字段,比如 Authorization、X-Requested-With、Content-Type,只要放开了就省得后续反复补。

setMaxAge 是预检响应在浏览器侧的缓存时间。单位是秒,我一般设 3600。不设置的话,浏览器每次跨域请求都会先发一次 OPTIONS,白白增加一半以上的请求量,接口一多响应速度肉眼可见地掉。

2.3 预检请求与 http.cors() 缺一不可

单独注册 CorsFilter 后,很多人会发现接口还是不通。问题出在 SecurityFilterChain 缺了 http.cors() 这个方法。

Spring Security 里有一个内置的 CorsFilter 注册逻辑,当你调用 http.cors() 时,它会把之前配置好的 CorsConfigurationSource 接入到安全过滤链中。不调用这个方法,虽然你手动注册的 CorsFilter 也会在容器级生效,但 Spring Security 内部的授权规则会先于它执行,OPTIONS 预检请求可能直接被打回。

我见过一个典型场景:后端只注册了 CorsFilter,安全配置里对所有请求要求认证,浏览器发 OPTIONS 预检时没有 token,Security 链直接返回 401,前端看到的依然是跨域失败。所以正确姿势是双保险:

http.cors(cors -> cors.configurationSource(corsConfigurationSource())) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 其他规则... );

http.cors() 让 Security 链在预检请求到达授权判断前,先返回 CORS 响应头;requestMatchers(HttpMethod.OPTIONS).permitAll() 则是兜底,确保任何 OPTIONS 请求都不会被安全规则拦截。这一步不配置,跨域配置必须甩锅给顺序问题。

提示:OPTIONS 请求本身不带业务数据,直接放行不会带来安全风险,真正的业务请求还是要认证。

3. token 失效或为空的响应乱象:自定义 AuthenticationEntryPoint 接管 401

3.1 前端看到的现象和后端日志对不上

上面的跨域问题解决之后,第二个问题就浮出水面了:token 过期,后端明明拦截了请求,前端拿到的却是别的响应。实际联调中我遇到过三种具体表现。

第一种,前端收到 302 跳转。因为 Spring Security 默认的登录入口是表单登录,未认证请求会被重定向到 /login 登录页。后端接口 8080 的 /login 路径没有任何页面,前端拿到的是空 HTML 或者 404。如果前端用了拦截器去读响应状态码,会发现读取到的可能是 200(因为浏览器跟随了重定向),完全不是你想要的 401。

第二种,前端收到 200。有同学在自定义过滤器里做了 token 解析,解析失败后 catch 住了异常,往 response 里写了一句"token 无效",但忘了设置状态码,默认就是 200。前端拿到的 HTTP 状态是成功,响应体里却是一句报错,逻辑上非常拧巴。

第三种,前端收到"网络错误"。JWT 解析抛出的异常如果直接穿透过滤器链,可能会被容器当成 500 处理,或者因为响应头已经写入而出现流关闭异常,浏览器直接报 Network Error,前端一脸懵。

这些表现的共同点就是:后端日志里明明有异常记录,可前端拿到的 HTTP 状态码和响应体都不是预期的东西。两个系统各说各话,谁都觉得不是自己的问题。

3.2 为什么默认配置返回不了 JSON

要理解这个问题,得先明白 Spring Security 处理认证异常的流程。在过滤器链中间有一个异常转换过滤器,它负责捕获链上抛出的 AuthenticationException 和 AccessDeniedException。捕获之后,它不会直接返回响应,而是委托给两个处理器:未认证异常找 AuthenticationEntryPoint,已认证但权限不足找 AccessDeniedHandler。

在前后端分离项目里,你通常没改这两个处理器,于是默认行为就是重定向到登录页或返回一个简单的 403 页面。前端是基于 JSON 来解析响应的,自然拿不到你要的状态码。

还有一个隐藏细节:token 为空和 token 失效,实际上走的是两条不同的路径。

token 为空时,请求在全局视角里是匿名用户,访问受保护资源时 AuthorizationFilter 会抛出 AccessDeniedException,异常转换过滤器发现这是匿名访问,会归类为"未认证",交给 AuthenticationEntryPoint 处理,所以最终应该返回 401。

token 失效时,请求在咱们自己的 JWT 过滤器里就被拦截了。如果过滤器里直接 catch 了异常并返回了响应,那么 Security 链的异常转换逻辑就没机会执行,响应长什么样完全看你 catch 代码有没有写全。如果过滤器自己没 catch,那个解析异常会往上抛,情况就更不可控。

3.3 自定义 AuthenticationEntryPoint 的完整实现

正确的做法,是把"未认证时的响应"完全交给自定义的 AuthenticationEntryPoint,代码里把状态码、响应头、JSON 结构都写清楚:

@Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setCharacterEncoding("UTF-8"); response.setContentType("application/json;charset=UTF-8"); Map<String, Object> result = new LinkedHashMap<>(); result.put("code", 401); result.put("message", "未登录或登录状态已过期,请重新登录"); result.put("data", null); result.put("timestamp", System.currentTimeMillis()); ObjectMapper objectMapper = new ObjectMapper(); response.getWriter().write(objectMapper.writeValueAsString(result)); } }

注意这里用的是 response.getWriter() 直接写 JSON,而不是像 Controller 里那样返回一个 ResponseEntity。因为 AuthenticationEntryPoint 运行在 Servlet 过滤器层面,不经过 DispatcherServlet,你没法用 @RestControllerAdvice 去统一包装它。

配套的 AccessDeniedHandler 也建议一并实现,处理"已登录但权限不够"的情况:

@Component public class RestAccessDeniedHandler implements AccessDeniedHandler { @Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setCharacterEncoding("UTF-8"); response.setContentType("application/json;charset=UTF-8"); Map<String, Object> result = new LinkedHashMap<>(); result.put("code", 403); result.put("message", "当前用户没有权限访问该资源"); result.put("data", null); result.put("timestamp", System.currentTimeMillis()); ObjectMapper objectMapper = new ObjectMapper(); response.getWriter().write(objectMapper.writeValueAsString(result)); } }

3.4 把两个处理器接进 SecurityFilterChain

有了处理器,还得让 Spring Security 知道用它们,在 SecurityConfig 里通过 exceptionHandling 绑定:

http.exceptionHandling(exception -> exception .authenticationEntryPoint(restAuthenticationEntryPoint) .accessDeniedHandler(restAccessDeniedHandler) );

绑完之后,401 和 403 的语义才算正式分家。很多开发者在做权限相关需求时,把"未登录"和"没权限"混为一谈,这里要特别区分一下:

场景HTTP 状态码由谁处理
未携带 token、token 失效、登录过期401AuthenticationEntryPoint
已登录但角色权限不足403AccessDeniedHandler
账号被禁用401 或 403 视业务而定可放在登录校验阶段处理

注意:401 表示"你是谁、你从哪来还没搞清楚";403 表示"知道你是谁,但你不可以干这件事"。前后端拿到这两种状态码后的页面提示通常完全不一样,千万别图省事用同一个处理器。

4. 返工三次才解决的坑:预检被拦、过滤器异常吞噬与 401/403 混用

4.1 坑一:curl 能通、浏览器却 403 的 OPTIONS 拦截

跨域问题初稿改完,我以为已经稳妥了。结果前端同事发来一张截图,浏览器控制台还是报跨域。我第一时间打开浏览器开发者工具,发现某个 POST 请求的网络状态是 403,响应头里没有任何 Access-Control-Allow-* 字段。

这里必须承认一个排查误区:我用 curl 调接口时为了省事,直接写curl -X POST http://localhost:8080/api/xxx -H "Authorization: Bearer ...",没有带 Origin 头,也没有先发 OPTIONS,所以 curl 测出来的结果压根没有触发浏览器跨域那一套流程。

排查链路是这样的:

  1. 用curl -i -X OPTIONS http://localhost:8080/api/xxx -H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: POST"手动模拟预检请求,看到 403 而非正常的 200/204;
  2. 回头检查 SecurityConfig,发现 authorizeHttpRequests 里只对业务接口做了配置,没有给 OPTIONS 方法做放行;
  3. 加上.requestMatchers(HttpMethod.OPTIONS, "/**").permitAll()之后,再测预检,响应头里出现了 Access-Control-Allow-Origin,浏览器正常。
  4. 顺带确认了 http.cors() 方法和 CorsFilter 双存在,避免顺序问题。

这个坑的教训是:先手动把预检请求测通,再让前端联调。后端多花五分钟,前端少折腾半天。

4.2 坑二:token 失效返回 200,前端把错误码当状态码用

第二个坑来自一个历史项目沿用下来的约定。团队里有人习惯把业务异常放在全局异常处理器里统一返回,响应体是{code: 50001, message: "登录过期"},但 HTTP 状态码却一直是 200。这个模式在纯后端开发时问题不大,到了前后端分离之后,前端为了兼容,所有请求都只能读响应体里的 code 字段来判断是否成功。

问题在于 Spring Security 的异常发生在 DispatcherServlet 之前,@RestControllerAdvice 根本接不到。我在 JWT 过滤器里做 token 校验时,解析失败后自己 catch 了异常,往响应体里写了错误信息,但遗漏了response.setStatus(HttpServletResponse.SC_UNAUTHORIZED),于是前端拿到 HTTP 200 + response body 里的 401。前端拦截器第一行代码一般长这样:

if (response.status === 200) { /* 走成功逻辑 */ }

自然就直接把"登录过期"的请求当作成功请求处理了,用户可能被踢到空数据页面,没有任何登录过期的提示。

排查思路是这样的:

  1. 先在前端 Network 面板看响应回包,发现 HTTP 状态码是 200;
  2. 看响应体里 code 字段,果然写的是 401;
  3. 在后端 JwtAuthenticationFilter 打日志,发现进入了 catch 块;
  4. 把 catch 块里的逻辑补上 setStatus(401),并且 return,不再继续走过滤器链。

这个经历让我意识到,前后端分离项目里前端同学已经把"HTTP 状态码 + JSON 响应体"当成一种契约来对待了,后端最好不要自创一套"HTTP 200 表示程序执行完成,业务错误看 code"的玩法,除非整个团队都吃透这套约定。安全强相关场景直接遵循 HTTP 语义,既直观又不容易出错。

4.3 坑三:自定义过滤器 catch 之后"半写半放行"

还有一个更隐蔽的问题,出现在第二次优化的时候。我想把 token 校验失败的逻辑统一放到 JWT 过滤器里,代码写成了这样:

try { String username = jwtUtil.parseToken(token); // ... 设置 SecurityContext } catch (JwtException e) { response.setStatus(SC_UNAUTHORIZED); response.getWriter().write("{\"code\":401,\"message\":\"token无效\"}"); // 忘记 return } chain.doFilter(request, response);

这段代码运行起来的表现非常诡异:状态码确实是 401,但响应体里除了我写的那句 JSON,还会拼上后面过滤器写出的内容,或者因为 response 的 writer 和 output stream 同时被使用,直接抛 IllegalStateException,最终变成网络错误。

原因在于调用 chain.doFilter() 之后,后续过滤器还会继续处理请求,可能在同一个 response 上再写内容。如果你决定在过滤器中直接生成响应,就必须在写完之后立刻 return,终止当前过滤器对后续链的调用。

后来我把这段逻辑重新梳理了一下,给了自己一条清晰的准则:

提示:在 Spring Security 的过滤器链里,处理 token 异常有两套都可行的方案。方案一:在自定义 JWT 过滤器里 catch 异常,写状态码和 JSON 后直接 return,适合想要快速做个性化响应、不想依赖 Security 默认流程的场景;方案二:在 JWT 过滤器里不 catch 异常,让异常继续冒泡,由 ExceptionTranslationFilter 转交给 AuthenticationEntryPoint。两套方案不要混着用,否则容易出现"写了响应又放行"这类半吊子状态。

我最终选择了方案一,因为 JWT 过滤器的职责本来就是提前预检 token,任何 token 相关异常在这里就地消化掉,语义最清晰。

4.4 通用排查套路:如何快速定位是"哪一层"拦截了请求

这三轮返工之后,我整理了一个可复用的定位套路,遇到类似的问题不用再逐行猜:

第一步,在 SecurityFilterChain 的每个关键节点打日志,包括 CorsFilter 前后、自定义 JWT 过滤器前后、AuthorizationFilter 前后。不要嫌日志多,跨域和 token 问题本来就难观察,有日志能省一小时;

第二步,用 curl 手动构造三类请求——带正确 token、带错误 token、不带 token,分别观察响应状态码和响应头。把这三份输出和浏览器里的网络请求对比,能快速锁定是浏览器封杀还是后端拒绝;

第三步,把 SecurityConfig 改到最简,逐渐加规则。很多时候规则之间会互相干扰,比如放行了 OPTIONS 但漏了放行某个公开接口,或者 CORS 跨域没问题却卡在了 CSRF 校验上。从最小配置开始加,每次只加一条规则,测一遍,能快速找到互相影响的配置项。

5. 拿得出手的完整配置:SecurityFilterChain 模板与验证清单

5.1 一份可直接改动的安全配置

把前面所有要点整合起来,我在当前项目中采用的安全配置大致是这个样子:

@Configuration @EnableWebSecurity public class SecurityConfig { private final RestAuthenticationEntryPoint authenticationEntryPoint; private final RestAccessDeniedHandler accessDeniedHandler; private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CorsConfigurationSource corsConfigurationSource; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .cors(cors -> cors.configurationSource(corsConfigurationSource)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .httpBasic(httpBasic -> httpBasic.disable()) .formLogin(formLogin -> formLogin.disable()) .exceptionHandling(exception -> exception .authenticationEntryPoint(authenticationEntryPoint) .accessDeniedHandler(accessDeniedHandler)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/api/auth/login", "/api/auth/captcha", "/api/public/**").permitAll() .anyRequest().authenticated()) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } }

几个配置点逐一说明,防止大家抄作业时抄错:

csrf 禁用:前后端分离加 JWT 后,身份信息在请求头里,不依赖 Cookie,CSRF 的防护对象已经不存在。如果你保留 CSRF,会发现所有 POST/PUT 请求都被拒绝,因为前端没有生成 token 放到请求头。

sessionManagement 设为 STATELESS:让 Spring Security 不创建也不读取 Session,避免里外两层状态管理打架。这也是 JWT 方案的标准姿势。

httpBasic 和 formLogin 都禁用:不启用浏览器弹窗登录,也不用默认的登录页,因为这两个默认行为都会在未认证时触发重定向。我们要的只是自定义 entryPoint 的 401 JSON。

5.2 JWT 过滤器与 Security 链的协作关系

自定义 JWT 过滤器是整个链上承上启下的一环,我把它加在 UsernamePasswordAuthenticationFilter 之前,就是为了在 Spring Security 还没走表单认证时,先把 token 解析成 Authentication 放进 SecurityContext:

@Component public class JwtAuthenticationFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { String token = resolveToken(request); if (token == null || token.isBlank()) { chain.doFilter(request, response); return; } try { // 解析 token:这里会包含签名校验、过期时间校验 Claims claims = jwtUtil.parseToken(token); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(claims.getSubject(), null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } catch (JwtException e) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"message\":\"token无效或已过期\",\"timestamp\":" + System.currentTimeMillis() + "}"); return; } chain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearer = request.getHeader("Authorization"); if (bearer != null && bearer.startsWith("Bearer ")) { return bearer.substring(7); } return null; } }

这段代码里隐藏着一个顺序陷阱。当 token 为空时,我选择直接放行,让请求走后面的授权过滤器。遇到受保护接口时,Security 会认为当前用户是匿名用户,抛出异常并转交给 entryPoint,最终同样是 401。表面上看,"filter 里直接放行"和"filter 里直接返回 401"都能得到 401,但前者能让 Security 链完整走一遍,也方便后续接入更复杂的认证逻辑;后者则绕过了安全链,响应完全由自定义代码掌控。

你只需要记住一个原则:token 为空时放行,交给 Security 统一裁定;token 非空但解析失败时立即返回,不要在无效身份上继续做授权。

5.3 curl 验证清单:联调前先自测这三类请求

最后把我在项目里固定执行的验证清单分享出来,每次改完安全配置,用这几条命令走一遍再喊前端联调:

# 1. 预检请求(跨域先决条件) curl -i -X OPTIONS http://localhost:8080/api/user/info \ -H "Origin: http://localhost:5173" \ -H "Access-Control-Request-Method: GET" # 2. 不带 token 访问受保护接口 curl -i http://localhost:8080/api/user/info # 3. 带过期 token 访问受保护接口 curl -i http://localhost:8080/api/user/info \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.invalid.token"

期望结果整理成表格,方便对照:

验证场景预期 HTTP 状态预期响应特征
OPTIONS 预检请求200 或 204响应头含 Access-Control-Allow-Origin、Allow-Methods、Allow-Headers
不带 token401JSON body,含 code=401,无 HTML
带过期 token401JSON body,含 code=401,无 HTML
带低权限 token 访问管理员接口403JSON body,含 code=403

另外我一般还会加上一个额外的确认:检查 OPTIONS 响应头里的 Access-Control-Max-Age 是不是有值。如果有,说明预检已经被浏览器缓存,后续同一域下的请求不会再频繁触发 OPTIONS,性能方面也放心。

这套验证结束后,我才会把接口交给前端联调。实践下来,前端报跨域的概率几乎清零,token 失效的场景也终于能弹出预期的"登录已过期"提示框了。

最后再分享一点切身体会:这类问题九成不是"配置没写",而是"过滤器顺序没搞对"。建议你在动手改配置前,先在纸上把从请求进入到 Controller 要经过的过滤器画一遍,再对照代码一一点名。CORS 配置和 Security 配置别写在同一个类里揉成一团,CORS 独立建一个配置类,SecurityConfig 只负责链的编排,出问题的时候排查起来会舒服很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询