写在前面:这篇是系列第六篇。前三篇我们跑通了基础登录流程,第四篇解决了前端跨域下的票据回传,第五篇主要聊了CasRealm权限加载和缓存策略。原本打算收尾,但后台和评论区反复出现几类问题:多个子系统接入时service参数怎么配、Shiro会话和CAS TGT之间的生命周期如何对齐、生产环境怎么优雅地做会话失效踢人、以及排查问题时日志里到底该看哪几行。这篇统一梳理一遍,算是把整套方案的最后一层窗户纸捅破。
1. 先理清CAS核心流程里最容易含糊的三个节点
很多朋友照着文档把配置写好了,登录也能跳转,但一旦碰到“为什么有时候登录失效了还要再输一次密码”“为什么回调地址不对就重定向死循环”这类问题,就卡住了。根本原因是对CAS协议里三个节点的语义理解不透:service参数、票据校验、会话粒度。
1.1 service参数不是随便填的,它决定票据发往哪里
CAS认证中心在放行之前,会拿着客户端传上来的service参数去和已注册的服务列表做匹配。这个匹配规则在不同版本里略有差异,但核心逻辑是一样的:回调地址必须和注册时填写的地址精确匹配,包括协议、域名、端口、路径。
举个例子,某公司内部有三个系统,配置分别如下:
| 系统 | 注册地址 | 实际回调用地址 |
|---|---|---|
| 财务系统 | https://fin.corp.local:8443/cas/callback | https://fin.corp.local:8443/cas/callback |
| 审批系统 | https://appr.corp.local/cas/callback | https://appr.corp.local:8443/cas/callback |
| 报表系统 | http://report.corp.local/cas/callback | https://report.corp.local/cas/callback |
审批系统第一行就踩了坑:注册时没写端口,实际回调带着8443,CAS直接判定为未授权服务,表现为跳转到登录页后立刻原样弹回,或者干脆报错“未授权的回调服务”。报表系统则是协议不一致,注册的HTTP和实际访问的HTTPS不匹配。这类问题排查时别再盯着代码看了,先去CAS管理后台核对注册地址,九成问题都在这里。
1.2 票据只能消费一次,这是很多人踩进去出不来的坑
CAS签发的ST(Service Ticket)是一次性的,后端拿着ST去CAS服务端换取用户信息后,这个ST立即失效。前端如果因为网络超时、页面刷新、重复提交等原因,把同一个ST再次发过来,CAS会友好地回一个INVALID_TICKET错误。
我见过不止一个团队在这里转圈:用户明明登录成功了,回来刷新一下就又跳回登录页。检查方向往往是filter顺序、Realm配置,其实问题出在浏览器缓存了回调URL,每次刷新都会把旧的ST再带上来。正确做法是后端在成功换取用户信息后,主动做个重定向,把URL里的ticket参数剥掉。Spring MVC里处理这个很直接,用RedirectView把请求转到不含ticket的地址即可。
1.3 TGT和Shiro Session是两个生命周期,不要混为一谈
CAS的TGT(Ticket Granting Ticket)存储在CAS服务端的会话里,负责维持“这个浏览器已经在CAS登录过了”的状态。Shiro的Session是应用服务端的会话,负责维持“用户在某个子系统内有合法身份”的状态。
两者在默认配置下没有任何联动关系。每个子系统的Shiro Session超时时间可能不同,CAS的TGT超时时间也可能不同。实际出现的现象就是:应用这边Session已经过期被踢回登录页,但CAS那边TGT还在有效期内,于是用户重新走一遍跳转、静默换票、回调,整个过程无感知,看起来像登录状态没掉。反过来也一样,应用Session还在,CAS的TGT先过期了,等用户下次访问需要重新走CAS认证的页面时,就要重新输密码。
这两者的一致性需要根据业务场景取舍。金融类、财务类系统建议统一缩短TGT时间,强制关键操作重新认证;内部办公系统则建议TGT保持宽松,让子系统的会话管理自己承担超时控制。
2. Shiro与CAS对接时,会话管理的几处关键配置
Shiro本身是权限框架,但在CAS模式下,它的角色更像一个“本地会话管家”。处理好会话生命周期,相当于把用户从CAS“借”过来的身份,安全地保存在子系统内部一段时间。
2.1 ShiroCasFilter的认证顺序不能放错
常见的Filter链配置里,casFilter必须放在logoutFilter之外的最前位置,确保每次请求进来先判断是否已有认证票据。一个比较稳妥的链配置大致是:
logoutFiltercasFilterauthc(或自定义的userFilter)roles、perms等授权过滤器
如果把authc放在casFilter前面,会导致未认证请求直接被拦截在本地,根本到不了CAS那边,登录跳转逻辑完全失效。
2.2 CasRealm的权限加载,要搞清楚和缓存的关系
Shiro的CasRealm主要负责从CAS返回的用户名校验,但真正的角色、权限数据通常还是由子系统的用户服务提供。这里推荐的做法是继承CasRealm,重写doGetAuthorizationInfo方法:
public class CustomCasRealm extends CasRealm { @Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username = (String) principals.getPrimaryPrincipal(); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); // 从本地用户中心加载角色与权限 Set<String> roles = userService.findRoles(username); Set<String> perms = userService.findPerms(username); info.setRoles(roles); info.setStringPermissions(perms); return info; } }这里有个容易被忽略的点:每次请求经过授权过滤器时都会调用doGetAuthorizationInfo,如果不做缓存,高并发下用户中心接口压力会很大。Shiro默认的AuthorizationCache是开启的,但很多人在自定义Realm时把AuthorizationCache给忘了,或者因为调试方便直接关了缓存。建议在配置里显式声明:
@Bean public JdbcCacheManager cacheManager() { JdbcCacheManager manager = new JdbcCacheManager(); manager.setCacheManager(new MemoryConstrainedCacheManager()); return manager; }2.3 Session超时策略要和顶部导航的“退出”按钮联动
很多系统的退出按钮只调用了子系统自己的退出接口,把本地的Shiro Session销毁了,但CAS的TGT还活着。下回用户再点其他需要认证的页面,又会自动静默登录,退出功能形同虚设。
正确的退出链路是:先调用子系统的退出接口销毁本地Session,同时带上service参数重定向到CAS的注销接口,让CAS把TGT一并销毁,等CAS注销完成后再跳回指定页面。CAS注销接口的完整形态是:
https://cas.corp.local/logout?service=https://fin.corp.local/loginShiro这边对应的logoutFilter要配置redirectUrl,同时前端退出操作也建议同步触发CAS注销,而不是只走后端跳转。
3. 多子系统接入时的三个实践细节
单系统接入CAS相对省心,真正麻烦的是多个子系统陆续接入。子系统之间共享一套认证体系是最容易的事前规划,但执行过程中一不留神就成了各调各的、互不兼容的孤岛。
3.1 回调地址统一走网关层会省掉一大半麻烦
如果每个子系统的CAS回调地址直接暴露各自的应用服务器,后续一改端口、调负载均衡、切域名,就得在CAS管理后台同步更新注册地址。更稳妥的做法是让所有子系统的回调统一经过内部网关转发,子系统只感知一个固定域名下的路径。
这样CAS管理后台的注册表就非常简短干净:
| 服务标识 | 注册回调 |
|---|---|
| 办公平台 | https://sso-gateway.corp.local/portal/cas/callback |
| 财务系统 | https://sso-gateway.corp.local/fin/cas/callback |
| 数据分析 | https://sso-gateway.corp.local/bi/cas/callback |
网关层再把同一域名下的不同路径转发到真实的服务节点。这套做法的价值在后续扩容、灰度发布时体现得特别明显,应用服务器IP变了根本不用动CAS配置。
3.2 各自的Shiro Session不要互相干扰
子系统虽然共享CAS认证,但各自的Shiro Session默认是各存各的。有些团队图省事,想用一套分布式Session同时覆盖多个子系统,结果常常出现权重均衡到另一台节点就提示“未登录”、用户信息串号等问题。如果非要统一,优先考虑让所有子系统共用一个会话存储中间件,同时保证Session的序列化方式一致,否则反序列化报错会让人崩溃。
经验之谈:单体阶段别为了“以后可能扩展”强行上分布式Session,复杂度是实打实增加的。保持各自独立的Shiro Session,配合CAS的TGT兜底,逻辑简单且不容易出错。
3.3 全局会话超时配置要一条链路打通
三个子系统的超时时间如果各定各的,用户半小时前在A系统还在用,切到B系统一看要重新登录,体验非常割裂。建议找个时间拉一次对齐会,把CAS的TGT超时时间、各子系统Shiro Session的超时时间统一成同一套参数。常见的做法是:
- TGT超时时间(CAS服务端)定为基础会话时长,比如8小时
- 各子系统Shiro Session超时时间 = TGT超时时间减去一个安全余量,比如7小时50分
这种做法能避免边缘情况下CAS.TGT先一步过期,导致用户静默掉线去重新走一遍认证。
4. 授权策略的常见误区和权限模型设计
登录流程通了之后,真正考验的是授权。很多系统的权限设计只有“登录即可访问”和“管理员专属”两级,这在内部工具类系统里勉强够用,但放到业务系统里很快就失控了。
4.1 粗粒度权限用角色,细粒度权限别硬塞给Filter
Shiro的roles过滤器适合做粗粒度控制,比如“财务角色才能访问报表模块”。但如果要做“只能用他人报销单、不能改、只能提交到自己部门的流程”这类数据级权限,再靠角色和URL匹配就力不从心了。
建议分层处理:角色和URL级别的权限交给Shiro注解或Shiro Filter控制,数据级权限放在Service层做校验。Shiro只是守门员,具体业务规则的裁判还得是业务代码自己。
@RequiresRoles("finance:approver") @PostMapping("/approve") public Result approve(@RequestBody ApproveDTO dto) { // 这里判断当前用户是否具备该单据的审批权 permissionService.checkApprovePermission(dto.getOrderId()); }4.2 权限缓存更新失败导致用户权限变更不生效
权限变更不生效是最常见的授权类投诉。用户上午被降了权限,下午还在访问根本没有权限的功能。多数情况是Shiro的授权缓存还在生效,即使数据库和用户中心的权限数据已经改了。
解决办法是在用户权限变更的同一事务里,显式调用Shiro缓存清理,同时提供一套集中式的缓存刷新入口:
public class ShiroCacheHelper { public static void clearAuthc(String username) { DefaultWebSecurityManager manager = (DefaultWebSecurityManager) SecurityUtils.getSecurityManager(); CachingRealm realm = (CachingRealm) manager.getRealms().iterator().next(); realm.clearCache(SecurityUtils.getSubject().getPrincipals()); } }如果是分布式部署,各节点各自持有本地缓存,还得借助消息通知机制让所有节点都执行清理,只清理一台的缓存是没用的。
4.3 权限模型落地前先画一张“角色-资源”矩阵
我建议在动手写代码之前,先和业务方一起明确一张权限矩阵表:横轴是功能点,纵轴是角色,单元格里勾选可访问级别。这张表既能当开发排期依据,又能当验收标准,后续做安全审计也有据可查。没有这张矩阵的权限系统,开发过程中一定会出现“你说你加了权限,但你能访问,我也能访问”的扯皮。
5. 踩坑实录:这套组合最典型的问题和排查路径
下面这些问题是过去N次技术支持和代码review中反复遇到的,整理成速查表,直接照着排查能少走很多弯路。
| 现象 | 排查路径 | 大概率原因 |
|---|---|---|
| 跳转CAS后陷入无限重定向 | 1. 检查CAS服务端是否收到回调请求 2. 检查service参数是否与注册地址一致 | 回调地址未精确注册,或注册时遗漏端口 |
| 登录成功但刷新就掉线 | 观察回调URL是否还残留ticket | 未做ticket剥离,刷新重放了旧ST |
| 退出后点其他系统又自动登录 | 查看CAS TGT是否还在有效期内 | 子系统的注销未联动CAS注销 |
| 权限改了不生效 | 确认是否走了权限缓存 | 授权缓存未清理或清理通知没送达所有节点 |
| 偶尔出现同一浏览器用户身份串号 | 检查Session Cookie的Path和Domain | Cookie作用域过宽,或者是分布式Session序列化串了 |
5.1 一次典型的“登录成功但页面空白”排查
某次做模拟项目X时,一个子系统登录成功后页面渲染出来一片空白,控制台没有任何报错,后端日志也停在CAS换票成功那一步。一开始以为是前端路由问题,查了半天没头绪。后来打开浏览器网络面板,发现回调请求返回了302,重定向到登录页,但登录页又带了CAS票据,又302回来,来回跳转后页面被浏览器判断为重定向死循环,表现为空白。
最终定位是Shiro的authc过滤器拦截了回调地址本身,而该地址又没有设置anon权限。解决方法是给回调路径加白名单:
/ = authc /cas/callback = anon /static/** = anon这类问题用肉眼盯代码很难发现,开浏览器开发者工具看请求链路,5分钟就能锁定。
5.2 排查时日志到底该看哪几行
遇到认证问题,别一上来就翻全量日志,抓重点看这几个位置:
- CAS服务端日志:看是否收到
validate请求,返回的XML片段里是否有<cas:success>或<cas:error>。 - 子系统Shiro日志:看
casFilter是否生成AuthenticationToken,以及Realm的doGetAuthenticationInfo是否被调用。 - 过滤链访问日志:确认每个请求实际命中了哪些Filter,顺序对不对。
- 缓存日志:确认权限获取是走了缓存还是重查了用户中心。
把这四个位置打印好,绝大多数问题都能在一杯茶的功夫里定位。
6. 这篇的最后一个提醒
这段内容不在原计划里,但写完上面这些后,觉得必须补上。很多团队把单点登录功能交给我或者团队里某个成员兼职做,能跑通就交付了,这是大忌。单点登录是横跨所有系统的安全基础设施,上线意味着整个公司的身份认证都压在它上面。建议在功能交付前,至少补上这几件事:理清三层会话的生命周期,列清哪些服务允许接入认证中心的注册表,梳理权限矩阵并和业务方确认,再把排查路径和日志采集工具准备到位。
整套系统做到这个程度,才算真正达到上线标准,而不只是能跑通Demo。下一阶段如果有时间,可以再把审计日志和风险控制单独展开聊,那两个话题同样值得细写。