接到这个需求的时候我心里大概是有数的——老的内部系统用的是CAS单点登录前后端没分离页面和后端服务跑在一个应用里。现在要做SpringBoot前后端分离改造Vue前端独立部署后端只出接口麻烦立刻来了CAS的重定向流程和跨域请求两个事撞在一起登录、鉴权、Cookie每一个环节都是坑。这篇文章把我这次接入CAS单点登录并且解决跨域问题的完整落地方案记录下来整个过程踩过的坑、最后采用的配置、以及每步背后的原因都会讲到给后面要干同样事的人做个参考。1. 从前后端分离那一刻起CAS的下一跳就成了问题1.1 CAS的认证流程为什么会失灵CASCentral Authentication Service中央认证服务的核心逻辑是多个应用共用同一个登录入口用户在某一个系统登录一次其他接入了CAS的系统就不用再重复登录。它的标准认证流程是这样的用户请求访问某个受保护资源可能是页面也可能是接口。应用检测到用户本地没有有效会话302重定向到CAS服务端的登录页同时带上一个service参数这个参数指明登录成功后请跳回哪里。用户在CAS登录页输入账号密码验证通过后CAS服务端在自己的域名下种下TGCTicket-Granting Cookie这是CAS层面的登录凭证。CAS服务端302重定向回service参数指定的URL并附上一个一次性票据STService Ticket。应用后端拿到ST后向CAS服务端发起校验请求换取用户身份信息。应用建立本地会话比如生成JSESSIONID然后放行用户最初访问的资源。在单体时代这个流程几乎是无声的。用户访问一个页面中间哪怕经历几次302浏览器地址栏最终还是会落到目标页面体验上没有大问题。但前后端分离之后情况完全变了。用户访问的是前端应用比如http://localhost:8081前端渲染页面后通过Ajax请求去调后端接口比如http://localhost:8080/api/xxx。后端发现这个请求没有携带有效的会话信息按照传统CAS接入方式它应该302到CAS登录页。但问题是这次302是一个异步请求在背后触发的浏览器虽然会自动跟随但在跨域场景下前端代码根本接收不到正常的JSON响应用户甚至看不到登录页。退一步说就算浏览器真的跟着跳到了CAS登录页用户登录成功后CAS跳回的service地址是后端接口地址http://localhost:8080/login/cas不是前端页面URL。前端应用完全不知道登录已经完成。所以说前后端分离架构下接入CAS最核心的难点不在引入依赖、填几行配置而是CAS这套服务端重定向驱动的流程和前端路由、后端API分离的架构存在天然冲突。需要先把这条链路理顺后面的代码才有意义。1.2 跨域问题是怎么被放大的前后端分离也意味着前端和后端各自独立部署域名不同或端口不同浏览器同源策略立刻生效。跨域带来的直接问题有三个前端通过XHR调用后端接口浏览器默认不会携带该域名下的Cookie。后端的Session往哪里放JSESSIONID这个Cookie发不出去会话自然建立不起来。后端如果不返回CORS响应头前端连异步响应都读不到更别提处理401、302这些状态码了。即便配了CORS如果把Access-Control-Allow-Origin配成*同时还需要Access-Control-Allow-Credentials: true浏览器会直接拒绝因为带凭证的跨域请求不允许用通配符来源。这几个问题叠加在一起就是很多人CAS接入看起来很简单一旦前后端分离就跑不通的直接原因。我在后面的方案里会把这两个问题分开处理先用架构思路规避跨域再在必须跨域的场景下正确配置CORS和Cookie属性。2. 方案选型动手写代码前先把路走通2.1 前期对比过的三条路线路线一Nginx反向代理让前后端看起来同源。前端和后端对外都暴露在同一个域名下Nginx按路径转发/api/**转给后端其他路径指向前端静态文件。浏览器视角里只有单一来源跨域从根本上不存在。路线二后端返回401 JSON前端拦截401后主动跳转CAS。后端认证失败时不再无脑302而是返回一个标准化的401响应体里面包含CAS登录地址。前端Axios拦截器收到401后主动把地址栏切到CAS登录页。路线三直接用Spring Security CAS的默认EntryPoint后端一律302。代码最少但只适合前后端同源部署的传统场景分离结构下用户体验极差、排查困难。最后我采用了路线一 路线二的组合生产环境用Nginx把前后端统一域名开发环境用Vite的proxy实现同样的同源效果同时改造Spring Security的AuthenticationEntryPoint让它返回401 JSON而不是302。为什么两个都做因为代理解决的是跨域请求能不能发通的问题401前端跳转解决的是登录流程怎么衔接的问题两者不冲突而且适配了从开发到生产的完整链路。2.2 为什么最终选这个组合选Nginx代理核心原因是它从源头消灭了跨域钩子。只要前后端在浏览器里是同源的Cookie的发送、Session的保持、后续的一切都变得顺理成章。你不需要在代码里到处处理CORS白名单也不用担心Cookie的SameSite策略把会话拦掉。这是复杂度最低、最终稳定性最高的一条路。选Spring Security CAS组件是因为它把接收ST、CAS服务端校验、建立本地认证上下文、处理单点登出这套核心流程全部封装好了我们不需要自己写CAS协议客户端。团队只需要把重点放在认证成功之后跳转到哪和认证失败后返回什么这两个自定义环节上。这两块也是我在第3节、第4节里重点展开的部分。需要强调的一点在前后端分离架构下后端必须摒弃一切重定向交给浏览器的思路。对异步API请求来说返回401让前端决定下一步动作才是可预期、可控制的做法。这一条也是这次改造和传统CAS接入最大的差异点。3. 后端接入CAS的完整实操3.1 引入依赖与版本控制在Spring Boot项目中接入CAS需要引入spring-security-cas。如果你用的是Spring Boot 2.x官方BOM已经替你把版本管理好了pom里直接声明即可dependency groupIdorg.springframework.security/groupId artifactIdspring-security-cas/artifactId /dependency注意几个版本上的细节Spring Boot 2.5.x及以下对应Spring Security 5.5及以下WebSecurityConfigurerAdapter还能用但已经被标记废弃。Spring Boot 2.6、2.7之后推荐直接注册SecurityFilterChain的Bean。如果升级到Spring Boot 3.x部分内部类从javax迁移到了jakarta包直接抄旧博客的配置会报编译错误。我这次用的是Spring Boot 2.7下面的代码全部基于SecurityFilterChain写法。要抄作业的同学建议确认下自己的Boot版本框架级别的大版本迁移不是小事。3.2 安全配置类核心组件逐个讲直接把核心配置类贴出来然后拆开讲每个组件的含义Configuration EnableWebSecurity public class SecurityConfig { private final String casServerUrl https://cas.example.com; private final String serviceUrl http://localhost:8080/login/cas; Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .cors(Customizer.withDefaults()) .authorizeHttpRequests(auth - auth .requestMatchers(/login/cas, /logout, /api/public).permitAll() .anyRequest().authenticated() ) .exceptionHandling(exception - exception .authenticationEntryPoint(restAuthenticationEntryPoint()) ) .addFilterAt(casAuthenticationFilter(), CasAuthenticationFilter.class) .logout(logout - logout .logoutUrl(/logout) .logoutSuccessUrl(casServerUrl /logout?service serviceUrl) .invalidateHttpSession(true) .deleteCookies(JSESSIONID) ); return http.build(); } Bean public CasAuthenticationFilter casAuthenticationFilter() throws Exception { CasAuthenticationFilter filter new CasAuthenticationFilter(); filter.setServiceProperties(serviceProperties()); filter.setAuthenticationManager(new ProviderManager(casAuthenticationProvider())); filter.setAuthenticationSuccessHandler((request, response, authentication) - { String targetUrl request.getParameter(redirect); if (targetUrl null || targetUrl.isEmpty()) { targetUrl http://localhost:8081; } response.sendRedirect(targetUrl); }); return filter; } Bean public ServiceProperties serviceProperties() { ServiceProperties sp new ServiceProperties(); sp.setService(serviceUrl); sp.setSendRenew(false); sp.setAuthenticateAllArtifacts(true); return sp; } Bean public CasAuthenticationProvider casAuthenticationProvider() { CasAuthenticationProvider provider new CasAuthenticationProvider(); provider.setServiceProperties(serviceProperties()); provider.setTicketValidator(new Cas20ServiceTicketValidator(casServerUrl)); provider.setUserDetailsService(userDetailsService()); provider.setKey(unique-key-per-application); return provider; } Bean public UserDetailsService userDetailsService() { return username - User.withUsername(username) .password() .authorities(ROLE_USER) .build(); } }逐个说ServiceProperties是配置当前应用在CAS体系里的回调地址的关键对象。setService指定了CAS登录成功后要跳回的应用地址也就是接收ST的入口。这个地址必须是外部可以访问到的地址如果应用部署在Nginx后面这里要填Nginx代理出来的外部URL而不是内网服务地址。CasAuthenticationProvider负责做ST校验。setTicketValidator指定了CAS服务端的地址注意这里用的是Cas20ServiceTicketValidator对应CAS 2.0协议。如果你的CAS服务端是3.x也没有问题它向下兼容2.0协议。setUserDetailsService这里容易误解CAS认证成功后用户身份来源是CAS返回的断言Assertion而不是本地数据库。这个UserDetailsService拿到的是已经通过CAS认证的用户名你在这里的任务是根据这个用户名加载本地系统的角色和权限比如查一下这个用户在本地系统里是普通用户还是管理员。CAS解决的是你是谁的问题本地系统还得解决你能干什么的问题。CasAuthenticationFilter是认证流程的入口过滤器它会识别/login/cas?ticketxxx这样的请求拿到ST后调用AuthenticationManager完成认证。我在这里自定义了AuthenticationSuccessHandler认证成功后不再跳回后端API地址而是跳回前端页面。跳转目标优先取redirect参数没有就默认首页这个我在第3.5节还会细讲。3.3 核心改造点自定义EntryPoint返回401Spring Security默认的CasAuthenticationEntryPoint在检测到未认证请求时会把请求重定向到CAS登录页。对于直接访问浏览器的场景比如刷新页面这没问题但对于前端异步请求我必须改成返回401 JSON。关键代码如下Component public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException { String serviceUrl http://localhost:8080/login/cas; String loginUrl https://cas.example.com/login?service URLEncoder.encode(serviceUrl, StandardCharsets.UTF_8.name()); response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write( {\code\:401,\message\:\未登录或会话已过期\,\loginUrl\:\ loginUrl \} ); } }然后在SecurityConfig中替换默认EntryPoint.exceptionHandling(exception - exception .authenticationEntryPoint(restAuthenticationEntryPoint()) )这个改造的收益非常直观所有未认证的API请求返回的一律是标准JSON。前端拦截器统一读这个401响应拿到loginUrl后主动跳转。不再依赖浏览器的302跟随机制逻辑完全可控。3.4 前端拦截401并跳转CAS前端以Axios为例设置全局拦截器import axios from axios const service axios.create({ baseURL: process.env.VUE_APP_API_BASE || /api, timeout: 15000, withCredentials: true }) service.interceptors.response.use( response response.data, error { const status error.response?.status if (status 401) { const data error.response.data const loginUrl data?.loginUrl || https://cas.example.com/login window.location.href loginUrl } return Promise.reject(error) } )两个容易忽略的点withCredentials: true必须设。不设的话跨域请求不会携带Cookie后端Session无法建立。如果后端返回的401 JSON里带了loginUrl前端可以直接用避免前端硬编码CAS服务端地址。这算是我后来加上去的一个小优化多环境部署时很管用。3.5 登录成功后的回跳细节CAS登录成功后会302跳回service指定的后端地址/login/cas。用户最终到底要回到哪个前端页面如果只写一个固定的前端首页体验会比较粗糙。我在实际项目中是这样处理的前端在跳转CAS之前把当前完整页面URL放到一个redirect参数里同时编码进service参数。CAS认证成功后后端AuthenticationSuccessHandler从请求参数里取这个redirect值。取到就跳转到对应前端页面取不到才回首页。前端侧跳转逻辑const redirectUrl encodeURIComponent(window.location.href) const serviceUrl encodeURIComponent(http://localhost:8080/login/cas?redirect redirectUrl) const loginUrl https://cas.example.com/login?service${serviceUrl} window.location.href loginUrl这里有一个编码层次的坑service参数本身在CAS协议里也是一个URLURL里再嵌一个URL参数必须做两层编码。第一层是对redirect参数的值做encodeURIComponent第二层是对整个service参数的URL做encodeURIComponent。少一层编码在某些CAS服务端上会导致ST校验失败或者跳转地址被截断。4. 跨域问题的多维度分析与解决4.1 跨域的根因和它为什么和CAS纠缠不清浏览器的同源策略不是什么新鲜概念但在CAS接入场景下它和Cookie联动创造了几个独特的难点。CAS的TGC是在CAS服务端域名下的Cookie用来记录这个浏览器在CAS这边登录过了。应用侧的会话CookieSpring Boot默认是JSESSIONID是在应用域名下的。这两个Cookie归属完全不同的域。在单体应用时代前端页面、后端接口、CAS回调地址都围绕同一个应用域工作Cookie路径天然正确。前后端分离后前端域名A调后端域名B的API浏览器处理第三方Cookie时受SameSite策略限制很可能根本不会把这个域下的Cookie发出去。这就是为什么很多人会碰到登录跳转一切正常但认证完马上就再次401这种现象。4.2 生产环境最优解Nginx反向代理同源化生产环境中Nginx代理是性价比最高的方案。对外只暴露一个域名按路径转发server { listen 80; server_name app.example.com; location /api/ { proxy_pass http://backend:8080/api/; 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; } location /login/cas { proxy_pass http://backend:8080/login/cas; proxy_set_header Host $host; } location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } }几个细节Host头必须是外部域名。Spring Security的Session cookie和某些重定向逻辑依赖它。proxy_cookie_path需要设置。如果后端Cookie的Path是/而代理路径是/api浏览器可能不会保存这个Cookie。实际配置中我给/开头的Cookie统一处理。前端部署的index.html里请求的基础路径建议写相对路径/api这样不管部署在哪个域名下代理关系都不会破。Nginx方案下前端withCredentials可以正常使用因为没有跨域了后端也可以不配置CORS所有请求看起来都是同源的。4.3 开发环境与无法代理场景CORS配置的正确姿势如果确实现前后端独立部署、无法统一代理那就老老实实配CORS。Spring Security里需要显式注册一个CorsConfigurationSource并开启http.cors()Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOrigins(Arrays.asList(http://localhost:8081)); config.setAllowedMethods(Arrays.asList(GET, POST, PUT, DELETE, OPTIONS)); config.setAllowedHeaders(Arrays.asList(*)); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return source; }这里有几个红线allowCredentials(true)时allowedOrigins不能用*必须写具体的域名和端口。如果担心环境多、域名不固定可以用setAllowedOriginPatterns配合allowCredentials(true)Spring Security的CORS实现支持这种模式。预检请求OPTIONS必须放行。我在实际项目里甚至遇到过预检返回401的情况因为安全过滤器链拦在CORS处理之前。如果发现这种情况要么给OPTIONS请求显式permitAll要么调整过滤器顺序。前端必须设withCredentials true否则后端无论怎么配浏览器都不会带Cookie过去。4.4 Cookie的SameSite属性看不见的墙这个问题我觉得值得单独拿出来强调因为它是很多线上问题的幕后黑手。现代浏览器普遍把Cookie的SameSite默认值设为Lax跨站请求默认不会携带第三方Cookie。CAS单点登录场景里有两类请求顶层导航请求用户点击跳转CAS登录页这是从应用域跳到CAS域属于跨站请求。SameSiteLax时顶层导航是可以携带目标域自己的Cookie的所以CAS的TGC基本不受影响。异步XHR请求前端从A域发起对B域的API请求如果Cookie属于B域且SameSiteLax浏览器不会带。你的JSESSIONID在这种请求里就消失了。解决方式是在应用的Session Cookie上显式设置SameSiteNone; SecureBean public ServletContextInitializer sessionCookieConfig() { return servletContext - { SessionCookieConfig sc servletContext.getSessionCookieConfig(); sc.setSameSite(javax.servlet.SessionTrackingMode.COOKIE.name()); // 占位示意 }; }严格一点说Spring Boot内置容器下更干净的做法是通过自定义TomcatContextCustomizer或者在响应阶段统一添加Set-Cookie属性Bean public WebServerFactoryCustomizerTomcatServletWebServerFactory cookieProcessorCustomizer() { return factory - factory.addContextCustomizers(context - { context.setUseHttpOnly(true); // 在Tomcat里通过CookieProcessor指定SameSite }); }注意SameSiteNone必须配合Secure使用也就是说应用必须是HTTPS访问。如果拿着HTTP调试浏览器会直接丢弃这个Cookie。开发环境如果不想搞HTTPS最省事的办法还是走Vite代理让前后端同源绕开SameSite这堵墙。我实测下来的结论是能用同源代理就绝不碰SameSite不能避免跨域就要提前规划HTTPS证书。4.5 开发环境Vite代理怎么配开发环境不用上完整NginxVite或者Webpack的devServer代理就能模拟出生产同源效果// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true }, /login/cas: { target: http://localhost:8080, changeOrigin: true }, /logout: { target: http://localhost:8080, changeOrigin: true } } } }前端开发时所有的请求都以/api开头浏览器看到的始终是http://localhost:8081/api/xxx不会跨域。changeOrigin: true会让后端认为请求来自localhost:8080避免Host头校验问题。这里有一个容易忽略的配置点如果后端有返回302重定向的需求比如某些遗留逻辑代理也要保证重定向的Location能被前端正确处理。Vite代理默认会传递响应头一般没问题但如果发现前端莫名其妙跳到了localhost:8080多半是代理没有把Host改干净。5. 常见问题与排查技巧实录5.1 登录成功后重定向死循环症状登录页输入完账号密码跳回/login/cas后马上又跳回CAS登录页页面来回横跳。排查思路确认/login/cas是否被Spring Security放行。放行条件是requestMatchers(/login/cas).permitAll()缺少这一行CAS回调携带ST的请求会被EntryPoint拦截重定向。检查ServiceProperties.service是否和CAS服务端登记的service地址完全一致。CAS对service地址校验很严格多一个斜杠、多一个端口都会导致ST校验不通过。确认authenticateAllArtifacts设置为true。这个属性表示只要请求带ticket参数就必须走CAS认证解析否则可能出现认证流程无法触达的问题。我在本地调试时还遇到过一种情况前端代理只配了/api忘记配/login/cas导致CAS跳回时请求直接打到Vite devServer上返回的是Vite的404页面。排查了半天才想起来是代理漏了路径。5.2 跨域请求不带Cookie症状前端登录成功后下一个接口请求还是401后端日志里每次都是新Session。排查顺序看浏览器Network面板请求头里有没有Cookie。没有进入第2步。看请求的With Credentials标志确认Axios实例设了withCredentials: true。看后端响应里的Access-Control-Allow-Origin是否是具体域名有没有Access-Control-Allow-Credentials: true。看浏览器Application面板里的Cookie确认Domain、Path、SameSite属性是否正确。如果Cookie根本没有出现在Application里那很可能是后端Set-Cookie被浏览器拦截了重点看SameSite和Secure。这个排查链基本覆盖了90%的登录完就失效问题。5.3 CAS跳回后端但ST校验失败症状CAS登录页跳转正常/login/cas?ticketST-xxx能看到但紧接着又跳回登录页。排查打开后端日志找到CasAuthenticationProvider抛出的异常最常见的是Ticket validation failed。确认Cas20ServiceTicketValidator指向的CAS服务端地址无误不要带多余的路径。确认service地址在CAS服务端已经登记。很多CAS服务端有服务注册表校验未登记的直接拒绝。检查应用时钟和CAS服务端时钟是否同步。ST签发与校验依赖时间窗口时差太大也会失败。这个我排查过两次都是测试环境时钟漂移导致的。5.4 前端拿到401 JSON但跳转CAS后丢失当前页面状态这不是Bug而是设计问题。跳转CAS登录必然触发整页跳转SPA的当前状态会丢。我的处理方式是跳转前把当前路由地址存到sessionStorage登录成功回跳后由前端路由再读一次并恢复位置而不是依赖后端传redirect参数。实际体验上用户被CAS登出后重新登录能够回到他刚才操作的那个页面而不是光秃秃的首页。这个细节看起来小但在系统上线后用户投诉率差别很大。5.5 预检请求OPTIONS返回401症状跨域环境下前端发POST请求控制台报CORS错误Network里看到OPTIONS预检请求返回401。原因Spring Security的安全过滤器链默认会拦截所有路径包括OPTIONS。预检请求一般没有携带认证信息被拦截后返回401浏览器就会认为CORS握手失败。解决.authorizeHttpRequests(auth - auth .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() // 其他规则 )或者调整CorsFilter让它排在安全过滤器链之前执行。我建议两种方式都做双保险。5.6 登出时Cookie清理不干净症状点击退出登录跳回登录页后再访问应用居然还是已登录状态。原因应用侧Session没有销毁或者JSESSIONID Cookie没有清除。解决logout配置里显式加上deleteCookies(JSESSIONID)logoutSuccessUrl指向CAS的登出地址并带上service参数让CAS登出后再次跳回应用.logout(logout - logout .logoutUrl(/logout) .logoutSuccessUrl(casServerUrl /logout?service serviceUrl) .invalidateHttpSession(true) .deleteCookies(JSESSIONID) )CAS服务端登出后跳回service地址如果这个地址是应用首页用户会被引导到一条完整的登出后回首页流程里而不是停在CAS的默认空白页。6. 最后再分享几个实战建议聊几句我这次踩完坑之后总结的习惯。能走同源代理就不要追着跨域打。这个思路无论是开发环境还是生产环境都成立。只要浏览器视角里前后端是同一个来源Cookie、Session、CORS、SameSite这些问题会减少八成。你只需要在前端代码里把API地址统一成相对路径剩下的事情交给Nginx或者Vite代理。接入CAS之前务必先把CAS服务端的脾气摸清楚。需要问清楚的问题包括service地址要不要预登记、CAS服务端有没有自定义登出流程、TGC的有效期是多久、用户属性从哪个字段映射过来。这些信息如果等调不通再看官方文档去猜效率很低。最后一个小技巧调试CAS相关问题时用无痕窗口比普通窗口顺手得多。无痕窗口天然没有历史Cookie干扰每一步Set-Cookie、每一次重定向都一目了然。我调Cookie丢失问题的时候同时开几个无痕窗口分别模拟未登录、已登录、会话过期的场景很快就能定位到底是哪一环断了。这次的经验核心就三条让同源代理消灭跨域、让后端返回401配合前端跳转、让Cookie的SameSite和Secure属性符合现代浏览器策略。把这三点想清楚SpringBoot前后端分离接入CAS并解决跨域其实没有想象中那么玄乎。