ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot + Shiro + CAS实战:授权、单点登出与前后端分离踩坑指南

Spring Boot + Shiro + CAS实战:授权、单点登出与前后端分离踩坑指南

Spring Boot + Shiro + CAS 这套组合,在不少公司内部系统里几乎是标准答案:登录归 CAS,会话与授权归 Shiro,业务系统只需要关注业务逻辑本身。前面五篇我们已经把 CAS 服务端拉起来、配置好 Shiro 的过滤器链、跑通最基本的登录跳转。到了第六篇,我倒不想继续堆新功能,而是想把几个真正影响上线体验的部分掰开揉碎讲清楚:授权数据怎么进入 Shiro、单点登出为什么经常失灵、前后端分离后这套方案该怎么改,以及这些年我在这套体系上踩过的经典坑。无论你是已经做完基础整合、正在做权限细化,还是准备把它部署到生产环境,这篇的内容应该都对得上。

1. 先理清楚整套架构里谁在干什么活

开始排查问题之前,我建议把 CAS 和 Shiro 的边界在脑子里再过一遍。我见过太多项目,配置文件都写对了,代码也能跑,但一遇到问题完全不知道往哪里查,根子就出在没想清楚这两个组件各自负责什么。

1.1 一次完整单点登录请求到底要过几道门

假设用户小王访问业务系统 A 的某个受保护页面,而他从未登录过。整个流程大致是这样的:

  1. 小王请求http://app-a.example.com/user/list,请求先落到 Shiro 的过滤器链上。
  2. Shiro 发现当前没有会话凭证,于是通过自定义的CasFilter把请求重定向到 CAS 服务端的登录页,同时带上一个service参数。
  3. 小王在 CAS 统一登录页输入账号密码。验证通过后,CAS 服务端在自己的 Session 里种下一个票据 TGT,同时生成一个一次性票据 ST,然后带着这个 ST 重定向回业务系统 A 的service地址。
  4. 业务系统 A 再次收到请求,这次 URL 上多了?ticket=ST-xxxx。此时CasFilter会拿着这个 ST 去 CAS 服务端的票据校验接口确认票据是否有效。
  5. 校验通过后,Shiro 用这个票据构造CasToken,执行Subject.login(),创建本地 Session。从这一刻起,Shiro 开始接管会话。

整个过程里,业务系统本身没有接触过用户密码,这是一个非常关键的安全收益。你可以把 CAS 想象成小区正门保安,业务系统是楼栋门禁。保安确认“这个人登记过了”,然后发给你一张临时通行证;楼栋门禁验证通行证有效后就放你进去,至于你能进哪几层哪几间房,由楼栋自己的规则决定。

1.2 单点环境里为什么还要留 Shiro 这一层

有朋友会问:CAS 都做了统一登录,为什么还要在业务系统里再套一层 Shiro?这不是重复建设吗?

其实两者解决的问题完全不同。CAS 回答的问题是“你是谁”,而 Shiro 回答的问题是“你能做什么”。CAS 服务端通常只维护用户名、密码、状态这些身份信息,它不会关心某个业务系统里的user:update权限到底是谁拥有。不同系统的权限模型差异很大,如果把权限管理也塞进 CAS 服务端,CAS 会变成一个越来越臃肿的“上帝系统”,每次业务调整都要改中央认证服务,风险极高。

职责项CAS 服务端Shiro(业务系统本地)
身份认证负责用户名密码校验、TGT/ST 管理负责校验票据、建立本地会话
会话管理统一登录态的会话管理业务系统本地 Session 生命周期管理
授权判断不关心,只做身份确认URL 拦截、方法注解、按钮级权限
权限数据存储通常只存账号密码业务系统数据库或统一用户中心下发

我在实际项目里的建议是:身份认证尽量集中,授权判断尽量分散。这样某一个业务系统权限调整时,不会影响其他系统的登录入口。

2. 授权链路打通:从“验证通过”到“按钮可见”

很多人说 Shiro 的登录好配,授权难配。难不在 API 本身,而在授权数据的来源和变更刷新。这一章我按实际落地顺序展开,把这块讲透。

2.1 授权数据怎么进 Shiro:本地查库还是走统一接口

先说结论:授权数据必须进入 Shiro 的Realm。你在doGetAuthenticationInfo里返回登录用户身份,在doGetAuthorizationInfo里返回该用户的角色和权限集合。

@Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); // 从数据库查角色 Set<String> roles = roleService.findRolesByUsername(username); info.setRoles(roles); // 从数据库查权限标识 Set<String> permissions = permissionService.findPermissionsByUsername(username); info.setStringPermissions(permissions); return info; }

这里最纠结的选择是:权限数据放业务系统本地库,还是统一从某个用户中心下发?

我的实践经验是分阶段看待。如果只有两三个业务系统接入且权限模型简单,直接在业务系统本地建sys_user、sys_role、sys_user_role、sys_role_permission这几张表就够用。一旦系统数量超过三个,或者权限规则越来越复杂,就建议提供独立的授权查询接口,由各业务系统按需拉取。这样做的好处是权限集中维护、口径一致,坏处是多一次远程调用,但也完全可以通过 Shiro 自带缓存兜底。

2.2 页面、接口、按钮三层鉴权的落地方式

权限控制不能只靠“登录就能访问”,通常需要拆成三个层级。

第一层是 URL 级别的拦截,配置在 Shiro 的filterChainDefinitionMap里。这一层适合门户类路径,比如/admin/**必须是系统管理员,/report/**只要有查询权限的角色就可以进。

Map<String, String> filterChainDefinitionMap = new LinkedHashMap<>(); filterChainDefinitionMap.put("/login/cas", "casFilter"); filterChainDefinitionMap.put("/logout", "logout"); filterChainDefinitionMap.put("/admin/**", "roles[admin]"); filterChainDefinitionMap.put("/report/**", "perms[report:view]"); filterChainDefinitionMap.put("/**", "user");

第二层是方法级注解。在 Controller 或 Service 方法上标注@RequiresPermissions("user:update")、@RequiresRoles("admin")。要提醒的是,注解要生效必须配置两个 Bean:DefaultAdvisorAutoProxyCreator和AuthorizationAttributeSourceAdvisor,很多“注解不起作用”的情况都是漏了后者。

第三层是页面按钮层。后端渲染模板时可以用标签库判断是否显示“删除”按钮,前后端分离时则通过接口返回当前用户的按钮权限标识集合,前端拿到后再决定按钮渲染。这里的原则是:按钮级权限只是优化体验,安全必须以后端接口校验为准,千万不要只遮不拦。

2.3 授权信息变了,为什么用户还拿着旧权限

这是生产环境最常见的问题:管理员修改了某个用户的角色,结果用户自己刷新页面后发现权限依然没变。原因通常是 Shiro 的授权缓存还保留着旧数据。

解决办法有两个方向。一是主动清理对应缓存,代码可以直接从SecurityManager的CacheManager中取出授权缓存移除指定用户:

private void clearAuthorizationCache(String username) { CacheManager cacheManager = securityManager.getCacheManager(); if (cacheManager == null) { return; } Cache<Object, AuthorizationInfo> cache = cacheManager.getCache("myRealm.authorizationCache"); if (cache != null) { cache.remove(username); } }

另一个方案是让用户重新登录,强制触发doGetAuthorizationInfo重新加载。这两种方案我都在项目里用过:如果权限变更不是很频繁,建议直接在用户下次重新登录时刷新;如果经常需要即时收敛权限,则必须实现主动清缓存并提供统一接口给权限管理页调用。

3. 容易忽略的配置细节:过滤器顺序和单点登出

这套方案里真正让人头疼的不是“怎么登录”,而是“怎么退出”。下面两个配置细节是我见过翻车率最高的地方。

3.1 过滤器链的书写顺序,决定了你会不会深夜排错

Shiro 的过滤器链是一个LinkedHashMap,它是有序的。请求进来后会从上到下匹配第一个匹配项的过滤器,然后执行链式过滤。顺序写错了,后果很隐蔽。

举个例子:filterChainDefinitionMap.put("/login/cas", "casFilter"),如果把它放在/** = user的后面,登录回调永远进不了casFilter,因为请求已经在/**被拦下要求本地会话,而本地会话此刻还没建立,于是陷入“跳到 CAS,回跳后再被拦截,再跳 CAS”的无限循环。

我常用的顺序模板是:

  1. 静态资源和匿名路径,如/static/**、/public/**配置成anon。
  2. CAS 校验回调路径,配置成casFilter。
  3. 登出路径,配置成logout。
  4. 需要角色的粗粒度路径,如/admin/**。
  5. 兜底的/**,配置成user。

user过滤器表示只要通过登录验证即可访问,它比authc更宽松一点,会自动识别“记住我”的状态。内部后台系统如果没有特殊要求,用user更顺手。

3.2 “退出后还能访问”,多半是单点登出没落地

我遇到过不少项目主登出功能做得像模像样,点退出按钮后跳转到了登录页,但原路返回业务地址还是能直接打开。原因很简单:CAS 服务端的会话销毁了,但业务系统本地的 Session 还活着,而 CAS 又不会主动通知业务系统“用户已全局退出”。

单点登出要生效,业务系统必须监听 CAS 的登出回调。做法是把SingleSignOutFilter注册成全局 Servlet 过滤器,必须在 Shiro 过滤器之前执行:

@Bean public FilterRegistrationBean<SingleSignOutFilter> singleSignOutFilter() { FilterRegistrationBean<SingleSignOutFilter> registration = new FilterRegistrationBean<>(); registration.setFilter(new SingleSignOutFilter()); registration.addUrlPatterns("/*"); registration.setOrder(Ordered.HIGHEST_PRECEDENCE); return registration; }

同时还要注册SingleSignOutHttpSessionListener,监听 Session 销毁事件。注意:Shiro 自己的logout过滤器负责“本地登出”,它只销毁业务系统的本地会话;要真正实现全局登出,业务系统的退出按钮不能只调本地/logout,还要重定向到 CAS 服务端的logout?service=业务系统首页,让 CAS 先销毁全局会话,再触发回调清掉所有已接入系统的本地会话。这个细节不处理好,就会觉得“我明明实现了 Shiro 登出,但 SSO 根本不彻底”。

4. 前后端分离场景,这套方案该怎么调整

这几年很多公司都在做前后端分离改造。传统 MVC 模式下 CAS 跳转很顺畅,但到了分离开发现service参数和 Session 都变成了问题。

4.1 前端是 hash 路由,登录回跳地址怎么定

前端如果采用http://app-a.example.com/#/user/list这种路由,后端通过request.getRequestURI()拿到的只是index.html的路径,拿不到#后面的业务地址。这时如果service参数设置为前端完整页面地址,CAS 回跳后要么路由丢失,要么前端根本拿不到真实目标页。

一个相对稳妥的做法是:service参数固定指向后端的一个回调接口,例如/casLoginCallback。CAS 校验通过后,后端直接重定向到前端首页,同时用一次性 token 或会话 cookie 传递登录态,前端再根据自己本地存储的“上次访问地址”进行跳转。

@GetMapping("/casLoginCallback") public String casLoginCallback(HttpServletRequest request) { // 走到这里说明票据已经在 CasFilter 中完成校验, // 直接返回前端首页地址即可。 return "redirect:" + frontendIndexUrl; }

这个方案需要前端配合维护一个本地路由栈,但逻辑清晰,而且不会因为前端框架升级导致后端频繁改动。

4.2 本地 Session 和前端 Token 怎么分工

Shiro 默认基于 Session 和 Cookie 工作,在前后端分离下很多人第一反应是改成“无状态 Token”。我的建议是不要为了分离而分离:如果部署在同一个域名体系下,Cookie + Session 依然是最简单可靠的方案,只需要在跨域时配置AllowCredentials和明确的Access-Control-Allow-Origin。

真要使用纯 Token 方案,通常意味着你要放弃 Shiro 原生 Session 管理,改成每次请求都解析 Token 并手动Subject.login(),或者引入无状态的 JWT 过滤器。这样做工作量会显著上升,而且很多 Shiro 的注解和会话特性都要绕过去。对于公司内部系统、用户量可控的场景,我始终觉得保留 Cookie + Session,把精力放在权限模型和数据安全上更划算。

5. 实战排错:那些“明明配了还是不行”的经典问题

我在这套方案上踩过的坑,基本都能做成一本《疑难杂症手册》。这里挑几个高频问题,按排查顺序给出一手经验。

5.1 登录成功后为什么又跳回登录页

这个现象很像“死循环”:输入账号密码,回跳业务系统,业务系统又把你弹到 CAS 登录页。

我通常按如下顺序排查:

  1. 确认ticket没有被重复使用。CAS 的 ST 是一次性的,如果service地址每次携带的参数不同,导致票据校验时 URL 不一致,CAS 可能不认。排查时看 CAS 服务端日志里有没有 “Invalid ticket” 之类字样。
  2. 确认service参数做了 URL 编码。service里如果带&参数,没有编码的话 CAS 回跳地址会解析错乱。
  3. 确认本地 Session 创建成功。检查 Redis 或内存 Session 里有没有建立新的会话,如果JSESSIONID每次请求都变,说明浏览器端 Cookie 没有写回去,通常是跨域或 Cookie 属性配置问题。
  4. 确认casFilter匹配路径正确。参考 3.1 里的顺序问题。
症状常见原因第一步检查
登录成功,回跳后再次要求登录ticket 被重复使用或 service 不一致CAS 服务端日志,确认消费过的 ST
刷新一次又退回登录页本地会话写入失败浏览器 Cookie 和 Session ID 是否稳定
第一次能进,换台机器不行CAS 域名 Cookie 作用域配置错误CAS 服务端 Cookie 配置的 Domain
前后端跨域时反复跳转跨域请求没携带 Cookie前端withCredentials设置

5.2 依赖版本带来的历史遗留问题

Shiro 在早期版本里内置了CasRealm和相关的 CAS 支持,后来这部分实现经历了多次调整。我自己踩过的坑是:升级 Shiro 版本后,发现原来的org.apache.shiro.cas.CasRealm找不到了,整个项目启动直接报ClassNotFoundException。

遇到这类问题时,先去看当前 Shiro 版本对应的官方文档,确认 CAS 集成模块是否已经独立成单独依赖或更换为推荐的替代方案。现在主流推荐是使用 pac4j 或自研一个继承AuthorizingRealm的子类,重写认证逻辑去调用 CAS 校验接口。不要死守旧类不放手,跟着官方生态走,后面升级才不会继续痛苦。

5.3 分布式 Session 和密码策略同样是隐藏雷区

当业务系统多实例部署时,Shiro 默认的本地 Session 无法多节点共享。常见做法是把 Session 放到 Redis,但这样做之后要记得同时调整 Shiro 的 SessionIdGenerator 和 Cookie 配置,确保多实例拿到同一个 Session 而不是各自为政。密码策略则更多发生在 CAS 服务端对接旧系统数据库时,比如老系统使用了 MD5、SHA1 等摘要算法,新启动的 CAS 服务端默认可能要求 BCrypt,结果一堆老用户全部登录失败。这种情况建议在 UserDetailService 里做编码器适配,或者预留一个密码升级接口,让用户在首次成功登录后自动切换为强摘要算法。

6. 落地顺序和后续演进的一些经验

这套方案折腾下来,我体会最深的一点是:先不要追求大而全,把最小闭环跑通比什么都重要。

6.1 我建议的集成落地顺序

如果你正准备从零开始做 Spring Boot + Shiro + CAS,我建议按这个顺序来:

  1. 先搭 CAS 服务端,用最简单的内存用户跑通登录认证;
  2. 用一个非核心业务系统接入 Shiro,只做登录跳转,不做权限细化;
  3. 验证单点登出,确认 A 系统退出后 B 系统也确实退出;
  4. 再加角色和权限,先把 URL 级别拦截做出来,再上方法注解;
  5. 最后才做按钮级权限和前后端分离适配。

很多团队一上来就想把所有系统同时接入,结果登录跳转、票据校验、登出回调这些问题混在一起,排查难度成倍增长。先用两个系统验证链路,再逐步铺开,反而更快。

6.2 如果项目继续膨胀,后面可以从哪里入手

如果这套方案将来要支撑更多系统或更大的用户量,可以考虑两个演进方向。一是将 CAS 逐渐升级或替换为支持 OIDC/OAuth2 的统一认证服务,让第三方系统和移动端接入的成本降低。二是在所有业务系统前面加一层网关,把“会话校验”上移到网关层统一处理,业务系统只负责解析已经确认的用户身份,这样授权链路的复杂度就不会继续下放给每个团队。

但我要泼一盆冷水:在没有明确需求之前,不要为了演进而演进。CAS + Shiro 这套组合在几十个内部系统范围内完全扛得住,真正决定系统好不好的,是你的权限模型设计得够不够清晰、数据变更节奏能不能跟上,而不是框架本身。

最后再分享一个小技巧:做第一轮联调时,就把 CAS 服务端和业务系统的日志级别调到 DEBUG,然后完整走一次“登录、访问、退出、再登录”的流程。你会看到票据的生成与消费、Shiro 的过滤器匹配、Realm 里的授权加载,所有环节都清清楚楚写在日志里。信我,等你在生产环境里对着几百行业务日志找 SSO 问题的时候,会特别怀念当初这些干净的调试日志。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进