简介围绕 CAS、SpringMVC 与 Shiro 三者搭建的单点登录SSO工程示例适合已具备 Java Web 基础、希望把统一认证流程真正跑通的中级开发者参考。包内共 316 个文件以 126 个 jar 依赖包、38 个 java 源码、38 个 xml 与 33 个 properties 配置、44 个 jsp 页面为主另含少量 css、js、图片资源与证书文件压缩包约 40.79MB覆盖 CAS 服务端配置、客户端接入、自定义 CasRealm 与 CasFilter、SpringMVC 拦截器、登录登出及权限控制等模块。读者可据此理清票据校验的完整链路未登录访问受保护资源时重定向至 CAS 登录页回跳携带 Ticket 交由 Shiro 认证退出时同步清理本地会话与 CAS 服务端会话并可参考其中的异常处理写法排查网络失败、票据验证不通过等问题。该工程已有 493 人学习适合作为 SSO 集成入门的可运行骨架便于在此基础上按实际需求裁剪优化。1. 从三套账号到一次登录CAS Spring MVC Shiro 的组合定位某个后台被拆成工单、报表、运维三个子系统各自一套用户表值班同事每天上班先把三个登录页各输一遍密码改密码还得跑三处改。这类“多系统一套账号”的诉求落到 Java 栈上最省事的组合就是CAS 做单点登录、Spring MVC 做 Web 层、Shiro 做权限控制——CAS 回答“你是谁”Shiro 回答“你能干什么”Spring MVC 负责把两者的结果接进 Controller。这份资源给的就是把这套组合从零串起来的完整工程适合正给老系统做统一登录改造、或者被“一次登录多系统漫游”需求卡住的后端开发。它不追新潮胜在链路完整每一段都能动手改。2. 架构分层CAS Server、Spring MVC 客户端与 Shiro 过滤器链怎么串先把三个角色摆清楚再谈代码。很多集成方案翻车不是代码写错而是没想明白谁该持有会话、谁该负责校验、谁只做授权判断。CAS 是独立的认证中心客户端应用是“信任 CAS 断言”的消费方Shiro 则是客户端内部的权限闸门。三者职责一旦混在一起后面登出不同步、票据重复校验的问题就会接连冒出来。2.1 三个角色的职责边界与一次登录的完整时序角色部署位置核心职责不该干的事CAS Server独立 war通常单独一台机器、独立域名收账号密码、签发 TGT/ST、校验 ST、广播登出不存业务权限不关心某个系统的菜单cas-client 过滤器每个子系统内拦截未登录请求、拿 ST 回 CAS 校验、建立本地会话不判断角色不做业务拦截Shiro每个子系统内把已认证身份转成 Subject、角色/权限判定、会话管理不直接和 CAS 登录页打交道Spring MVC每个子系统内接收请求、调用服务、返回视图或 JSON不重复写登录校验逻辑一次完整的登录时序是这样的浏览器访问http://app-a/ordersAuthenticationFilter发现本地没有会话302 重定向到 CAS 的/login?servicehttp://app-a/cas/callbackCAS 检查浏览器有没有带 TGC Cookie没有就返回登录页用户提交账号密码校验通过后 CAS 生成 TGT 并把 TGC 写进 CAS 域下的 Cookie同时生成一张一次性的 ST302 跳回service?ticketST-1-xxxx客户端的TicketValidationFilter拿这张 ST 去 CAS 的/serviceValidate换回一段 XML 断言断言里带着用户名和扩展属性客户端据此建立自己的 HttpSessionShiro 再用这个身份构建 Subject。关键在于身份只认证一次会话各存各的。TGC 存在 CAS 的域下是“你已经在认证中心登录过”的凭证应用侧的 session 是“这个子系统认你”的凭证。第二次访问报表子系统时浏览器带着 TGC 去 CASCAS 直接签发新 ST用户看不到登录页——这就是单点登录的全部魔法没有更玄的东西。提示CAS 默认跑在 8443 的 HTTPS 上客户端回调地址也必须是 HTTPS。本地开发图省事改成 HTTP往往就是后面一串票据校验失败的开端。2.2 工程目录结构与 Maven 依赖清单工程按父子模块组织服务端和客户端彻底分离公共的 token、realm、工具类抽到sso-common里两个客户端子系统各自依赖它避免同一份 Realm 代码复制两遍sso-parent ├── cas-server # CAS 服务端打成 war 丢进容器 │ └── src/main/ │ ├── webapp/WEB-INF/ │ │ ├── cas.properties # 票据有效期、数据源等 │ │ ├── deployerConfigContext.xml │ │ └── services/ # 服务注册 JSON白名单在这 │ └── java/.../handler/ # 自定义认证处理器 ├── sso-common # 客户端公共代码 │ └── src/main/java/.../ │ ├── CasToken.java # 自定义 AuthenticationToken │ ├── CasRealm.java # 调 serviceValidate 的 Realm │ └── CasXmlParser.java # 解析断言 XML ├── sso-client-order # 工单子系统 └── sso-client-report # 报表子系统客户端需要的关键依赖不多cas-client-core负责过滤器shiro-spring和shiro-web负责权限与会话httpclient用于自定义 Realm 里主动校验票据dependencies !-- CAS 客户端过滤器AuthenticationFilter、TicketValidationFilter 都在里面 -- dependency groupIdorg.jasig.cas.client/groupId artifactIdcas-client-core/artifactId /dependency !-- Shiro 与 Spring 的整合ShiroFilterFactoryBean 由它提供 -- dependency groupIdorg.apache.shiro/groupId artifactIdshiro-spring/artifactId /dependency dependency groupIdorg.apache.shiro/groupId artifactIdshiro-web/artifactId /dependency !-- 自定义 Realm 里主动调 /serviceValidate不用容器默认的连接池 -- dependency groupIdorg.apache.httpcomponents/groupId artifactIdhttpclient/artifactId /dependency /dependencies版本上不必强求统一常见搭配是 Spring 4.x/5.x 配 Shiro 1.2 到 1.13 之间的版本、CAS 服务端用 4.x 或 5.x。真要踩坑多数出现在 cas-client 和 CAS 服务端协议版本不匹配上——服务端只开了 CAS 3.0 协议客户端却用Cas20ProxyReceivingTicketValidationFilter校验就会一直返回格式错误。稳妥做法是客户端统一用 3.0 的校验过滤器服务端把cas.tgc.secure、cas.serviceRegistry这些配置按实际域名配全。2.3 会话、票据与登出的三条约定第一ST 是一次性的。同一张 ticket 第二次去校验必定返回INVALID_TICKET这不是 bug是协议设计。浏览器刷新、用户点后退都可能触发重复提交所以客户端校验成功后要立刻 302 到不带 ticket 参数的干净 URL把 ticket 从地址栏抹掉。第二TGC 的生命周期决定免登录时长。TGT 默认两小时左右TGC 跟着它走。用户在两小时内访问任何注册过的子系统都能静默通过超过就得重新在 CAS 登录一次。这个值可以在服务端的cas.properties里调但调太大等于把安全边界放得太宽内部系统一般四到八小时封顶。第三单点登出靠 CAS 反向通知。用户在 CAS 点退出CAS 除了销毁 TGT还会向所有注册过的 service 挨个 POST 一个 SAML 格式的logoutRequest里面带着SessionIndex。客户端必须配SingleSignOutFilter和SingleSignOutHttpSessionListener把 SessionIndex 和本地 HttpSession 的映射维护起来收到通知才能精确销毁对应会话。少了这一步就会出现“CAS 已经退了应用还能继续点”的经典问题。3. CAS Server 侧认证处理器与票据签发的落地点服务端一般不改太多标准 war 包部署起来就能用。真正要动的是两处认证数据从哪来以及哪些 service 允许接入。前者决定你能不能对接现有用户表后者决定你会不会在回调时被一句“未认证授权的服务”挡在门外。3.1 认证处理器把现有用户表接进 CASCAS 自带几种认证方式JDBC 是最常见的一种但老系统的密码往往是加盐 MD5 或者自定义摘要写在 XML 里的 SQL 拼不出来。这时候写一个AuthenticationHandler更省事继承AbstractUsernamePasswordAuthenticationHandler把校验逻辑塞进authenticateUsernamePasswordInternalpublic class SysUserAuthenticationHandler extends AbstractUsernamePasswordAuthenticationHandler { private DataSource dataSource; Override protected HandlerResult authenticateUsernamePasswordInternal( UsernamePasswordCredential credential, String originalPassword) throws GeneralSecurityException, PreventedException { String username credential.getUsername(); // 按系统自己的规则加密别在这里改成明文比对 String encoded getPasswordEncoder().encode(credential.getPassword()); String sql select id, password, status, dept_id from sys_user where username ?; try (Connection conn dataSource.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (!rs.next()) { throw new AccountNotFoundException(username not found); } if (rs.getInt(status) ! 1) { throw new AccountDisabledException(username disabled); } if (!encoded.equalsIgnoreCase(rs.getString(password))) { throw new FailedLoginException(bad credential); } // 主键和部门放进 attributes客户端断言里能直接拿到 MapString, Object attrs new HashMap(); attrs.put(userId, rs.getLong(id)); attrs.put(deptId, rs.getLong(dept_id)); return createHandlerResult(credential, this.principalFactory.createPrincipal(username, attrs)); } } catch (SQLException e) { throw new PreventedException(query user failed, e); } } public void setDataSource(DataSource dataSource) { this.dataSource dataSource; } }几个参数值得说清楚。UsernamePasswordCredential里的密码是 CAS 从登录表单收上来的原文getPasswordEncoder()是父类注入的编码器两边必须一致配错了就是永远密码错误。principalFactory.createPrincipal(username, attrs)的第二个参数会序列化进断言客户端用assertion.getAttributes()就能取到userId和deptId省掉一次查库。异常类型也不是随便抛的AccountNotFoundException和FailedLoginException在登录页上提示的文案不同AccountDisabledException会明确告诉用户账号被停用这比统一返回“用户名或密码错误”对运维友好得多。写完处理器还要在deployerConfigContext.xml的authenticationManager里注册把原来的AcceptUsersAuthenticationHandler换掉否则新写的类根本不会被调用。数据源直接引用容器里的连接池别在 handler 里自己 new 一个。3.2 TGT 与 ST 的生命周期及存储方式票据的默认存储是内存DefaultTicketRegistry服务端一重启所有 TGT 全失效用户集体掉线。单机玩玩没问题一旦上集群就必须换成共享存储否则用户在节点 A 登录、请求打到节点 B 时不认账。常见做法是换成缓存或数据库实现在ticketRegistry.xml里把默认实现替换掉!-- 集群环境必须换掉默认的内存实现否则节点之间互不认账 -- bean idticketRegistry classorg.jasig.cas.ticket.registry.DefaultTicketRegistry !-- 单机保留内存即可集群换成带共享存储的实现 -- /bean bean idticketRegistryCleaner classorg.jasig.cas.ticket.registry.support.DefaultTicketRegistryCleaner p:ticketRegistry-refticketRegistry /有效期相关的配置集中在cas.properties里和票据相关的主要是三个值TGT 的最大存活时间、ST 的存活时间、以及票据清理线程的执行间隔。ST 默认只有十秒够客户端完成一次校验别为了“保险”把它调到几分钟——一张能用几分钟的票据等于把重放窗口放大了几百倍。TGT 的时长按业务来内部系统八小时、对外系统两小时是常见选择。注意不同大版本的 CAS 配置项名字有差异有的叫tgc.timeout有的挪进了 YAML。改之前先看你手里那份cas.properties里实际存在的键名别照抄网上的片段。3.3 服务注册表谁能拿票据由它说了算CAS 只给注册过的 service 签发 ST。注册表有三种存法默认的内存注册、JSON 文件、或者 JDBC。中小规模用 JSON 文件最直观每个 service 一个文件配正则和属性{ class: org.jasig.cas.services.RegexRegisteredService, serviceId: ^https://app-a\\.example\\.org/.*, name: order-system, id: 1001, evaluationOrder: 10, accessStrategy: { class: org.jasig.cas.services.DefaultRegisteredServiceAccessStrategy, enabled: true } }serviceId是正则不是字符串相等。这一点坑了不少人客户端回调地址写成https://app-a.example.org/cas/callback注册表里写https://app-a.example.org看着像前缀匹配实际正则不匹配末尾斜杠直接返回“未认证授权的服务”。稳妥写法是^https://app-a\.example\.org/.*把域名后面的路径全放开。evaluationOrder控制多条规则命中时的优先级数字越小越优先有通配规则时记得给它一个较大的值让精确规则先匹配。另外正式环境建议把serviceId里的协议钉死成 https。见过太多因为反向代理把 https 转成 http导致 service 参数和注册表对不上、来回重定向的案例。4. 客户端集成Spring MVC 侧接入 CAS 与 Shiro 的 Realm 改造客户端是整个方案里改动最多的部分。核心要解决两件事CAS 的过滤器要在 Shiro 之前完成认证以及 Shiro 要认可 CAS 给出的身份。顺序错一步请求就会在“未认证”和“重定向”之间反复横跳。4.1 过滤器顺序cas-client 必须排在 Shiro 前面web.xml里 filter 的执行顺序按filter-mapping的声明顺序来同级匹配时先声明的先执行。正确顺序是单点登出监听 → 登出过滤器 → 认证过滤器 → 票据校验 → 请求包装 → Shiro。写成 XML 大致是这样!-- 1. 监听器负责销毁被 CAS 反向通知的本地会话 -- listener listener-classorg.jasig.cas.client.session.SingleSignOutHttpSessionListener/listener-class /listener !-- 2. 接收 CAS 发来的 logoutRequest必须排在认证之前 -- filter filter-nameCAS Single Sign Out Filter/filter-name filter-classorg.jasig.cas.client.session.SingleSignOutFilter/filter-class /filter filter-mapping filter-nameCAS Single Sign Out Filter/filter-name url-pattern/*/url-pattern /filter-mapping !-- 3. 未登录就 302 到 CAS只拦需要登录的路径 -- filter filter-nameCAS Authentication Filter/filter-name filter-classorg.jasig.cas.client.authentication.AuthenticationFilter/filter-class init-param param-namecasServerLoginUrl/param-name param-valuehttps://sso.example.org:8443/cas/login/param-value /init-param init-param param-nameserverName/param-name param-valuehttps://app-a.example.org/param-value /init-param /filter !-- 4. 拿 ST 去 CAS 校验校验结果写进 request -- filter filter-nameCAS Validation Filter/filter-name filter-classorg.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter/filter-class init-param param-namecasServerUrlPrefix/param-name param-valuehttps://sso.example.org:8443/cas/param-value /init-param init-param param-nameserverName/param-name param-valuehttps://app-a.example.org/param-value /init-param /filter !-- 5. 把断言里的 principal 包装成 getRemoteUser 可读 -- filter filter-nameCAS HttpServletRequest Wrapper Filter/filter-name filter-classorg.jasig.cas.client.util.HttpServletRequestWrapperFilter/filter-class /filter !-- 6. Shiro 最后进场此时身份已经就绪 -- filter filter-nameshiroFilter/filter-name filter-classorg.springframework.web.filter.DelegatingFilterProxy/filter-class init-param param-nametargetFilterLifecycle/param-name param-valuetrue/param-value /init-param /filterserverName这个参数容易被忽略它的作用是拼出当前应用的 service 地址。如果应用挂在反向代理后面不配或者配错拼出来的 service 和注册表里的正则对不上症状就是无限重定向。Cas30ProxyReceivingTicketValidationFilter里的 30 指协议版本服务端也要开着对应协议的校验端点版本错配的表现是校验返回一段看不懂的 XML而不是明确的错误码。DelegatingFilterProxy放在最后是因为 Shiro 的SecurityManager需要在身份建立之后才能构建 Subject。反过来排Shiro 先跑看到的是匿名用户于是把所有请求都判成未认证CAS 那边又把请求送回来形成死循环——这种问题看日志特别像“配置全对但就是不生效”实际就是顺序两个字。4.2 自定义 CasRealm用一张 ST 换回 Shiro 的 Subject有了过滤器身份信息已经在 request 里了但 Shiro 并不认识它。常见做法有两种一种是在某个入口把request.getRemoteUser()取出来直接subject.login()一个自定义 Token另一种是干脆让 Shiro 自己负责票据校验写一个继承AuthorizingRealm的CasRealm把 ST 当作凭证。后者更彻底Shiro 的认证链完整走一遍后面授权、缓存、会话都能用上。先定义 Token把 ticket 当作 principal 和 credentialspublic class CasToken implements AuthenticationToken { private final String ticket; public CasToken(String ticket) { this.ticket ticket; } Override public Object getPrincipal() { return ticket; } Override public Object getCredentials() { return ticket; } }再写 Realm。setAuthenticationTokenClass这行不能省Shiro 靠它判断什么类型的 Token 交给这个 Realm 处理不设的话默认只认UsernamePasswordTokenCasToken会被直接跳过public class CasRealm extends AuthorizingRealm { private String casServerUrlPrefix; // 例如 https://sso.example.org:8443/cas private String clientServiceUrl; // 当前应用回调地址必须和注册表正则一致 public CasRealm() { // 关键声明本 Realm 处理 CasToken setAuthenticationTokenClass(CasToken.class); } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { String ticket (String) token.getPrincipal(); Assertion assertion validateTicket(ticket); String username assertion.getPrincipal().getName(); // 把 CAS 断言里的属性挂到 principal 上Controller 里可直接取 MapString, Object principal new HashMap(); principal.put(username, username); principal.put(attributes, assertion.getAttributes()); // 凭证用 ticket 占位即可真正校验已经在 validateTicket 里做完了 return new SimpleAuthenticationInfo(principal, ticket, getName()); } private Assertion validateTicket(String ticket) { String url casServerUrlPrefix /serviceValidate; // 超时必须设否则 CAS 一慢整个登录线程就挂住 RequestConfig config RequestConfig.custom() .setConnectTimeout(2000) .setSocketTimeout(3000) .build(); try (CloseableHttpClient client HttpClients.custom() .setDefaultRequestConfig(config).build()) { HttpPost post new HttpPost(url); ListNameValuePair params new ArrayList(); params.add(new BasicNameValuePair(ticket, ticket)); params.add(new BasicNameValuePair(service, clientServiceUrl)); post.setEntity(new UrlEncodedFormEntity(params, StandardCharsets.UTF_8)); try (CloseableHttpResponse resp client.execute(post)) { String body EntityUtils.toString(resp.getEntity(), StandardCharsets.UTF_8); return CasXmlParser.parse(body); // 解析失败会抛 AuthenticationException } } catch (IOException e) { throw new AuthenticationServiceException(CAS ticket validate failed, e); } } public void setCasServerUrlPrefix(String casServerUrlPrefix) { this.casServerUrlPrefix casServerUrlPrefix; } public void setClientServiceUrl(String clientServiceUrl) { this.clientServiceUrl clientServiceUrl; } }validateTicket里的两个参数是协议的硬要求ticket是 URL 上带来的 STservice必须和申请 ticket 时用的 service 完全一致多一个斜杠都会被 CAS 拒绝。超时设置不是可选优化CAS 服务端一旦出现网络抖动没有超时的 HttpClient 会把 Tomcat 的工作线程全占住表现是整个应用假死这种问题排查起来非常像“数据库连接池耗尽”实际根因在认证链路上。CasXmlParser.parse要做的事情不复杂解析cas:serviceResponse根节点判断有没有cas:authenticationSuccess成功就取cas:user和cas:attributes下的子节点失败就把cas:authenticationFailure的 code 抛出来。用 dom4j 或者 JAXB 都行注意关掉外部实体解析避免 XXE。最后在 Controller 里接一下票据登录成功后立刻清掉 URL 参数GetMapping(/cas/callback) public String callback(RequestParam(ticket) String ticket, HttpServletRequest request) { Subject subject SecurityUtils.getSubject(); if (!subject.isAuthenticated()) { subject.login(new CasToken(ticket)); // 走 CasRealm 的认证流程 } // 重定向到无 ticket 的地址避免刷新时重复使用一次性票据 return redirect:/index; }4.3 权限模型三张表怎么落到 AuthorizationInfo认证解决“你是谁”授权才是 Shiro 真正发挥价值的地方。用户、角色、权限三张主表加两张关联表结构不复杂但查询要一次拿全避免进doGetAuthorizationInfo时打出一串 N1 查询-- 用户表只保留认证必需字段密码摘要不参与授权计算 create table sys_user ( id bigint primary key, username varchar(64) not null unique, password varchar(128) not null, status tinyint default 1, dept_id bigint ); create table sys_role ( id bigint primary key, code varchar(64) not null unique, name varchar(64) ); create table sys_permission ( id bigint primary key, code varchar(128) not null unique, -- 例如 order:view、order:edit name varchar(64) ); create table sys_user_role ( user_id bigint not null, role_id bigint not null, primary key (user_id, role_id) ); create table sys_role_permission ( role_id bigint not null, permission_id bigint not null, primary key (role_id, permission_id) );对应的授权方法一次查出角色码和权限码分别塞进SimpleAuthorizationInfoOverride protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { Map?, ? principal (Map?, ?) principals.getPrimaryPrincipal(); String username (String) principal.get(username); SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); // 角色码一次查完别在循环里再查权限 ListString roles roleDao.findCodesByUsername(username); info.setRoles(new HashSet(roles)); // 权限码order:view 这种字符串配合 Shiro 的 wildcard 匹配 ListString perms permissionDao.findCodesByUsername(username); info.setStringPermissions(new HashSet(perms)); return info; }权限码的命名建议用模块:动作的冒号风格order:*可以匹配工单模块下所有动作*:*就是超级管理员。这种写法在RequiresPermissions(order:edit)注解和shiro:hasPermission标签里通用改权限时只动数据库不动代码。授权结果默认会被 Shiro 缓存用户改了角色不重启不生效是常见现象。要么在角色变更时主动调clearCachedAuthorizationInfo要么把缓存换成集中式存储、在管理端做统一失效。用本地 Ehcache 做缓存、又部署了多节点的话还会出现“A 节点改了权限、B 节点还是老样子”的情况这一点在做权限中心时必须有预案。5. 避坑与排查票据校验失败、重定向循环与登录态丢失这套组合的问题有个共同特点现象都很像“配置没错但就是不工作”。下面几条是集成过程中反复出现的按现象、原因、解决三段记下来下次遇到能省半天。5.1 票据与重定向类问题现象一访问应用 A 无限 302浏览器地址栏在应用和 CAS 登录页之间来回跳。原因service 参数与注册表里的正则不匹配。常见于反向代理把 https 转成 http、末尾斜杠不一致、或者端口号被代理吞掉。CAS 认不出这个 service就不签发 ticket客户端拿不到 ticket 又重定向形成闭环。 解决把客户端日志里实际拼出的 service 地址打印出来和services/目录下 JSON 里的serviceId正则逐字符比对。统一在serverName里写死对外域名不要让容器根据请求头自己推断。现象二校验返回INVALID_TICKET用户点一次刷新就登录失败。原因ST 是一次性的第一次校验成功后就被 CAS 标记为已消费。用户刷新页面时 URL 上的 ticket 还在客户端又拿它去校验自然失败。 解决校验成功后立刻重定向到不含 ticket 的地址把参数从地址栏抹掉。同时可以判断一下本地 session已认证就不再走票据逻辑。现象三request.getRemoteUser()始终为 null注解式权限判断全部失效。原因HttpServletRequestWrapperFilter排在了 Shiro 后面或者压根没配。Shiro 拿到的是原始 request里面没有 CAS 包装过的 principal。 解决严格按 4.1 的顺序排 filter包装过滤器必须在 Shiro 之前。排完在 Controller 里打一行日志验证别靠猜。5.2 会话与登出类问题现象四CAS 上已经退出应用里还能继续操作。原因SingleSignOutFilter没配或者配了但SingleSignOutHttpSessionListener漏了。CAS 发出的logoutRequest无人接收本地 session 一直活着。 解决两个都要配并且确认监听器在web.xml里注册在过滤器之前。收到通知后按SessionIndex找会话找不到就会退化成按 principal 清理日志里要能看出来走了哪条分支。现象五登录成功但权限一直提示无权限重启应用后恢复正常。原因AuthorizationInfo走了缓存角色变更后没有主动清理。多节点部署时本地缓存各自为政问题更明显。 解决角色、权限变更的入口统一调clearCachedAuthorizationInfo(principals)或者把授权缓存换成集中式存储在管理端做一次失效广播。现象六高峰期登录接口大量超时线程数打满。原因CasRealm里调/serviceValidate的 HttpClient 没设连接超时和读取超时CAS 一慢工作线程全被挂住。 解决连接超时两秒、读取超时三秒是常用值超时后抛出认证异常让用户重试比整站假死好得多。同时对 CAS 校验做一层短时缓存同一张 ticket 在校验成功后的几秒内复用结果减少重复请求。提示排查这类问题最有效的动作是把 cas-client 的日志级别调到 DEBUG它会打印每次重定向的 service 地址和校验响应体。九成的“玄学问题”看一眼这行日志就清楚了。6. 二次开发从单点登录到统一权限中心的三个切入点项目跑通之后多数人下一步想的不是“再加个系统”而是“能不能少查一次库、少配一次地址”。这套工程的结构留了几个比较好下手的地方按投入产出排一下。改造点现状建议做法属性透传客户端拿到用户名后还要查一次用户表在 CAS 认证处理器里把部门、岗位、手机号塞进 attributesCasRealm直接读票据校验缓存每次登录都同步调一次/serviceValidate校验结果放 Rediskey 用 ticketTTL 设为票据剩余有效期注意一次性语义登出联动只清理本应用 session应用侧暴露内部登出接口CAS 通知到达后广播处理跨节点会话前后端分离依赖 302 和 CookieSPA 不好接增加一个 REST 登录接口前端拿 ticket 换 token后续走 token 鉴权属性透传是最容易见效的一项。原来的CasRealm里只放了用户名业务代码要展示部门名称还得再查一次库。改造后直接把断言里的属性摊平// 认证成功后把 CAS 属性提升为 Shiro 的 principal 属性 MapString, Object attrs assertion.getAttributes(); MapString, Object principal new HashMap(); principal.put(username, assertion.getPrincipal().getName()); principal.put(deptId, attrs.get(deptId)); principal.put(deptName, attrs.get(deptName)); // 业务层通过 SecurityUtils.getSubject().getPrincipal() 直接取不再回查用户表 return new SimpleAuthenticationInfo(principal, ticket, getName());注意属性不是越多越好。断言会随每次重定向在网络上传输字段一多URL 长度和解析开销都会上去。手机号、身份证这类敏感字段能不传就不传真要传也只传标识详细信息由业务侧按需拉取。前后端分离的改造要更谨慎一些。CAS 的票据走 URL 参数、会话走 Cookie这套模型和 token 鉴权是两种思路。可行的折中是保留 CAS 做认证登录成功后由后端签发一个短期 token 给前端前端放进请求头后端用 Shiro 的自定义 Filter 校验 token 并把身份写进 Subject。这样 CAS 的职责不变前端也不用处理 302。有一个习惯我保持了几年每接一个新子系统先把它的 service 地址、注册表正则、serverName配置三处并排贴在一个文本文件里逐字符对一遍再启动。这个动作花不了一分钟但省掉的排查时间往往是按小时算的。单点登录这类跨系统链路问题从来不在某一行代码写错而在两个系统对同一个约定的理解差了半个斜杠。希望帮到你。本文还有配套的精品资源点击获取