把 Spring Boot、Shiro、CAS 这套单点登录方案写到第四篇的时候,我一度觉得登录、授权、SSO 这三个词已经被拆得差不多了。demo 能跑、流程图能画、配置能抄,看起来没有什么继续写下去的必要。但实际把项目往生产环境推的过程中,我很快发现之前的“跑通”只是起点:两个应用同时接同一套 CAS,A 系统登录完,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.com,Cookie 默认不能跨域名共享,所以你在 A 应用种下的会话,B 应用根本无法读取。CAS 在这种场景下的价值才会真正体现出来:用户访问 A,被引导到统一登录域名,登录完成后生成 Service Ticket 回跳 A;再访问 B,B 依然没有本地会话,于是再引导到统一登录域名,但因为用户在 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不能全部 anon,login 地址要指向 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 带到 B,B 解析 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;charset=UTF-8"); resp.getWriter().write("{\"code\":401,\"message\":\"session timeout\"}"); return false; } return super.onAccessDenied(request, response); } }5.4 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 重定向次数过多 | 回调地址受保护 / Ticket 被重复消费 / 协议跳转不一致 | 放行回调地址、校验后清理 ticket、对齐协议配置 |
| 登录成功但接口仍 401 | Ajax 接口被默认重定向拦截 | 用自定义 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?service=https://app.example.com/callback"); Map<String, Filter> filters = new HashMap<>(); filters.put("cas", casFilter); filters.put("jsonAuth", jsonAuthFilter); factory.setFilters(filters); Map<String, 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 的体系才算真正长在你脑子里了。