1. 同一个302背后,跨域和重定向是两回事
先说个非常典型的场景:前端用浏览器访问http://localhost:5173,后端接口跑在http://localhost:8080,两边端口不同,铁定跨域。前端拿着账号密码往登录接口发POST,结果在开发者工具里看到状态码是302,而不是预期的401。前端同事的第一反应就是"跨域没配好",后端同事看了一眼Network面板说"重定向了,这是Spring Security的正常行为"。两边都没说错,但这个302到底为什么会出现、为什么浏览器端看起来像跨域报错,其实是两套机制叠在一起的结果。
在这个问题里,输入的核心关键词是"Spring Security""302""跨域""前后端分离"。很多人排查了半天下不了手,就是因为把"Spring Security返回重定向"和"浏览器CORS拦截"当成一个问题来处理。实际上,这是两个独立的问题,只是在浏览器登录失败的时候恰好同时爆发。
如果想彻底解决,必须先把两件事分开看。第一,Spring Security为什么会在登录失败时返回302;第二,在跨域场景下,浏览器拿到这个302之后到底做了什么。把这两条链路捋清楚,后面写配置就是顺手的事,不会再靠猜。
2. 拆解Spring Security默认表单登录:302是设计如此
2.1 表单登录模式下,失败后会重定向到登录页
Spring Security默认的认证方式就是表单登录,也就是formLogin()。这个默认配置大批量存在于旧项目中,很多基于Spring Boot的后端在没有显式声明认证方式时,跑的就是这套逻辑。表单登录的认证信息传递,靠的是用户名和密码字段,认证成功后Spring Security会把用户信息塞进SecurityContext,然后默认重定向到之前请求的地址;认证失败则默认重定向到登录页,然后附带一个?error参数。
所以当你的前端POST登录失败时,后端根本没有返回401 JSON。Spring Security会直接响应一个302,Location指向登录页路径。如果你的登录页配置的是/login,那么响应头会是:
HTTP/1.1 302 Location: /login?error这个行为对传统Web项目是合理的,因为服务器要把浏览器踢回登录页重新输入账号密码。但前后端分离后,前端是独立的SPA,后端不该再有"重定向到页面"这种职权。前端需要的是结构化错误信息,比如{"code":401,"message":"用户名或密码错误"},然后由前端路由跳转或弹提示。
2.2 源码层面看"谁"发出了这个302
Spring Security的处理链里,异常处理核心是ExceptionTranslationFilter。当登录认证抛出了AuthenticationException,这个过滤器会根据异常类型找对应的AuthenticationEntryPoint。在formLogin配置下,默认的AuthenticationEntryPoint是LoginUrlAuthenticationEntryPoint,它拿到异常后直接执行重定向。
源码里关键的是这个方法:
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { String redirectUrl = buildRedirectUrlToLoginPage(request, response, authException); redirectStrategy.sendRedirect(request, response, redirectUrl); }buildRedirectUrlToLoginPage会构造登录页的URL,然后sendRedirect向浏览器回应302。所以结论很明确:只要你的认证请求是未通过验证的,Spring Security的默认入口点就会让浏览器跳转。这个重定向跟跨域没有任何关系,纯粹是表单登录的设计逻辑。
当你前后端分离后,直接沿用formLogin又不做任何自定义,那么登录失败返回302是必然结果。如果你尝试在application.yml里配置登录页地址,比如loginPage("/api/login"),它也只是换一个重定向目标,并不会变成JSON响应。
2.3 认证失败处理器也被"跳过"了
很多人会在代码里写这样的配置:
http.formLogin() .failureHandler((request, response, exception) -> { response.setStatus(401); response.getWriter().write("{\"code\":401}"); });配置好之后一测试,发现还是302。这里有个容易忽略的措辞:failureHandler确实会被调用,但如果安全过滤器链在认证阶段抛异常时先被ExceptionTranslationFilter拦截,而入口点又被配置成重定向,那么重定向发生在认证失败处理之前还是之后?实际上,ExceptionTranslationFilter是在异常发生时先通过AuthenticationEntryPoint来处理,而这个处理并不一定调用failureHandler。
简单说:AuthenticationFailureHandler是"认证处理器"内部使用的"失败回调",只有认证流程真正进入具体的认证Provider并返回认证失败结果时,它才会被触发;如果是匿名用户直接访问需要认证的资源,走的是AuthenticationEntryPoint。登录接口本身的POST请求,如果在FilterSecurityInterceptor阶段因为未认证被拦截,那么就是AuthenticationEntryPoint触发重定向,压根不会走到failureHandler。
所以把failureHandler写在formLogin里,不代表就能生效。这也是让很多后端纠结半天的原因之一。
3. 浏览器跨域策略如何让302雪上加霜
3.1 CORS预检请求可能直接卡在安全链路上
跨域场景下,如果前端发的请求不是"简单请求",浏览器会先发一个OPTIONS预检请求,询问服务器允不允许真实请求。常见的触发条件是请求头里带Content-Type: application/json,或者前端手动设置了Authorization头。
问题是,Spring Security的过滤器链默认情况下会拦截/login路径下的OPTIONS请求。因为安全过滤器认为OPTIONS也是一个HTTP请求,没有认证就不放行。这时OPTIONS请求要么返回403,要么被重定向到登录页,反正不是正常的CORS响应。前端浏览器收到预检失败后,真实请求压根不会发出去,Network面板里看到的还是302或403。
如果你后端配了@CrossOrigin或全局CorsFilter,但Spring Security的过滤器链没放行OPTIONS,预检依旧会被安全策略拦截。这就是为什么很多人写了CORS配置还是没有用。
3.2 重定向响应里没有CORS头,浏览器也会报错
即便预检通过了,真实请求发出去了,假设Spring Security又抛出一个302,那么重定向响应(302本身)也必须有正确的Access-Control-Allow-Origin头。否则浏览器会认为跨域响应不合法,直接拦截掉。前端在fetch里看到的不是302,而是TypeError: Failed to fetch这种模糊错误。
更麻烦的是,浏览器对重定向还有自己的处理策略。如果fetch配置了redirect: 'follow',浏览器会透明地跟随302去访问Location地址。如果Location指向的是后端同源地址,比如http://localhost:8080/login?error,那么浏览器会把最终那个页面(HTML)拿回来。前端拿到的就是一堆HTML字符串,根本解析不了JSON。这也是前后端分离项目中最常见的"登录失败返回302"表象。
3.3 跨域配置和重定向叠加的典型表现
归纳一下,浏览器端看到的302,实际上有两个来源:
| 来源 | 触发条件 | 表现 |
|---|---|---|
| Spring Security默认重定向 | 认证失败/未认证访问受保护资源 | 302带Location到登录页 |
| 浏览器CORS拦截 | 预检失败或响应头缺少CORS头 | 直接Canceled / Failed to fetch |
| 跟随重定向后返回HTML | 前端设置follow + 后端302到页面 | 拿到HTML而不是JSON |
很多前端把最后一种情况误判成"跨域问题",后端则坚持"重定向是Spring Security的正常行为"。两边都没错,但都不完整。真正的问题是后端不应该在前后端分离项目中继续用默认表单登录的重定向模式。
4. 落地修复:一套完整的JSON化+CORS配置
4.1 明确目标:不要HTML重定向,只要JSON响应
修复的原则其实很简单:把Spring Security从"传统重定向模式"切换到"API响应模式"。我们需要做四件事:
- 关闭默认表单登录的重定向行为;
- 自定义认证成功和失败处理器,返回JSON;
- 提供自定义认证入口点,未认证请求也返回JSON而不是302;
- 配置合法的CORS并确保OPTIONS请求能通过安全链路。
4.2 编写认证失败处理器和成功处理器
先写最核心的失败处理器。注意这里要直接输出JSON,同时设置模拟状态码:
@Component public class ApiAuthenticationFailureHandler implements AuthenticationFailureHandler { @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=UTF-8"); String message = exception.getMessage(); if (exception instanceof BadCredentialsException) { message = "用户名或密码错误"; } response.getWriter().write( "{\"code\":401,\"message\":\"" + message + "\"}" ); } }再写成功处理器,让认证成功也返回JSON,避免后端又自动跳转页面:
@Component public class ApiAuthenticationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { response.setStatus(HttpStatus.OK.value()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write( "{\"code\":200,\"message\":\"登录成功\",\"username\":\"" + authentication.getName() + "\"}" ); } }4.3 自定义AuthenticationEntryPoint
这一步非常关键,特别是在前后端分离架构下。当匿名用户访问一个需要认证的接口时,默认入口点会重定向到登录页。我们要把它改成返回401 JSON:
@Component public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { response.setStatus(HttpStatus.UNAUTHORIZED.value()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write( "{\"code\":401,\"message\":\"未登录或登录已过期\"}" ); } }有了这个入口点,即使前端直接访问某个受保护接口,返回的也会是401 JSON,而不是302重定向。这个配置能解决大量API调用中的奇怪重定向问题。
4.4 Spring Security主配置
接下来把它们串起来。注意下面这段代码给出了核心配置结构,适用于Spring Boot 2.7+和Spring Security 5.8+,方法名和写法可能会随版本微调,但思路不变:
@Configuration @EnableWebSecurity public class SecurityConfig { private final ApiAuthenticationFailureHandler failureHandler; private final ApiAuthenticationSuccessHandler successHandler; private final ApiAuthenticationEntryPoint entryPoint; public SecurityConfig(ApiAuthenticationFailureHandler failureHandler, ApiAuthenticationSuccessHandler successHandler, ApiAuthenticationEntryPoint entryPoint) { this.failureHandler = failureHandler; this.successHandler = successHandler; this.entryPoint = entryPoint; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .cors(withDefaults()) .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .anyRequest().authenticated() ) .exceptionHandling(e -> e.authenticationEntryPoint(entryPoint)) .formLogin(form -> form .successHandler(successHandler) .failureHandler(failureHandler) .permitAll() ) .logout(logout -> logout.logoutSuccessHandler( (request, response, authentication) -> { response.setStatus(HttpStatus.OK.value()); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":200,\"message\":\"退出成功\"}"); } )); return http.build(); } @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config = new CorsConfiguration(); config.setAllowedOrigins(List.of("http://localhost:5173")); config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); config.setAllowedHeaders(List.of("*")); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", config); return source; } }这里有个容易踩的细节:如果你在CorsConfigurationSource里设置了allowCredentials(true),那么allowedOrigins不能写成*,必须指定具体的来源地址。如果写*再开credentials,Spring会在配置阶段直接报错。浏览器端要求是:Access-Control-Allow-Origin不能是通配符且同时带Access-Control-Allow-Credentials: true。前后端分离项目如果登录状态依赖Cookie,必须把这个开关打开,否则浏览器不会带上凭证。
4.5 为什么关闭CSRF
上面的配置里csrf(disable)了,这是前后端分离中很常见的做法。如果你用的是JWT或OAuth2的Bearer Token方式认证,登录本身不依赖Cookie,关闭CSRF是安全的。如果前端还在使用基于Session的Cookie认证,那么关闭CSRF会带来安全隐患,但你又需要保留CSRF Token。实际上Spring Security的CSRF默认拦截POST等写操作,如果你没有给登录接口附带CSRF Token,登录请求会被403拦截,然后又被重定向/异常处理,连锁反应很乱。
所以这里需要做一个权衡:一般前后端分离、接口由Token保护的项目,建议关闭CSRF并改用请求头传递认证信息;如果坚持用Session,就一定要引入CSRF Token,并且让前端每次请求都携带该Token。为了演示"登录失败返回302跨域问题"的修复链路,下面的方案采用关闭CSRF是最直白的,适合大部分内部系统或中小型项目。
4.6 如果项目里还有自定义JWT过滤器,也得处理OPTIONS
很多Spring Security项目会自己写一个JWT认证过滤器。如果自定义过滤器用OncePerRequestFilter,并且在里面做了getHeader("Authorization")的判断,那这些过滤器也会提前拦住OPTIONS预检请求,因为它认为没有Token就是未认证。此时,你必须在过滤器里加一个判断:
if (request.getMethod().equalsIgnoreCase("OPTIONS")) { response.setStatus(HttpServletResponse.SC_OK); return; }这个判断要放在过滤器最开始,否则预检请求又会在安全过滤器之前失败。前面SecurityConfig里放行OPTIONS只是放行了Spring Security的路径规则,自定义过滤器是独立于它的,还是得自己做兼容。
5. 验证与排雷:从preflight到真正登录成功
5.1 用curl确认后端响应是否正确
在加前端之前,先用命令行直接看后端接口返回,避免"前后端各执一词"。如果后端返回的是一串JSON而不是HTML,说明后端逻辑已经正确:
curl -i -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"wrong"}'正常响应应该是:
HTTP/1.1 401 Content-Type: application/json {"code":401,"message":"用户名或密码错误"}如果还是302,说明配置没有生效,优先检查是否有多个SecurityFilterChain或配置类被覆盖。
5.2 用OPTIONS预检请求测试CORS配置
模拟浏览器预处理:
curl -i -X OPTIONS http://localhost:8080/api/auth/login \ -H "Origin: http://localhost:5173" \ -H "Access-Control-Request-Method: POST" \ -H "Access-Control-Request-Headers: content-type"预期响应头应该包含:
Access-Control-Allow-Origin: http://localhost:5173 Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS Access-Control-Allow-Headers: content-type Access-Control-Allow-Credentials: true如果没有这些头,回去检查CorsConfigurationSource和http.cors()是否都配了。只配了Spring MVC的全局CORS而Spring Security没有感知,依然会出现这个问题。
5.3 前端联调时确认redirect模式和withCredentials
前端如果使用fetch,建议显式设置:
fetch('/api/auth/login', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: 'admin', password: 'wrong' }) })同时,不要把redirect设置为'follow'之外的模式。如果后端配置正确,它不会返回302,所以这个值其实影响不大。但如果你是排查阶段,建议把redirect: 'manual'先试一次,手工检查response的redirected属性和status,能帮助你判断问题在哪一环节。
用axios时注意withCredentials: true必须设置,否则即使后端CORS允许携带Cookie,前端也发不出凭证,登录状态会一直建立不起来:
axios.post('/api/auth/login', data, { withCredentials: true })5.4 排查清单
整理了一份配置检查表,用来对冲"心血来潮改漏一个"的尴尬:
| 检查项 | 是否配置 | 影响 |
|---|---|---|
cors()开启 | 是/否 | 未开启则CORS过滤器不进安全链 |
CorsConfigurationSourceBean | 是/否 | 未配置则无法自定义来源 |
allowedOrigins与前端地址一致 | 是/否 | 不一致则被浏览器拦截 |
allowCredentials(true)且允许具体源 | 是/否 | *+ credentials会冲突 |
csrf状态与前端匹配 | 是/否 | Token下应禁用,Cookie下应保留 |
| OPTIONS路径放行 | 是/否 | 未放行则预检失败 |
| 自定义JWT过滤器放过OPTIONS | 是/否 | 未放过则预检失败 |
| 认证失败/成功/入口点返回JSON | 是/否 | 保留默认则仍是302 |
这八项里只要有一项没对齐,浏览器端的表现就可能还停留在"登录失败返回302"。但有了这张表,排查顺序就很清晰了:先用curl排查后端,再用OPTIONS排查CORS,最后用浏览器DevTools看着请求头、响应头逐项对照。
5.5 常见误区:别把异常处理器和Security混在一起
很多后端同学喜欢在全局@RestControllerAdvice里写@ExceptionHandler(AuthenticationException.class),期待它能智能返回401 JSON。但实际上AuthenticationException在Spring Security过滤器链中被抛出,根本不会进入Spring MVC的异常处理机制。过滤器链还在DispatcherServlet之前,全局异常处理器管不到那一层。所以你只能在Spring Security的AuthenticationFailureHandler或AuthenticationEntryPoint里去输出JSON,而不是在ControllerAdvice里处理。
还有一类做法是在formLogin里配failureUrl("/login?error")然后把/login也返回JSON,但这样做很容易把未认证访问重定向和登录失败重定向搞混,而且前端发起的POST会被改成GET,语义已经坏了。建议还是老老实实用handler。
6. 我个人的一个体会
拿这个302折腾过几天之后,我有一个很深的感受:前后端分离的项目里,后端做安全控制时不要惯性沿用默认配置。Spring Security是个配置化非常重的框架,默认配置是为传统服务端渲染设计的,它不是Bug,但确实会让API项目很不舒服。遇到跨域和重定向问题最怕的就是在浏览器面板里反复刷Network,不如先回到命令行,把"后端到底输出了什么"看清。
如果要给一句浓缩的经验,那就是——先保证后端的响应是体面的JSON,再让浏览器跨域规则也体面。两者之间,是Spring Security定制化的一道小门槛。过了这道门槛,你的登录接口才算真正适配了前后端分离的架构,也才敢说把这个302彻底解决了。
最后留一个小技巧:在开发环境用域名而不是localhost时,注意allowedOrigins的端口和协议必须和前端页面地址完全一致,包括空格。一个多余的空格就足以让浏览器静默拦截,而这个问题通常比302更隐蔽。