1. 前后端分离下跨域问题的本质拆解1.1 为什么Vue访问Shiro接口会触发跨域拦截做过前后端分离项目的人基本都踩过这个坑前端用Vue起在本地某个端口后端SpringBoot跑在另一个端口浏览器F12一打开控制台红彤彤一片CORS报错请求根本到不了后端。更让人头疼的是明明在Controller上加了CrossOrigin注解普通接口能通了可一旦涉及到Shiro的认证接口又开始报跨域甚至有时候报的是401而不是CORS错误排查起来一头雾水。这个问题的本质得从浏览器的同源策略说起。所谓同源就是协议、域名、端口三者完全一致。Vue开发服务器默认跑在localhost:8080或者你自定义的端口SpringBoot默认跑在localhost:8081端口不同浏览器就判定为跨域请求。对于简单请求浏览器直接放行但会检查响应头对于非简单请求比如带Content-Type: application/json的POST或者带自定义Header的请求浏览器会先发一个OPTIONS预检请求问后端“我能不能这么请求你”后端必须正确响应这个预检真正的请求才会发出去。Shiro在这里扮演了一个“拦路虎”的角色。Shiro的过滤器链默认会拦截所有请求包括那个OPTIONS预检请求。预检请求本身是不带认证信息的Shiro一看没带Token或者Session直接返回401或者重定向到登录页浏览器收到这个响应发现预检没通过于是真正的请求压根不会发出控制台就报CORS错误。这就是为什么很多人发现“普通接口加个注解就好了Shiro接口死活不行”的根本原因——问题不在跨域配置本身而在于Shiro把预检请求给拦截了。理解这一点非常关键因为后面所有的解决方案本质上都是在解决两个问题第一让浏览器认可跨域第二让Shiro放行预检请求。这两个问题必须同时解决缺一不可。1.2 跨域问题的三种典型表现与误判在实际排查中跨域问题往往不会以“标准CORS错误”的形式出现而是会伪装成其他问题导致排查方向跑偏。我总结下来常见的表现有三种。第一种是控制台明确报CORS错误类似“Access to XMLHttpRequest at ‘xxx’ from origin ‘xxx’ has been blocked by CORS policy”这种最直观说明预检请求没通过或者响应头缺失。第二种是报401 Unauthorized很多人以为是Token过期了反复检查Token其实是因为预检请求被Shiro拦截返回了401浏览器把这个401当成了预检失败。第三种是请求发出去了但响应被浏览器拦截Network面板里能看到请求状态是200但前端拿不到数据控制台报CORS这种情况通常是响应头里缺少Access-Control-Allow-Origin。注意排查跨域问题时一定要先看Network面板里有没有OPTIONS请求以及这个请求的响应状态码。如果OPTIONS请求返回401或302那问题百分之百出在Shiro拦截了预检请求而不是跨域配置写错了。我见过不少开发者在这个环节浪费大量时间反复调整CrossOrigin注解的参数甚至怀疑是Vue的axios配置有问题其实方向完全错了。正确的做法是先把OPTIONS请求放行再处理跨域响应头顺序不能乱。1.3 方案选型的核心考量维度解决这个问题的方案有好几种但并不是每种都适合所有场景。选型时我一般会考虑四个维度项目阶段开发期还是生产期、部署架构是否用Nginx反向代理、认证方式Session还是Token、维护成本配置是否集中、是否容易出错。开发期最常用的是后端配置CORS因为改起来快前端不用动。生产期如果用了Nginx通常会在Nginx层做反向代理让前后端同源从根上消除跨域。如果认证用的是Token比如JWT那Shiro的过滤器链配置相对简单放行OPTIONS即可如果用的是Session还要考虑withCredentials和Allow-Credentials的配合坑更多。下面这张表是我在实际项目中总结的方案对比可以作为选型参考。方案适用阶段优点缺点推荐指数后端全局CORS配置开发期配置集中一次搞定生产环境暴露过多高Nginx反向代理生产期彻底消除跨域安全需要运维配合高单接口CrossOrigin临时调试简单直接接口多了很乱低前端代理devServer开发期前端独立解决仅开发期有效中选型的核心逻辑是开发期用后端CORS配置提效生产期用Nginx反向代理保安全。两者结合基本能覆盖绝大多数前后端分离项目。至于前端devServer代理适合前端主导、后端不方便改代码的场景但它只在开发期有效上线后还是得靠后端或Nginx。2. Shiro过滤器链与CORS的冲突原理2.1 Shiro过滤器链的执行顺序解析要彻底搞懂这个问题必须理解Shiro过滤器链的执行机制。Shiro的ShiroFilterFactoryBean在初始化时会按照你配置的filterChainDefinitionMap顺序构建一条过滤器链。请求进来后会从上到下依次匹配第一个匹配上的过滤器链就会生效后面的不再执行。这里有个非常关键的细节Shiro的过滤器链匹配是基于URL模式的而OPTIONS请求的URL和真实请求的URL是一样的。比如你的登录接口是/api/login预检请求的URL也是/api/login只是Method不同。Shiro默认不区分Method所以预检请求会命中同一个过滤器链被authc或者你自定义的认证过滤器拦截。我在实际项目中遇到过这样的情况配置了/api/** authc结果所有/api下的预检请求全被拦截。后来改成/api/** anon又太不安全所有接口都放行了。正确的做法是在过滤器链的最前面加一条针对OPTIONS请求的放行规则但Shiro的URL匹配不支持直接按Method过滤所以需要自定义一个过滤器。2.2 预检请求被拦截的完整链路还原我们把整个链路还原一下这样你排查问题时心里就有谱了。Vue发起一个POST请求到/api/user/info带上了Authorization头。浏览器发现这是非简单请求先发一个OPTIONS请求到/api/user/info不带Authorization头。请求到达SpringBoot进入Shiro过滤器链。Shiro匹配到/api/** authc执行认证过滤器。认证过滤器发现没有Token返回401或者重定向。浏览器收到401判定预检失败真正的POST请求不发出。控制台报CORS错误。整个链路里第4步和第5步是问题核心。很多人只关注第7步的CORS错误去改跨域配置但根因在第5步。所以解决方案必须让第4步匹配到一个放行OPTIONS的过滤器或者让认证过滤器识别OPTIONS请求并直接放行。2.3 自定义过滤器放行OPTIONS请求的实现最稳妥的方案是自定义一个过滤器专门处理OPTIONS请求。这个过滤器要放在过滤器链的最前面优先级最高。下面是我常用的实现代码。import org.apache.shiro.web.filter.PathMatchingFilter; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; public class CorsFilter extends PathMatchingFilter { Override protected boolean onPreHandle(ServletRequest request, ServletResponse response, Object mappedValue) throws Exception { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; // 设置跨域响应头 resp.setHeader(Access-Control-Allow-Origin, req.getHeader(Origin)); resp.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); resp.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization, X-Requested-With); resp.setHeader(Access-Control-Allow-Credentials, true); resp.setHeader(Access-Control-Max-Age, 3600); // 如果是OPTIONS预检请求直接返回200不放行到后续过滤器 if (OPTIONS.equalsIgnoreCase(req.getMethod())) { resp.setStatus(HttpServletResponse.SC_OK); return false; } return true; } }这段代码的关键点有三个。第一Access-Control-Allow-Origin不能写死成*因为如果前端带了withCredentials浏览器不允许*必须回显具体的Origin。第二Access-Control-Allow-Credentials设为true时Allow-Origin必须是具体域名。第三OPTIONS请求直接返回false表示拦截链到此结束不再往下走这样就不会被后续的authc过滤器拦截。然后在Shiro配置里注册这个过滤器放在最前面。Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager) { ShiroFilterFactoryBean factoryBean new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); MapString, Filter filters new LinkedHashMap(); filters.put(cors, new CorsFilter()); factoryBean.setFilters(filters); MapString, String chainMap new LinkedHashMap(); chainMap.put(/**, cors); chainMap.put(/api/login, anon); chainMap.put(/api/**, authc); factoryBean.setFilterChainDefinitionMap(chainMap); return factoryBean; }注意chainMap的顺序/**放在最前面匹配所有请求但因为我们用的是LinkedHashMap它会按插入顺序匹配/**会先匹配上然后执行cors过滤器。cors过滤器对OPTIONS返回false对普通请求返回true继续往下走。但这里有个问题/**匹配后后面的/api/**就不会再匹配了因为Shiro的过滤器链是“第一个匹配即生效”。所以这种写法会导致所有请求都只走cors过滤器authc不生效。正确的写法应该是把cors过滤器单独配置或者用filterChainDefinitions的字符串形式让cors作为全局过滤器而不是链上的一个节点。更简单的做法是直接在CorsFilter里继承OncePerRequestFilter注册为Spring的Bean这样它在Shiro之前执行不参与Shiro的过滤器链匹配。import org.springframework.core.Ordered; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import javax.servlet.*; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; Component Order(Ordered.HIGHEST_PRECEDENCE) public class GlobalCorsFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) res; response.setHeader(Access-Control-Allow-Origin, request.getHeader(Origin)); response.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); response.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization, X-Requested-With); response.setHeader(Access-Control-Allow-Credentials, true); response.setHeader(Access-Control-Max-Age, 3600); if (OPTIONS.equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); return; } chain.doFilter(req, res); } }用Order(Ordered.HIGHEST_PRECEDENCE)保证这个过滤器在Shiro过滤器之前执行OPTIONS请求直接返回不进入Shiro。普通请求设置好响应头后继续走chain.doFilter进入Shiro的过滤器链。这样既解决了预检拦截又保证了认证逻辑正常。3. SpringBoot端CORS配置的完整实操3.1 全局CORS配置的三种写法与取舍SpringBoot端配置CORS有三种主流写法各有适用场景。第一种是WebMvcConfigurer的addCorsMappings第二种是CorsFilterBean第三种是CrossOrigin注解。我一般推荐第一种和第二种结合使用。WebMvcConfigurer的写法最简洁适合大多数场景。import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里有个细节要注意SpringBoot 2.4之后allowedOrigins(*)和allowCredentials(true)不能同时使用会报错。必须用allowedOriginPatterns(*)代替。这个坑我踩过当时升级SpringBoot版本后跨域突然失效排查了半天才发现是版本兼容问题。CorsFilterBean的写法更底层优先级更高适合需要精细控制的场景。import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; Configuration public class CorsFilterConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }这两种写法的区别在于WebMvcConfigurer是在SpringMVC层面处理跨域CorsFilter是在Servlet过滤器层面处理。如果Shiro的过滤器在SpringMVC之前执行那WebMvcConfigurer可能来不及生效这时候必须用CorsFilter。我一般两个都配上双保险。3.2 与Shiro整合时的配置顺序陷阱配置顺序是这里最大的坑。SpringBoot中过滤器的执行顺序由Order或者FilterRegistrationBean的order属性决定。Shiro的过滤器默认order是DEFAULT_FILTER_ORDER也就是最低优先级。如果你用Bean注册CorsFilter它的order默认也是最低两者谁先执行不确定。正确的做法是显式指定CorsFilter的order让它比Shiro先执行。import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.Ordered; Configuration public class FilterConfig { Bean public FilterRegistrationBeanCorsFilter corsFilterRegistration() { FilterRegistrationBeanCorsFilter registration new FilterRegistrationBean(); registration.setFilter(new CorsFilter()); registration.addUrlPatterns(/*); registration.setOrder(Ordered.HIGHEST_PRECEDENCE); registration.setName(corsFilter); return registration; } }Ordered.HIGHEST_PRECEDENCE的值是Integer.MIN_VALUE保证这个过滤器最先执行。Shiro的过滤器order通常是0或者默认值排在后面。这样OPTIONS请求在CorsFilter里就被处理掉了根本不会进入Shiro。提示如果你用的是Shiro的ShiroFilterFactoryBean它注册的过滤器order是DEFAULT_FILTER_ORDER值是Ordered.LOWEST_PRECEDENCE。所以只要你的CorsFilter order比它小就能保证先执行。我见过有人把CorsFilter和ShiroFilter的order配成一样的结果执行顺序随机有时候好有时候坏这种问题最难排查。所以order一定要显式指定不要依赖默认值。3.3 允许携带凭证时的Origin配置要点当前端需要携带Cookie或者Authorization头时axios要设置withCredentials: true这时候后端的CORS配置有几个硬性要求。第一Access-Control-Allow-Origin不能是*必须是具体的Origin。浏览器会检查这个值是否和请求的Origin一致不一致就拒绝。第二Access-Control-Allow-Credentials必须是true。第三Access-Control-Allow-Headers不能是*必须列出具体的头名称比如Authorization, Content-Type。config.setAllowCredentials(true); config.addAllowedOriginPattern(http://localhost:8080); config.addAllowedHeader(Authorization); config.addAllowedHeader(Content-Type); config.addAllowedHeader(X-Requested-With);如果前端有多个开发环境Origin不固定可以用addAllowedOriginPattern配合通配符但要注意allowCredentials为true时allowedOriginPatterns不能是*得写成具体的模式比如http://localhost:*。config.addAllowedOriginPattern(http://localhost:*); config.addAllowedOriginPattern(http://127.0.0.1:*);这样配置后本地开发时不管Vue跑在哪个端口都能正常跨域。上线后把生产域名加进去即可。4. Vue前端的配合配置与调试技巧4.1 axios全局配置与拦截器设置前端这边axios的配置也很关键。我一般会在request.js里做全局配置。import axios from axios const service axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000, withCredentials: true }) service.interceptors.request.use( config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] Bearer token } return config }, error { return Promise.reject(error) } ) service.interceptors.response.use( response { return response.data }, error { if (error.response error.response.status 401) { // 处理未认证 } return Promise.reject(error) } ) export default servicewithCredentials: true这个配置必须加否则跨域请求不会带Cookie。但加了之后后端的Allow-Origin就不能是*前面已经说过了。Authorization头的设置也要注意如果后端Shiro用的是Token认证这个头必须带上而且要在CORS的Allow-Headers里列出来。4.2 开发环境devServer代理的配置方法如果后端不方便改代码或者你想在前端层面先解决跨域可以用Vue CLI的devServer代理。在vue.config.js里配置。module.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:8081, changeOrigin: true, pathRewrite: { ^/api: } } } } }这样前端请求/api/logindevServer会代理到http://localhost:8081/login浏览器看到的是同源请求不会有跨域问题。changeOrigin: true的作用是把请求头里的Host改成目标服务器的Host有些后端会校验Host不加这个可能被拒绝。但要注意devServer代理只在开发期有效打包上线后就没用了。上线后要么用Nginx反向代理要么后端配置CORS。所以这只是个临时方案不能作为最终解决方案。4.3 浏览器调试跨域问题的实用技巧调试跨域问题时有几个技巧能帮你快速定位。第一在Network面板里勾选“Preserve log”这样页面跳转后请求记录不会丢失能看到完整的预检和真实请求。第二看OPTIONS请求的Response Headers确认Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers是否都正确返回。第三如果OPTIONS请求返回401或302直接去查Shiro的过滤器链配置不用看跨域配置了。还有一个技巧是用curl模拟预检请求绕过浏览器直接看后端返回。curl -X OPTIONS http://localhost:8081/api/login \ -H Origin: http://localhost:8080 \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: Authorization,Content-Type \ -v看返回的Headers里有没有正确的CORS头状态码是不是200。如果返回401说明Shiro拦截了需要调整过滤器链。这个方法比在浏览器里反复试快得多。5. 常见问题排查与避坑经验实录5.1 跨域问题速查表下面这张表是我在实际项目中整理的跨域问题速查表覆盖了90%以上的场景。现象可能原因排查方法解决方案OPTIONS请求返回401Shiro拦截了预检看Network里OPTIONS状态码自定义CorsFilter放行OPTIONSOPTIONS返回200但真实请求报CORS响应头缺失看真实请求的Response Headers补全Allow-Origin等头带Cookie时跨域失败Allow-Origin为*检查Allow-Origin值改为具体Origin升级SpringBoot后跨域失效allowedOrigins与allowCredentials冲突看启动日志报错改用allowedOriginPatterns部分接口跨域正常部分报错过滤器链匹配问题对比正常和异常接口的URL调整Shiro过滤器链顺序生产环境跨域失败开发正常Nginx未配置CORS检查Nginx配置Nginx加add_header5.2 三个最容易踩的坑与规避方法第一个坑是**allowedOrigins(*)和allowCredentials(true)同时使用**。SpringBoot 2.4之前这样写没问题2.4之后直接启动报错。很多人升级版本后项目起不来以为是依赖冲突其实是这个配置问题。规避方法就是统一用allowedOriginPatterns。第二个坑是Shiro的anon配置放行了所有接口。有人为了图省事把/api/** anon结果跨域是解决了但所有接口都不需要认证了安全隐患极大。正确的做法是只放行OPTIONS请求或者用自定义过滤器处理。第三个坑是Nginx反向代理后跨域头重复。后端配了CORSNginx又配了CORS结果响应头里出现两个Access-Control-Allow-Origin浏览器直接拒绝。规避方法是二选一要么后端配要么Nginx配不要同时配。如果非要用Nginx后端就把CORS配置去掉。注意Nginx配置CORS时add_header指令在if块里可能不生效建议放在location块的最外层。另外add_header默认只对2xx和3xx响应生效4xx和5xx需要加always参数。5.3 生产环境Nginx反向代理的配置参考生产环境我强烈推荐用Nginx反向代理让前后端同源从根上消除跨域。配置参考如下。server { listen 80; server_name your-domain.com; location / { root /var/www/vue-dist; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8081/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS always; add_header Access-Control-Allow-Headers Authorization, Content-Type always; add_header Access-Control-Allow-Credentials true always; if ($request_method OPTIONS) { return 204; } } }这样前端请求/api/loginNginx转发到后端/login浏览器看到的是同源请求不会有跨域。OPTIONS请求直接返回204不转发到后端减轻后端压力。add_header加了always参数保证4xx和5xx响应也带上CORS头。这套配置我在多个项目中用过稳定性很好。唯一要注意的是proxy_pass末尾的斜杠http://127.0.0.1:8081/和http://127.0.0.1:8081的区别在于路径拼接方式前者会替换掉/api/后者会保留。根据你的接口路径设计选择。5.4 个人实操心得与建议最后分享几个我在实际项目中总结的心得。第一跨域问题优先在后端解决不要依赖前端代理。前端代理只在开发期有效上线后还得后端处理不如一开始就在后端配好前后端一致。第二CORS配置要写在一个地方不要散落在各个Controller。用CrossOrigin注解虽然简单但接口多了之后很难维护而且容易漏配。统一在WebMvcConfigurer或CorsFilter里配置清晰可控。第三Shiro的过滤器链配置要遵循“先放行预检再认证”的原则。OPTIONS请求必须最先处理不能进入认证逻辑。自定义一个高优先级的CorsFilter是最稳妥的方案。第四调试时善用curl和Network面板。浏览器控制台的CORS错误信息有时候会误导人直接看OPTIONS请求的原始响应最准确。curl可以绕过浏览器的同源策略直接看后端返回什么。第五升级SpringBoot版本后一定要回归测试跨域。SpringBoot 2.4的CORS配置有破坏性变更升级后跨域失效是很常见的问题。提前在测试环境验证别等上线了才发现。这套方案我在多个前后端分离项目中落地过Vue SpringBoot Shiro的组合基本都能一次跑通。核心就是理解预检请求被Shiro拦截这个根因然后用高优先级的CorsFilter放行OPTIONS再配合后端的CORS响应头配置。顺序对了问题就解决了一大半。
阅读完成 · 觉得有帮助?