
这一篇是系列里的第六篇。前五篇我们把环境搭起来了登录流程通了授权也开始生效了。但说句实话很多项目走到这一步只是“能演示”真正难的是把这条链路放进生产环境里跑稳。比如多个业务系统同时对接会话怎么共享用户权限改了缓存为什么不生效晚上两点线上反馈有人退出登录日志里却什么都没有。这一篇不打算再把基础配置抄一遍而是把我在实际落地过程中遇到的高频问题、排查思路和最终解决方案整理出来围绕springboot、shiro、cas这套组合把登录、授权和sso单点登录后半段的坑一个个填平。1. 这一篇写在系列文的哪里前五篇做完之后还得解决什么1.1 前面几篇已经把基础链路走通了如果按顺序读下来现在你应该拥有一套能跑起来的demo一个CAS服务端一个或多个SpringBoot客户端Shiro负责本地会话管理CAS负责统一认证。流程上用户第一次访问业务系统A被Shiro拦截跳转到CAS登录页输完账号密码拿到ticket再回调回系统AcasFilter校验ticketRealm查询用户信息最后由Shiro建立本地会话。这套流程是标准路线能跑通不代表能扛住线上环境。真正的问题恰恰出现在“多个系统一起接入”的时候。比如系统A和系统B都接同一个CAS用户登录完A再访问B理论上应该免登录但实际却会重新跳转或者更诡异的——A和B读到的用户信息错位了。还有权限场景同一个用户在不同系统里的角色不一样你在A系统是管理员到了B系统却变成普通员工权限缓存怎么隔离也是个大问题。1.2 本篇重点补三块短板我大致梳理了一下第六篇要覆盖的内容集中在三块。第一块是认证链路的细节校验也就是从请求被拦截到最终登录成功的整个过程中有哪些步骤看似正常但实际有隐患。第二块是会话与会话缓存这直接决定SSO能不能在多系统间真正生效也是出错率最高的地方。第三块是运维与排障手段单点登录系统上线后最大的难点是问题难定位登录涉及多个服务日志如果不完整排查全靠猜。这一篇我会用一个小规模的多系统场景来讲假设有一个统一认证中心CAS服务端三个接入的业务系统分别处理运维、财务和报表业务。这样更贴近实际部署也方便把缓存隔离、权限拆分和登出同步这些点讲清楚。1.3 实验环境的虚拟约定后面文中涉及的代码和配置我会用一个叫“Passport”的模块代指CAS服务端三个业务系统分别叫“系统A”“系统B”“系统C”。它们都部署在同一台服务器的不同端口上通过nginx做域名转发。这个环境是我在本地反复折腾过的配置上不存在偶然因素你照着搭基本也能复现。2. 认证链路逐段检查一次完整登录是怎么跑通的2.1 Service回调地址别名与真实端口的匹配陷阱在CAS协议里service参数是整个登录链路的关键。它决定了用户在认证中心登录成功后要跳回哪个地址。举个实际例子你在本地的nginx配置了域名user.example.com指向系统A的8080端口那么系统A向CAS发起登录跳转时service参数应该是https://user.example.com/api/auth/callback。这里有两个注意点。第一个注意点是认证中心会对比service参数是否在它的“可信服务列表”里。CAS服务端有service registry配置如果用户访问的是内网IP而注册的是域名会被判定为非法服务直接报302循环。很多同学在本地联调时明明配置没问题却一直跳不回来十有八九是service的域名和端口不一致。第二个注意点是要区分两个地址一个是对外暴露的地址经过nginx一个是应用本身监听的内网端口。如果你的系统A在8080端口但用户访问的是80端口那么在Shiro里配置的casServerUrlPrefix和redirectUrl都必须以对外地址为准。我见过不少人把Shiro的loginUrl配成了内网地址结果用户在外网登录成功后浏览器跳到一个内网IP自然打不开。这个问题在本地测不出来部署到服务器上才暴露属于典型的“环境差异坑”。2.2 登录成功后回跳地址丢失的处理CAS登录成功以后浏览器会携带ticket访问业务系统的回调接口。Shiro的casFilter在这里做了两件事先校验ticket再调用Realm创建Subject。但这些都发生在回调地址上并不是用户最初访问的那个页面。如果你不做处理用户登录完成后会被丢到系统的默认首页而不是他一开始想打开的那个深链接。要实现“登录前想访问A页面登录后自动跳到A页面”有几个方案。最简单的是使用Shiro原生的SavedRequest机制在拦截器里把原始地址存到session里登录成功后再重定向回来。还有一个方式是让前端在跳转登录之前把当前页面的完整URL通过参数传给后端后端在生成CAS登录地址时拼接成service的一部分。但这里有个细节需要注意service参数会被CAS服务端原样解析如果你把过长的URL塞进service某些CAS版本会限制参数长度。我在实际项目里会把原始地址做一次URL编码作为额外参数传而不是直接拼接进service。比如生成登录地址时用servicehttps://system.example.com/auth/callback?redirect_uriencodeURIComponent(/report/page)这样既不会撑爆service参数也能保证回跳地址可控。2.3 Filter注册顺序anon和authc不是随便排的再讲一个我排查了很久才搞明白的坑Shiro的FilterChainDefinitionMap里你写/login authc和/login anon的顺序直接影响是否能访问登录接口。正常情况下CAS登录回调接口比如/auth/callback必须放行否则回调请求会再次被拦截形成无限重定向。放行的正确姿势是把/auth/callback anon放在所有拦截规则的最前面。同时要注意anon只是放行它不会执行任何认证逻辑。如果你的回调接口需要获取当前用户信息不能指望在这个Filter里自动创建Subject因为Shiro是懒加载的只有你调用SecurityUtils.getSubject()时才会触发登录逻辑。这一点在前后端分离的场景里尤其容易踩前端以为登录成功了调后端接口还是401原因就是回调接口只做了ticket换token没有把token写回客户端的session里。3. 授权体系在六个子系统里的实战扩展3.1 一个认证中心多套权限模型对接多个系统的时候最容易产生的分歧是“权限到底放在哪”。有的团队希望所有权限都交给CAS统一管有的团队坚持每个系统自己管自己的。以我看到的比较理性的做法是CAS只负责“证明你是谁”权限留在各个业务系统本地维护。具体来说认证中心在登录成功后会返回用户的基础身份信息用户ID、姓名、部门业务系统本地通过这个用户ID去查自己的角色和权限表。这样做有几个显而易见的好处每个系统的权限模型可以根据业务自由设计比如财务系统需要分金额粒度报表系统只需要按目录授权权限变更不需要全部推到认证中心业务系统自己改自己的表就行系统的稳定性也不依赖于认证中心的接口即使CAS暂时不可用已登录用户依然能在本地会话内操作。3.2 动态URL访问控制和Shiro Filter的结合业务系统的权限往往不只是判断“是否登录”还要判断“某个角色能否访问某个URL”。Shiro的经典做法是在FilterChainDefinitionMap里写死规则比如/admin/** roles[admin]。但如果你有一个配置中心希望管理员随时调整角色对应的URL权限写死的Filter链就满足不了了。我做过一个相对通用的方案把权限规则存在数据库里启动时加载。FilterChainDefinitionMap不是静态的而是从一个Service里动态读取。每次有新的菜单权限配置更新就调用ShiroFilterFactoryBean.getFilterChainDefinitionMap()重新构建再刷新SecurityManager。这样的好处是权限更新不需要重启服务。需要注意的坑是动态刷新FilterChain的时候会导致某些在线用户短暂被踢下线。原因是刷新时会重建SessionManager老的Session对象会失效。如果要做无感刷新必须保证CacheManager是同一个实例并把SessionDAO设置成Redis实现否则生产环境会大面积断线。3.3 接口鉴权与页面鉴权分开处理Shiro最方便也最坑的地方在于它同时拦截页面和接口。页面拦截失败可以跳转登录页但接口拦截失败跳转登录页就很尴尬了前端拿到的是一段HTML而不是JSON。我的处理方式是做一个统一的异常处理器捕获AuthorizationException和UnauthenticatedException根据请求头判断是普通浏览器访问还是AJAX请求分别返回重定向状态和JSON结构体。代码大致是这样的在全局异常处理器里判断请求是否带有X-Requested-With: XMLHttpRequest头。如果是接口请求直接返回403状态和错误码如果是页面请求才执行重定向。这里有个建议接口鉴权尽量不要依赖Shiro的RequiresPermissions注解在Controller方法上做AOP校验。因为注解只能拦截SpringMVC层如果走了内部调用或者RPC转发注解就不会生效。把鉴权逻辑下沉到Service层或者更底层才能覆盖所有调用路径。4. 会话、缓存与并发登录三类最容易出问题的细节4.1 Session ID的生成规则与会话固定攻击Shiro默认会使用Servlet容器的会话机制Session ID也默认由容器生成。在CAS场景下这个默认方式隐藏了一个安全风险用户在跳转到CAS登录页之前业务系统已经给他分配了一个Session ID。如果这个ID没有变登录前后用的是同一个攻击者一旦提前拿到这个ID在用户登录后就能直接冒用身份。解决办法是在casFilter校验通过、Realm创建完Subject之后主动让Session失效并重新生成ID。Shiro里可以用session.stop()再通过newSession()实现。或者简单一点在登录成功的回调逻辑里手动执行request.changeSessionId()这样既不影响当前会话数据又能更换会话编号。这个细节很多教程不会讲因为它只影响安全强度不影响功能。但对于面向公网的登录系统我强烈建议加上。做渗透测试的时候会话固定攻击是一个常规检查项不要在这里翻车。4.2 分布式环境下的Redis Session序列化问题单机部署时Shiro的Session默认放在内存Map里重启就丢用户得重新登录。这在开发环境无所谓但生产环境一旦有多台机器做负载均衡就必须换成外部会话存储。第一步是把SessionDAO换成一个支持Redis的实现。这里藏着一个很深的坑Shiro内部对Session的存储对象是有状态依赖的。如果Redis客户端配置的序列化方式不对存进去是一坨Java对象取出来变成另一坨Java对象各种ClassCastException会蜂拥而至而且只在某个节点随机出现。我在项目里就遇到过用户在系统A登录后下一跳请求被负载均衡转发到系统B系统B从Redis里读Session结果反序列化失败。经过排查根因是使用了自定义的Fastjson序列化器但Session对象里嵌套了一个Shiro内部类那个内部类没有默认构造函数Fastjson反序列化时直接炸掉。换回JDK自带的序列化器后问题彻底消失。所以如果你不熟悉序列化细节第一版老老实实使用原生JDK序列化稳定压倒一切。等整个链路跑顺了再考虑用统一的JSON序列化方案优化体积。4.3 单用户多端登录如何做到互踢和在线数控制产品通常会提这样一个需求同一个账号只允许在最后一个登录的地方生效之前的登录会被强制下线。在Shiro里实现互踢核心思路是记录用户的登录会话标记每次登录时把旧的Session标记为失效。我在项目里用了一个轻量方案在客户端发送请求时通过一个拦截器读取用户标识查询当前保存的“在线会话集合”。如果集合里有旧会话就停止旧会话。这样用户一旦在新设备上登录旧设备上的任何请求都会因为会话失效而跳转到登录页。要注意的是缓存里的会话键必须是用户ID值必须是Session ID列表。如果只存最后一个Session ID会出现一个用户同时登录多个系统但互不干扰的场景没法处理。比如用户同时打开系统A和系统B两个系统各自维护会话你如果只记录一个Session ID就只能踢掉其中一个系统另一个系统的会话还活着用户就会在A系统被踢B系统正常看起来像是随机掉线。5. 单点登出与全局失效配一个能用的SLO流程5.1 登出触发链路前端主动POS和CAS回调单点登出Single LogoutSLO是整个链路里最容易被忽略的部分。CAS服务端提供了单点登出回调机制当用户访问认证中心的登出接口时CAS会向所有已注册的CAS客户端发送一个登出通知各个客户端收到之后清除本地会话。问题在于这个通知是异步的而且是内网服务间调用。如果业务系统A部署在公网认证中心在内网两者之间网络不通登出通知就送不到。这种情况下用户以为退出了但系统A本地会话还活着重新访问又自动登录了体验极其分裂。5.2 我能接受的降级方案登出后清理两类缓存目前我比较认可的降级策略是业务系统提供一个登出接口前端在登出时同时调用认证中心和各业务系统的本地登出接口。业务系统本地登出接口要做两件事一是删除自己的Session记录二是清除权限缓存和Realm缓存。用Shiro的DefaultSecurityManager时权限缓存在Realm里有一个AuthorizationCache。如果这里不清空用户退出后再登录其他账号有可能读到上一个账号的权限缓存出现越权访问。清缓存的操作放在登出接口里会比较保险调一次clearCachedAuthorizationInfo()即可。这个细节我提过多次但每次还是要强调登出别只删SessionRealm缓存必须一起清。5.3 CAS服务端挂掉时的本地会话保护单点登出还有一个反向场景认证中心服务不可用用户访问业务系统时本地会话还在能不能继续操作我的习惯是业务系统就是基础的登录和权限判断不要依赖远程CAS认证中心的接口只用于“首次登录”和“登出广播”。只要本地Session没过期权限缓存还在业务系统就应该允许用户正常使用。只有在本地会话失效、需要走SSO登录的时候才会访问CAS。这样设计可以让认证中心做到7x24小时允许短暂维护不会因为认证中心重启导致整个线上业务全部掉线。这在运维角度非常实用。我一直认为做SSO的最终目的应该是提升用户体验而不是引入新的单点风险。6. 运维视角日志、埋点与故障恢复6.1 登录失败定位日志里必须有三样东西登录问题一旦发生大部分人都是两眼一抹黑。要么是CAS跳不回来要么是ticket校验失败要么是Realm查询出错。如果没有一套完整的日志链定位问题的时间会从分钟级拉到小时级。我给自己的登录日志定了三个必打字段用户ID未登录时为匿名标识、客户端IP、回调地址。日志格式类似[LOGIN] userId1001, clientIp192.168.1.10, servicehttps://system.example.com/auth/callback, resultsuccess, msgticket validated有些登录失败是发生在CAS服务端的业务系统的日志根本看不到。这种情况我会在认证中心和业务系统两边都打日志并且通过同一个requestId串联。做法是在业务系统发起登录跳转之前生成一个UUID放入session并把它作为service参数的一部分传给CASCAS在日志里记录这个UUID回调时再带回来。这样就能用requestId把一次完整登录的事件串起来。6.2 在线人数统计与会话心跳探活SSO上线后运维大概率会问你当前有多少在线用户这时候如果之前没做过埋点就非常被动。统计在线人数的标准做法是利用SessionDAO统计活跃Session数量。Shiro的SessionDAO提供了getActiveSessions()方法可以拿到所有会话。但要注意这个方法拿到的会话有很大比例是“僵尸会话”。用户直接关闭浏览器Session不会立刻消失只有等到超时才会被回收。所以如果直接用getActiveSessions()统计数据会偏大。我的做法是把Redis里的Session视为原始数据再做一次心跳更新每次请求时记录lastAccessTime统计时只计算五分钟内有活跃的Session。这样才能得到一个相对真实的在线人数。6.3 一个容易被忽略的环境坑时间同步最后分享一个特别隐蔽的坑。CAS的ticket校验过程会把当前时间戳放进ticket文本里服务端验证时会检查这个时间戳是否在允许的误差范围内。如果你的认证中心服务器时间偏移超过几十秒客户端提交的ticket会被判定为无效用户登录会随机失败。多个业务系统的机器之间时间不同步也容易出这个问题。因为CAS服务端生成ticket时用自己的时间客户端校验时用自己的时间如果两者误差过大校验直接失败。这个问题的表象非常迷惑登录有时候成功有时候失败完全没规律。排查方法也很简单在所有参与认证的设备上执行时间同步命令统一NTP服务器源。我在项目里踩过这个坑之后已经习惯把时间同步写进部署文档的第一页。还有DNS缓存也可能导致类似问题如果CAS客户端的域名解析出不同IPticket落到不同服务端节点校验也容易失败。这些和代码无关的环节恰恰是SSO稳定性的地基。把时间、域名、网络这些基础问题确认清楚再排查代码逻辑才是正确的排障顺序。