CAS+Spring MVC+Shiro 单点登录与权限控制集成实践

发布时间:2026/10/24 2:03:29
CAS+Spring MVC+Shiro 单点登录与权限控制集成实践

简介:围绕 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/orders,AuthenticationFilter发现本地没有会话,302 重定向到 CAS 的/login?service=http://app-a/cas/callback;CAS 检查浏览器有没有带 TGC Cookie,没有就返回登录页;用户提交账号密码,校验通过后 CAS 生成 TGT 并把 TGC 写进 CAS 域下的 Cookie,同时生成一张一次性的 ST,302 跳回service?ticket=ST-1-xxxx;客户端的TicketValidationFilter拿这张 ST 去 CAS 的/serviceValidate换回一段 XML 断言;断言里带着用户名和扩展属性,客户端据此建立自己的 HttpSession,Shiro 再用这个身份构建 Subject。

关键在于身份只认证一次,会话各存各的。TGC 存在 CAS 的域下,是“你已经在认证中心登录过”的凭证;应用侧的 session 是“这个子系统认你”的凭证。第二次访问报表子系统时,浏览器带着 TGC 去 CAS,CAS 直接签发新 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> <groupId>org.jasig.cas.client</groupId> <artifactId>cas-client-core</artifactId> </dependency> <!-- Shiro 与 Spring 的整合,ShiroFilterFactoryBean 由它提供 --> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-spring</artifactId> </dependency> <dependency> <groupId>org.apache.shiro</groupId> <artifactId>shiro-web</artifactId> </dependency> <!-- 自定义 Realm 里主动调 /serviceValidate,不用容器默认的连接池 --> <dependency> <groupId>org.apache.httpcomponents</groupId> <artifactId>httpclient</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 认证处理器:把现有用户表接进 CAS

CAS 自带几种认证方式,JDBC 是最常见的一种,但老系统的密码往往是加盐 MD5 或者自定义摘要,写在 XML 里的 SQL 拼不出来。这时候写一个AuthenticationHandler更省事,继承AbstractUsernamePasswordAuthenticationHandler,把校验逻辑塞进authenticateUsernamePasswordInternal:

public 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,客户端断言里能直接拿到 Map<String, 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 id="ticketRegistry" class="org.jasig.cas.ticket.registry.DefaultTicketRegistry"> <!-- 单机保留内存即可,集群换成带共享存储的实现 --> </bean> <bean id="ticketRegistryCleaner" class="org.jasig.cas.ticket.registry.support.DefaultTicketRegistryCleaner" p:ticketRegistry-ref="ticketRegistry" />

有效期相关的配置集中在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-class>org.jasig.cas.client.session.SingleSignOutHttpSessionListener</listener-class> </listener> <!-- 2. 接收 CAS 发来的 logoutRequest,必须排在认证之前 --> <filter> <filter-name>CAS Single Sign Out Filter</filter-name> <filter-class>org.jasig.cas.client.session.SingleSignOutFilter</filter-class> </filter> <filter-mapping> <filter-name>CAS Single Sign Out Filter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 3. 未登录就 302 到 CAS,只拦需要登录的路径 --> <filter> <filter-name>CAS Authentication Filter</filter-name> <filter-class>org.jasig.cas.client.authentication.AuthenticationFilter</filter-class> <init-param> <param-name>casServerLoginUrl</param-name> <param-value>https://sso.example.org:8443/cas/login</param-value> </init-param> <init-param> <param-name>serverName</param-name> <param-value>https://app-a.example.org</param-value> </init-param> </filter> <!-- 4. 拿 ST 去 CAS 校验,校验结果写进 request --> <filter> <filter-name>CAS Validation Filter</filter-name> <filter-class>org.jasig.cas.client.validation.Cas30ProxyReceivingTicketValidationFilter</filter-class> <init-param> <param-name>casServerUrlPrefix</param-name> <param-value>https://sso.example.org:8443/cas</param-value> </init-param> <init-param> <param-name>serverName</param-name> <param-value>https://app-a.example.org</param-value> </init-param> </filter> <!-- 5. 把断言里的 principal 包装成 getRemoteUser 可读 --> <filter> <filter-name>CAS HttpServletRequest Wrapper Filter</filter-name> <filter-class>org.jasig.cas.client.util.HttpServletRequestWrapperFilter</filter-class> </filter> <!-- 6. Shiro 最后进场,此时身份已经就绪 --> <filter> <filter-name>shiroFilter</filter-name> <filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class> <init-param> <param-name>targetFilterLifecycle</param-name> <param-value>true</param-value> </init-param> </filter>

serverName这个参数容易被忽略,它的作用是拼出当前应用的 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 和 credentials:

public 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 处理,不设的话默认只认UsernamePasswordToken,CasToken会被直接跳过:

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 里可直接取 Map<String, 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); List<NameValuePair> 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 上带来的 ST,service必须和申请 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时打出一串 N+1 查询:

-- 用户表只保留认证必需字段,密码摘要不参与授权计算 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) );

对应的授权方法,一次查出角色码和权限码,分别塞进SimpleAuthorizationInfo:

@Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { Map<?, ?> principal = (Map<?, ?>) principals.getPrimaryPrincipal(); String username = (String) principal.get("username"); SimpleAuthorizationInfo info = new SimpleAuthorizationInfo(); // 角色码:一次查完,别在循环里再查权限 List<String> roles = roleDao.findCodesByUsername(username); info.setRoles(new HashSet<>(roles)); // 权限码:order:view 这种字符串,配合 Shiro 的 wildcard 匹配 List<String> 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 认证处理器里把部门、岗位、手机号塞进 attributes,CasRealm直接读
票据校验缓存每次登录都同步调一次/serviceValidate校验结果放 Redis,key 用 ticket,TTL 设为票据剩余有效期,注意一次性语义
登出联动只清理本应用 session应用侧暴露内部登出接口,CAS 通知到达后广播,处理跨节点会话
前后端分离依赖 302 和 Cookie,SPA 不好接增加一个 REST 登录接口,前端拿 ticket 换 token,后续走 token 鉴权

属性透传是最容易见效的一项。原来的CasRealm里只放了用户名,业务代码要展示部门名称还得再查一次库。改造后直接把断言里的属性摊平:

// 认证成功后,把 CAS 属性提升为 Shiro 的 principal 属性 Map<String, Object> attrs = assertion.getAttributes(); Map<String, 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配置三处并排贴在一个文本文件里,逐字符对一遍再启动。这个动作花不了一分钟,但省掉的排查时间往往是按小时算的。单点登录这类跨系统链路,问题从来不在某一行代码写错,而在两个系统对同一个约定的理解差了半个斜杠。希望帮到你。

本文还有配套的精品资源,点击获取