这一篇是系列里的第六篇。前五篇我们把环境搭起来了,登录流程通了,授权也开始生效了。但说句实话,很多项目走到这一步只是“能演示”,真正难的是把这条链路放进生产环境里跑稳。比如多个业务系统同时对接,会话怎么共享;用户权限改了,缓存为什么不生效;晚上两点线上反馈有人退出登录,日志里却什么都没有。这一篇不打算再把基础配置抄一遍,而是把我在实际落地过程中遇到的高频问题、排查思路和最终解决方案整理出来,围绕springboot、shiro、cas这套组合,把登录、授权和sso单点登录后半段的坑一个个填平。
1. 这一篇写在系列文的哪里:前五篇做完之后还得解决什么
1.1 前面几篇已经把基础链路走通了
如果按顺序读下来,现在你应该拥有一套能跑起来的demo:一个CAS服务端,一个或多个SpringBoot客户端,Shiro负责本地会话管理,CAS负责统一认证。流程上,用户第一次访问业务系统A,被Shiro拦截,跳转到CAS登录页,输完账号密码拿到ticket,再回调回系统A,casFilter校验ticket,Realm查询用户信息,最后由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。比如生成登录地址时用service=https://system.example.com/auth/callback?redirect_uri=encodeURIComponent('/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 Logout,SLO)是整个链路里最容易被忽略的部分。CAS服务端提供了单点登出回调机制:当用户访问认证中心的登出接口时,CAS会向所有已注册的CAS客户端发送一个登出通知,各个客户端收到之后清除本地会话。
问题在于这个通知是异步的,而且是内网服务间调用。如果业务系统A部署在公网,认证中心在内网,两者之间网络不通,登出通知就送不到。这种情况下,用户以为退出了,但系统A本地会话还活着,重新访问又自动登录了,体验极其分裂。
5.2 我能接受的降级方案:登出后清理两类缓存
目前我比较认可的降级策略是:业务系统提供一个登出接口,前端在登出时,同时调用认证中心和各业务系统的本地登出接口。业务系统本地登出接口要做两件事,一是删除自己的Session记录,二是清除权限缓存和Realm缓存。
用Shiro的DefaultSecurityManager时,权限缓存在Realm里有一个AuthorizationCache。如果这里不清空,用户退出后再登录其他账号,有可能读到上一个账号的权限缓存,出现越权访问。清缓存的操作放在登出接口里会比较保险,调一次clearCachedAuthorizationInfo()即可。这个细节我提过多次,但每次还是要强调:登出别只删Session,Realm缓存必须一起清。
5.3 CAS服务端挂掉时的本地会话保护
单点登出还有一个反向场景:认证中心服务不可用,用户访问业务系统时,本地会话还在,能不能继续操作?我的习惯是,业务系统就是基础的登录和权限判断不要依赖远程CAS,认证中心的接口只用于“首次登录”和“登出广播”。
只要本地Session没过期,权限缓存还在,业务系统就应该允许用户正常使用。只有在本地会话失效、需要走SSO登录的时候,才会访问CAS。这样设计可以让认证中心做到7x24小时允许短暂维护,不会因为认证中心重启导致整个线上业务全部掉线。这在运维角度非常实用。我一直认为,做SSO的最终目的应该是提升用户体验,而不是引入新的单点风险。
6. 运维视角:日志、埋点与故障恢复
6.1 登录失败定位:日志里必须有三样东西
登录问题一旦发生,大部分人都是两眼一抹黑。要么是CAS跳不回来,要么是ticket校验失败,要么是Realm查询出错。如果没有一套完整的日志链,定位问题的时间会从分钟级拉到小时级。
我给自己的登录日志定了三个必打字段:用户ID(未登录时为匿名标识)、客户端IP、回调地址。日志格式类似:
[LOGIN] userId=1001, clientIp=192.168.1.10, service=https://system.example.com/auth/callback, result=success, msg=ticket validated有些登录失败是发生在CAS服务端的,业务系统的日志根本看不到。这种情况我会在认证中心和业务系统两边都打日志,并且通过同一个requestId串联。做法是在业务系统发起登录跳转之前,生成一个UUID放入session,并把它作为service参数的一部分传给CAS,CAS在日志里记录这个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客户端的域名解析出不同IP,ticket落到不同服务端节点,校验也容易失败。这些和代码无关的环节,恰恰是SSO稳定性的地基。把时间、域名、网络这些基础问题确认清楚,再排查代码逻辑,才是正确的排障顺序。