把 Spring Boot、Shiro、CAS 这套单点登录方案写到第四篇的时候我一度觉得登录、授权、SSO 这三个词已经被拆得差不多了。demo 能跑、流程图能画、配置能抄看起来没有什么继续写下去的必要。但实际把项目往生产环境推的过程中我很快发现之前的“跑通”只是起点两个应用同时接同一套 CASA 系统登录完B 系统依然不认权限数据改了不重启就永远停在旧版本后端服务上了负载均衡之后明明几分钟前还登录成功下一次请求又被打回登录页。这一篇就是给这个系列收尾兼补坑的重点解决三个生产级问题授权模型怎么做得更灵活、SSO 会话在分布式环境里怎么保持、以及我实际改造过程中真实踩过的那些坑。适合已经能把基础 demo 跑起来、正在把这套体系往真实环境推进的读者。1. 开局先对齐前四篇做完这套体系到底跑到了哪一步1.1 快速回顾你手里现在这套链路前面四篇做的事情可以归纳成一条很清晰的链路用户访问业务系统业务系统发现未登录重定向到 CAS Server 的登录页用户输入账号密码完成身份认证CAS Server 生成票据 Ticket 并通过回调地址返回给业务系统业务系统拿着 Ticket 去 CAS Server 做服务端校验校验成功后在自己的会话里建立登录态Shiro 依靠 Realm 里查询出来的角色和权限数据对受保护资源和接口做拦截判断。这个链路里Spring Boot 负责把整个流程组件化Shiro 负责业务应用内部的身份和授权控制CAS 负责统一认证和跨应用票据传递。前几篇的代码走到了“能登录、能拦截、能退出”这一步对应的角色表、权限表、URL 过滤规则和 CAS 校验逻辑也都通掉了。单应用视角下这确实已经是一套能演示的完整方案。1.2 第五篇的重点不是“能登录”而是“可上生产”很多人把单点登录理解成“一次登录到处访问”但在真实项目里这句话只说对了一半。一个大型系统往往由多个业务应用构成这些应用可能部署在不同服务器、不同域名路径甚至属于不同团队维护。你的应用 A 可以跳转到 CAS 完成登录应用 B 同样可以跳转登录但两者之间的“登录状态”互相不感知。更麻烦的是你自己可能还有集群环境、权限动态变更、接口鉴权统一返回 JSON 这些需求。所以第五篇我换一个视角不去重复讲基本流程怎么配而是从“生产环境需要什么”这个角度把方案补完整。核心是三件事多应用接入时的认证链路怎么调整授权信息怎么做动态刷新分布式会话和全局退出怎么保证一致。这三件事处理完这套 springboot shiro cas 的组合才算真正有落地的底气。2. 多应用接入下的认证链路改造2.1 两个应用接同一个 CAS为什么用户体验对不上我第一次带新人接入第二个应用的时候出现了一个很直观的问题应用 A 在 8080 端口登录成功之后切到应用 B 的页面B 依然把用户弹回 CAS 登录页。这其实是符合 CAS 协议行为的因为 CAS 是认证中心它验证的是“用户身份是否合法”而不是“用户登录过哪一个应用”。用户访问 A 时A 拿到的是 A 的 service 票据访问 B 时B 没有这个票据只能重新发起一次 CAS 校验流程。更隐蔽的是回调地址不一致的问题。CAS Server 在登录成功后要把用户带回发起登录的应用带回去的地址由 service 参数决定。如果你在 B 系统配置的 service 回调地址写错了一个路径CAS 回调的时候直接打到一个不存在或无人处理的接口用户看到的要么是 404要么是校验失败重新跳回登录页。所以多应用接入的第一条经验是所有应用的回调地址必须完整、独立、可重复访问并且在 CAS Server 侧维护一份白名单。2.2 三层结构改法认证中心、业务应用、应用间互信多应用接入后我习惯把整个体系改成三层结构。最顶层是 CAS Server只负责两件事对用户身份做认证、发放和校验 Ticket。中间层是业务应用每个应用都是一个独立的 Shiro 客户端负责自己的登录态维护和 URL 权限判断。最底层是统一用户中心或权限数据库给 Realm 查询提供角色和权限数据。这三层里容易犯错的是“信任传递”问题。CAS Server 验证通过后业务应用应当在本地创建会话后续请求只信任自己的本地会话不每次回跳到 CAS Server。但不同业务应用之间如果想做接口级互调就不能只依赖用户浏览器里的会话而应该用接口签名、Token 或内部鉴权的方式解决问题。这里有一个常见的误解单点登录只是让用户在多个应用之间免重复登录它不负责解决服务与服务间的调用鉴权后者是另一个独立话题。2.3 跨域场景下的登录态传递Ticket 管认证Session 管会话多应用一旦涉及不同二级域名问题会更明显。比如应用在 a.example.com另一个在 b.example.comCookie 默认不能跨域名共享所以你在 A 应用种下的会话B 应用根本无法读取。CAS 在这种场景下的价值才会真正体现出来用户访问 A被引导到统一登录域名登录完成后生成 Service Ticket 回跳 A再访问 BB 依然没有本地会话于是再引导到统一登录域名但因为用户在 CAS Server 端已经有全局 TGT所以这次不会让用户重新输密码而是直接发一张新票据并回跳 B。整个交互过程中Cookie 不需要跨域只需要统一登录域名自己的 Cookie 工作正常即可。也就是 CAS Server 管“你之前有没有登录过”业务应用管“你当前这个应用的会话有没有建立”。这两段状态各管一段配合起来才是完整的 SSO 体验。2.4 Shiro 过滤器链的正确定位多应用接入后改动其实集中在 Shiro 的过滤链配置上。核心原则是要分清楚哪些地址必须匿名放行、哪些地址由 CAS 过滤器处理、哪些地址需要用户登录才能访问。我在配置里一般这样划分/ anon /callback anon /login cas /logout anon /css/** anon /js/** anon /static/** anon /** user这里有两个容易踩坑的点。第一原因/login和/callback不能全部 anonlogin 地址要指向 CAS 的登录入口所以交给 cas 过滤器处理比单纯 anon 更合适第二/** user并不是多余配置它保证了除上述白名单外的所有地址都要有本地会话而user过滤器比authc更宽容允许“记住我”的用户访问实际用下来体验更贴近业务要求。还有一点必须注意Spring Boot 下 Shiro 过滤器配置是LinkedHashMap配置顺序就是执行顺序匿名放行一定要放在拦截规则之前一旦把/**写在前面后面全部白搭。3. 授权这层别只满足于“能拦就行”3.1 每次请求都查数据库还是走缓存基础 demo 里最常见的写法是每来一个请求Shiro 的 Realm 就根据用户名去数据库查询角色和权限列表。单用户场景下没有任何问题但并发上来之后权限查询会成为很明显的性能瓶颈。所以我把权限数据放到了 Redis登录成功后首次查询写入缓存后续授权判断直接走缓存。Shiro 的授权缓存理解起来很简单权限数据不常变变化频率远低于用户请求频率把它缓存起来换取更短的响应时间。Realm 中通过getAuthorizationInfo方法返回用户权限集合Shiro 的AuthorizingRealm内部会根据用户名调用这个方法来取数据你只要把返回值做成“先查缓存缓存没有再查库查完再写回缓存”的逻辑就可以了。实现上可以直接依赖 Shiro 内置的 CacheManager也可以自己接 Redis我更推荐后者因为集群环境下多节点共享同一个 Redis权限缓存才能全局一致。3.2 权限改完不重启动态刷新的设计思路缓存带来的副产物是数据陈旧。你给某个用户新增了一个角色数据库已经改了但 Redis 里还留着旧的授权信息用户刷新页面发现权限没生效。这个问题在第四篇里其实就已经存在只是当时没有单独讲。动态刷新的本质是当你主动修改了某个用户的角色或权限之后必须把该用户对应的授权缓存删掉让下一次请求重新查库并写缓存。我有一个比较省事的做法所有权限变更操作走一个统一的服务接口操作完之后根据用户名调用刷新方法把该用户在 Shiro 授权缓存里的记录移除。代码骨架大致是这样public void refreshUserAuthorization(String username) { String cacheKey shiro:authorization: username; redisTemplate.delete(cacheKey); // 让 shiro 的授权缓存也失效 authorizationCache.remove(username); }注意这里不只是删自己 Redis 的 key还要处理 Shiro 内置的授权缓存。如果用的是 Shiro 自带的 CacheManager 且底层就是 Redis那删一个 key 就够了如果是内存缓存就还需要显式调用缓存管理器的 remove 方法。实际项目里由于存在多节点我推荐直接统一走 Redis 缓存这样刷新动作只要执行一次所有节点就都感知到了。3.3 授权粒度URL 拦截与注解两个维度怎么配合Shiro 授权可以写在 filterChain 配置层也可以写在 Controller 方法上。URL 维度适合做粗粒度的安全边界比如/admin/**只有 admin 角色能访问注解维度适合做细粒度控制比如某个具体的删除接口要求指定权限码user:delete。初学的时候很容易把这两种手段混用结果同一个接口被两层逻辑限制排查问题的时候都不知道是谁拦的。我的经验是分清楚层级FilterChain 负责入口管控解决“哪些人进得去”的问题方法注解负责接口级授权解决“进去之后能干什么”的问题。两者并不冲突而且生产上非常有用。举个例子一个普通用户可以进/user/**路径但其中某个删除接口标了RequiresPermission(user:delete)没有这个权限码的用户就算走到接口也会在方法调用前被拦截下来。这套组合既能保护大范围路径又不丢失细粒度控制能力。4. 会话与 SSO 状态保持的各种疑难杂症4.1 会话放在哪CAS Server、应用本地还是 Redis做 CAS 接入的时候一个绕不开的问题是“用户登录状态到底存在哪里”。CAS Server 的 Session 里保存的是全局登录态 TGT业务应用自己不直接感知业务应用在拿到 Service Ticket 并通过验证后需要在本地再建一个自己的 Session保存当前用户信息和授权信息。很多人误以为 CAS 登录成功后业务应用就一直靠 CAS 会话撑着这是不对的业务应用后续接口判断登录看的永远是自己的 Shiro Session。那这个本地 Session 存哪里小项目可以放内存部署在同一个进程里没有问题大一点、有多节点部署的必须把 Session 放到 Redis。因为一旦你做了负载均衡用户请求可能会被转发到不同节点如果 Session 只在节点 A 的内存里请求落到节点 B 自然就丢了。这个问题不是 Shiro 特有的但 Shiro 给你提供了替换 Session 存储的接口接 Redis 属于常规操作。4.2 多节点部署同一个登录态怎么跨节点保持多节点会话保持的配置核心改造点是给DefaultWebSessionManager挂一个RedisSessionDAO。每次创建新会话的时候session 对象会被序列化到 Redis后续请求如果落到其他节点节点从 Redis 取出同一个 Session用户无感知。代码上比较典型的一段配置长这样Bean public DefaultWebSessionManager sessionManager(RedisSessionDAO sessionDAO) { DefaultWebSessionManager sessionManager new DefaultWebSessionManager(); sessionManager.setSessionDAO(sessionDAO); sessionManager.setGlobalSessionTimeout(1800000); sessionManager.setDeleteInvalidSessions(true); return sessionManager; }这里有个容易忽略的细节sessionManager必须被设置进SecurityManager不然配置白做。另外一个体验相关的参数是globalSessionTimeout不能设太短否则用户操作稍微慢一点就被踢出也不能太长否则安全风险大。30 分钟是一个比较平衡的默认值具体按业务敏感度调整。4.3 全局退出怎么做得干净又怎么避免“假退出”单应用退出很简单业务应用调一下subject.logout()把本地会话销毁就完了。但 CAS 单点登录要求的退出是“全局退出”也就是用户在一个应用点击退出其他已经登录过的应用也要同步失效。协议上的做法是业务系统重定向到 CAS Server 的 logout 接口CAS Server 再把其他受保护应用的会话一并清掉然后通过 service 参数跳回指定页面。这段逻辑看着简单但坑也在这里。如果你只是销毁了本地 Shiro 会话不跳 CAS 的 logout 接口其他应用在用户下一次访问时还会带着 CAS 的 TGT 自动登录给用户的感受就是“退出了个寂寞”。反过来如果你每次都跳全局退出但有一个应用的回调地址配置了错误域名CAS 在跳转时会报错或者卡住。所以我这里处理登出的控制层通常会写成两段式先销毁本地会话再 302 到 CAS 的统一退出地址同时带上一个完整可回跳的 service 参数。5. 踩坑实录重定向、Cookie、返回值三大拦路虎5.1 重定向死循环浏览器反复横跳怎么定位整个接入过程中遇到最多的问题就是“页面一直跳转最后浏览器提示重定向次数过多”。大部分原因集中在三个地方第一个是 CAS Server 回调的业务地址写在了受保护范围内回调地址本身又触发登录跳转形成死循环解决方法是把回调地址anon放行。第二个是业务应用拿到的 Service Ticket 已经被消费过一次第二次用同一个 Ticket 再去校验CAS Server 直接返回失败系统又把用户打回登录页这个经常出现在刷新回调页面的场景所以我的习惯是校验完 Ticket 后立刻重定向到一个清理掉 ticket 参数的内部地址别让票据留在浏览器地址栏。第三个是 Nginx 层的跳转协议和 CAS Server 配置的协议不一致HTTP 和 HTTPS 之间来回跳导致每次都拿不到预期的回调地址这种问题最坑排查时先抓包看 Location 地址基本一眼就能看出来。5.2 Cookie 作用域和同名 Cookie 冲突多应用共存的另一个经典问题是 Cookie 同名导致会话互相干扰。常规 Java Web 容器默认生成的 Session Cookie 叫JSESSIONID。如果应用 A 和应用 B 都在同一个主域下、部署在不同路径浏览器里可能会同时存在不同路径范围的同名 Cookie。某些请求会把 A 的 Cookie 带到 BB 解析 Session ID 失败又把用户当成未登录处理表现就是登录状态时有时无。这种问题我在生产里处理过几次最直接的方案是给不同应用设置不同的会话 Cookie。Shiro 的会话 Cookie 名称是JSESSIONID可以在DefaultWebSessionManager里换掉比如改成APP_A_SESSION、APP_B_SESSION。从根源上规避同名冲突比调整 Cookie 作用域要省心得多。5.3 接口被重定向到登录页Ajax 调用方一脸懵如果业务系统后端有大量接口返回 JSON而前端是 Ajax 调用登录失效场景下有个很烦的行为默认的 Shiro 过滤器发现未登录会把请求重定向到登录页。前端拿到的是 302 和一段 HTML而不是预期的{code:401}页面逻辑直接混乱用户看到的可能是跳回登录页也可能是整个页面的内容被塞进某个 div。解决办法是自定义一个AuthenticationFilter子类覆盖未登录场景的响应逻辑。判断请求头里的Accept或X-Requested-With如果是 Ajax 请求直接返回 401 JSON否则才走默认重定向。这样前后端接口和页面跳转就能各走各的通道不会再互相干扰。public class JsonAuthFilter extends FormAuthenticationFilter { Override protected boolean onAccessDenied(ServletRequest request, ServletResponse response) throws Exception { if (isLoginRequest(request, response)) { return true; } HttpServletRequest req (HttpServletRequest) request; if (req.getHeader(X-Requested-With) ! null || req.getHeader(Accept) ! null req.getHeader(Accept).contains(application/json)) { HttpServletResponse resp (HttpServletResponse) response; resp.setStatus(401); resp.setContentType(application/json;charsetUTF-8); resp.getWriter().write({\code\:401,\message\:\session timeout\}); return false; } return super.onAccessDenied(request, response); } }5.4 常见问题速查表现象可能原因处理方式重定向次数过多回调地址受保护 / Ticket 被重复消费 / 协议跳转不一致放行回调地址、校验后清理 ticket、对齐协议配置登录成功但接口仍 401Ajax 接口被默认重定向拦截用自定义 Filter 针对 JSON 请求返回 401权限改了不生效授权缓存未清除权限变更后删 Shiro 授权缓存多节点登录态丢失Session 存在单节点内存接 RedisSessionDAO退出后其他应用还能访问没走 CAS 全局退出跳 CAS logout 接口CAS 回调 404应用没配置回调接口或路径不匹配检查 service 参数和回调接口这张表的每一条我都在项目里真实遇到过而且大部分问题在 demo 环境根本暴露不出来只有部署到多应用、多节点环境才会显现。6. 性能与安全加固最后再做一遍收口6.1 认证请求放大问题与票据校验的短期缓存单点登录的代价是每一次进入新应用都要向 CAS Server 发起一次票据校验请求。用户量大的时候CAS Server 的票据校验接口会成为一个热点尤其是所有应用共享同一个中心节点时压力会被成倍放大。一个用户从访问 A 到访问 B、再到访问 C每个应用的登录请求都要打一次校验接口这还不算用户多开浏览器标签的情况。我的优化思路有两个层面。第一层是业务应用本地做好 Session 缓存用户完成一次登录后后续请求不会每次都回 CAS第二层是给 CAS Server 的票据校验接口加短期结果缓存比如一分钟内同一个 Ticket 的校验结果可以直接复用。但这里必须注意一个边界Ticket 是一次性的CAS 服务端在正式协议里是允许消费一次如果缓存时间过长回调时同一个 Ticket 带过来两次就会和本地逻辑冲突。所以缓存只适合做“拒绝结果”或“短时间重复查询”的快速返回不能把“已消费”状态当成永久结果。我的建议是控制在一分钟以内既缓解压力又不影响协议行为。6.2 授权缓存的安全性不能和会话完全绑定授权缓存虽然好用但有一个安全边界要想清楚缓存的数据是“用户当前拥有的权限”如果用户被禁用、被移除角色、被改权限缓存必须同步失效。如果权限缓存和登录会话绑定在同一个过期时间那用户被禁用后最坏情况下只有会话过期才会触发重新查询这中间的时间窗口是不可接受的。我在系统里加了一个很小的机制用户表维护一个权限版本号或者状态字段每次登录时把版本号带进会话授权缓存 Key 里包含版本号。权限变更时版本号递增旧版本的授权缓存自然作废新请求会重新查库。这比单纯删 key 更可靠因为它天然覆盖了多节点、多缓存副本的问题也方便排查缓存的时效性。6.3 一套生产可用的核心配置参考把前面若干点合起来我贴一段在生产环境验证过的配置参考。这里不展开每个类只给关键骨架你按自己的包路径替换即可Configuration public class ShiroConfig { Bean public Realm realm() { // 自定义或使用 CasRealm核心是实现认证和授权信息获取 CustomCasRealm realm new CustomCasRealm(); realm.setCachingEnabled(true); realm.setAuthorizationCachingEnabled(true); realm.setAuthorizationCacheName(authorizationCache); return realm; } Bean public DefaultWebSessionManager sessionManager(RedisSessionDAO sessionDAO) { DefaultWebSessionManager manager new DefaultWebSessionManager(); manager.setSessionDAO(sessionDAO); manager.setGlobalSessionTimeout(1800000L); manager.setSessionIdCookieEnabled(true); manager.setSessionIdCookie(new SimpleCookie(APP_SESSION_ID)); return manager; } Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, CasFilter casFilter, JsonAuthFilter jsonAuthFilter) { ShiroFilterFactoryBean factory new ShiroFilterFactoryBean(); factory.setSecurityManager(securityManager); // loginUrl 指向 CAS 登录入口service 为当前应用完整回调地址 factory.setLoginUrl(https://cas.example.com/login?servicehttps://app.example.com/callback); MapString, Filter filters new HashMap(); filters.put(cas, casFilter); filters.put(jsonAuth, jsonAuthFilter); factory.setFilters(filters); MapString, String chain new LinkedHashMap(); chain.put(/, anon); chain.put(/callback, anon); chain.put(/static/**, anon); chain.put(/logout, logout); chain.put(/**, jsonAuth); factory.setFilterChainDefinitionMap(chain); return factory; } }注意这里logout过滤器我直接用了 Shiro 内置的 logout它会先销毁会话然后按配置跳到指定的登出地址这个地址要指向 CAS 的全局退出接口。7. 把这个方案真正落到项目里的一些思考7.1 最终的系统分工与部署节奏整套方案稳定之后部署结构应该是清晰的一台或一组CAS Server 作为认证中心若干业务应用作为 Shiro 客户端一个 Redis 集群承载业务应用的会话和授权缓存一个用户权限库为 Realm 提供查询数据。团队协作上认证中心和业务应用可以分头维护只要保证回调地址、票据校验规则、权限缓存清理策略这些接口契约一致就行。我建议改造节奏也按这个分层走先让单个业务应用接好 CAS跑通认证再引入 Redis 会话解决多节点问题接着做授权缓存和动态清理最后再接入第二个应用做跨应用验证。每一步验证完再往下走排查问题的范围会小很多不会出现“一堆配置同时改完出问题了不知道怪谁”的情况。7.2 方案取舍的底线CAS 管认证Shiro 管授权做这套方案最忌讳的是职责混在一起。CAS 只负责“你是不是合法用户”Shiro 只负责“这个用户能不能访问这个资源”。有人会把业务系统的权限判断逻辑塞进 CAS Server想做成一个万能中心最后发现 CAS Server 改动频繁认证中心变得异常脆弱所有接入它的应用全部受影响。正确的边界是 CAS Server 保持精简授权逻辑留在业务应用侧不同业务应用完全可以选择不同的授权粒度认证中心不需要理解业务流程。这套组合适合老系统改造、需要保护既有 URL 权限规则、团队对 Spring 生态和过滤器机制比较熟悉的场景。如果是全新系统并且没有历史包袱那么 OIDC 或者 JWT 方案也值得考虑但那不是本系列的出发点。7.3 后续扩展方向如果这个体系还要继续往前走最值得扩展的是三个方向。第一个是登录方式矩阵化CAS Server 本身可以扩展支持扫码登录、短信验证码登录、企业微信或钉钉这类第三方身份源业务应用不需要改动认证中心替你把多种登录方式统一掉。第二个是授权模型升级目前以 URL 和注解控制为主规模大了之后可以往数据权限、对象权限模型演进比如某个用户只能看自己部门的数据这已经不属于 Shiro 拦截器要解决的问题而是业务查询层需要统一的权限过滤器。第三个是网关层整合如果系统前面已经有统一网关可以考虑把 Shiro 的会话校验前置到网关后端业务只信任经过网关校验的请求头这样每个业务应用不用再各自维护一套过滤逻辑。我个人的体会是这个系列做到这里实际上已经不是一个单纯的“接入教程”了更像是一套完整的安全体系改造纪实。中间最折磨人的部分不是 Shiro 或 CAS 的 API 调用方式而是本地会话、全局会话、缓存失效、统一退出这些状态同步问题。最后再分享一个非常实用的小技巧联调阶段一定要在普通窗口、无痕窗口、不同浏览器之间反复切换测试只有把登录态在多窗口下的表现看清楚了你才能真正理解 CAS 的 TGT 和业务应用 Session 之间的交互边界很多莫名奇妙的问题到最后都会聚合到同一个结论上——状态放哪了谁负责清理有没有做到全局一致。把这几个问题想透这套 springboot shiro cas 的体系才算真正长在你脑子里了。