先交代一个场景:公司某内部平台要把账号体系收编到统一认证中心,Spring Boot 应用选的是 Shiro 做权限控制,认证中心是 CAS 5.2。按理说最常规的接法是浏览器跳转到 CAS 登录页,回调后拿 ticket 回跳,Shiro 一套就过了。但真到落地时你会发现,凡是涉及第三方客户端、后台服务、移动端、接口对接的登录需求,纯跳转模式根本接不进去,这时候 CAS 5.2 的 RESTful 协议就成了绕不开的一条路。
这篇文章不是讲理论,是我在 Spring Boot 2.x + Shiro + CAS 5.2 的组合里,通过 RESTful 协议打通 SSO 单点登录的完整记录。包括 CAS 端怎么开 REST 支持、Shiro 侧认证链路怎么改、ticket 和 TGT 的流转细节、联调阶段踩过的坑,以及登录成功之后用户数据和权限怎么落库。适合正在做统一认证、或者被“既要跳转登录又要接口登录”这种双重需求折磨的开发同学参考。
1. 为什么是“RESTful 协议”而不是“跳转登录”:协议选型的场景依据
1.1 标准 CAS 网页登录链路长什么样
CAS 最经典的登录流程是浏览器模式。用户在客户端请求受保护资源,客户端发现未登录,就 302 带到 CAS 的 /login 页面,用户在 CAS 上输账号密码,CAS 校验通过后生成一个 ST(Service Ticket),并带着 ST 和 service 参数重定向回客户端,客户端拿 ST 去 CAS 的 serviceValidate 接口验票,验票通过才算登录成功。
这套流程对于“人在浏览器里操作”的场景非常顺手,特别是多个 Web 应用统一用一个登录页,体验是自然的。Shiro 官方生态里也有专门的 shiro-cas 模块,把 CasFilter、CasRealm 都封装好了,只要配好 casServerUrlPrefix 和 loginUrl,就能跑通。
但它有一个隐含前提:登录这件事的入口必须是浏览器。
1.2 什么情况下你会想念一个 API 形式的登录入口
我这次接入时遇到了两种跳转模式搞不定的情况。
第一种是内部定时任务服务。一个批量跑批的模块要在凌晨用系统账号去拉统一用户中心的用户列表,这个服务没有界面、没有浏览器,它登录 CAS 的方式只能是通过 HTTP 请求提交账号密码,CAS 返回票据,它再拿着票据访问业务接口。
第二种是移动端 App 和前后端分离的前端页面。前端页面不跳 CAS 登录页,而是自己弹出一个登录表单,把账号密码提交给后端网关,网关再代替用户去 CAS 完成认证。这时候如果后端还是用 302 跳转的方式,前端拿到的就是一个重定向响应,整个认证链路就断了。
于是需要一个“非浏览器客户端也能走 CAS 认证”的通道。CAS 5.2 里的 RESTful 协议解决的就是这件事:它把登录抽象成了一个普通的 REST 接口,调用方 POST 用户名和密码,CAS 返回 TGT,之后再拿 TGT 换 ST,最后用 ST 校验身份。整个过程不依赖 TGC Cookie,不依赖浏览器,只要是能发 HTTP 请求的客户端都能接入。
1.3 架构分层的思路:认证中心、客户端、会话管理各管一段
把这套方案拆开看,责任边界其实很清晰。
CAS 5.2 负责统一认证和票据签发,它只认“用户名密码对不对“和”这个 service 是否允许认证”,不关心你用的是浏览器还是脚本。
Spring Boot 应用负责对接用户的登录请求,它在收到账号密码后,隐藏一个 REST Client 去调 CAS,拿到认证结果后再把用户 Session 建起来,这层由 Shiro 管理。
Shiro 负责本地会话和权限控制。用户是否已登录、有没有某个角色、某个接口允不允许访问,这些是 Shiro 的管辖范围。CAS 认证通过后,Shiro 拿到一个已经可信的身份信息,把它封装成 Subject,后续的鉴权都在本地处理,不用每次请求都远程验证票据。
三层各做各的事,SSO 的“单点”体现在:多个 Spring Boot 应用接入同一个 CAS,任何一端登录成功,其他端都可以通过 REST 协议重新用相同账号获取票据,或者通过已有的认证结果直接建立会话。这也是“RESTful 协议”在这套架构里的核心价值——它不是一个新的登录系统,而是把原有的 CAS 能力暴露成了 API。
2. CAS 5.2 端开启 REST 认证:从依赖到接口验证
2.1 添加 REST 支持的依赖与配置
CAS 5.2 的 RESTful 协议不是我随手调一个开关就能用的,它本身是一个扩展模块,官方叫 cas-server-support-rest。在 Overlay 方式部署 CAS 时,需要把它打进构建里。
既然是基于 Overlay 的部署方式,大多数团队都只是构建叠加了几个模块,结构比较统一。我当时处理的是 5.2.x 的版本,依赖大致要加到 CAS 的重叠配置里:
<dependency> <groupId>org.apereo.cas</groupId> <artifactId>cas-server-support-rest</artifactId> <version>${cas.version}</version> </dependency>加完依赖后,需要在 CAS 的配置里确认 REST 认证开关是打开的。5.2 版本里常见配置项是:
cas.restful.authentication.start=true这段配置的含义是开启 RESTful 认证入口,让 /cas/v1/tickets 这个接口能够接收账号密码请求。如果没开,你会看到接口返回 404,极容易让人误以为是路由没配。
建议配置里同时检查一下 service 注册情况。REST 协议换取 ST 时,service 参数必须指向一个 CAS 中已注册的合法服务。5.2 默认的 Registered Services 存储可以是 JSON,也可以落在数据库里。我这里就是典型的 JSON 注册方式:
{ "@class": "org.apereo.cas.services.RegexRegisteredService", "serviceId": "^https://app\\.server\\.local:8443/casclient/.*", "name": "casclient", "id": 1, "evaluationOrder": 1 }serviceId 是一项正则表达式,只有当请求里带的 service 能匹配上这串正则时,CAS 才愿意为它颁发 ST。这也是我后来排查“服务被拒”类问题时的重点检查对象。
2.2 三个核心接口的含义与调用顺序
CAS 5.2 REST 协议要盯住的接口其实就三个,我整理成了一张对照表:
| 步骤 | 接口 | 作用 | 关键参数 |
|---|---|---|---|
| 1. 认证并获取 TGT | POST /cas/v1/tickets | 校验账号密码,返回 TGT | username、password、service(可选) |
| 2. 换取 ST | POST /cas/v1/tickets/{TGT} | 拿着 TGT 换取某个服务的 ST | service(必填) |
| 3. 校验 ST | GET /cas/serviceValidate | 校验 ST 是否有效,返回用户身份 | service、ticket |
第一步最像“登录”。调用方把账号密码 POST 给 /cas/v1/tickets,如果 CAS 认证通过,HTTP 响应码是 201,并且响应头 Location 会带着新生成的 TGT 地址,比如 /cas/v1/tickets/TGT-xxxxx。这个 TGT 就相当于停车场给你的“总凭证”,证明你确实开着车进来了。
第二步是把总凭证一次性消费成“某个门禁的临时通行码”,也就是 ST。调用方拿着 TGT 再 POST 一次,请求体里带上业务系统的 service 地址,CAS 返回的响应体就是 ST。ST 是一次性票据,用完就销毁,而且有效期非常短,默认通常是 10 秒以内。
第三步是业务系统自己校验 ST。它拿着 ST 和 service 去访问 serviceValidate,CAS 返回一段 XML,里面有 username 和 authentication-success 节点,业务系统拿到之后就可以确认:这个用户是 CAS 信任的,允许建立本地登录态。
用生活类比来说:TGT 是你在车站换票时的身份凭证,ST 是进站口那张实名车票,serviceValidate 是检票员,你给他车票,他核对了身份没问题,才放你上车。
2.3 用简单命令先验证服务端是否畅通
服务端配没配好,写代码之前先用 curl 验一遍是最快的。我在联调初期就是这么干的:
# 第一步:提交账号密码,拿 TGT curl -k -X POST -d "username=casuser&password=password&service=https%3A%2F%2Fapp.server.local%3A8443%2Fcasclient%2Flogin" \ https://cas.server.local/cas/v1/tickets # 如果返回201,Location 里就是 TGT 地址 # 第二步:用 TGT 换 ST curl -k -X POST -d "service=https%3A%2F%2Fapp.server.local%3A8443%2Fcasclient%2Flogin" \ https://cas.server.local/cas/v1/tickets/TGT-xxxxx # 第三步:校验 ST curl -k "https://cas.server.local/cas/serviceValidate?service=https%3A%2F%2Fapp.server.local%3A8443%2Fcasclient%2Flogin&ticket=ST-xxxxx"这三条命令走通了,就说明 CAS 端的 REST 协议没问题,可以放心去改业务系统。第一次跑这条命令时,我最常碰到的问题反而是 service 没编码、被 302 拦截、或者 HTTPS 证书不信任。curl 加 -k 能绕开证书问题,但业务代码里还是得把证书链处理好,不能图省事关掉证书校验。
3. Shiro 侧的自定义认证器:把 CAS REST 拉进 Realm
3.1 为什么不用现成的 shiro-cas
Shiro 官方提供的 shiro-cas 模块很好用,但它面向的是网页跳转模式。它里面的 CasFilter 会主动把未登录请求重定向到 CAS 登录页,CasRealm 里的 doGetAuthenticationInfo 接收的是一个 CasToken,这个 token 是通过回调地址带回来的 ST 构造的。
但本文的 RESTful 场景完全不同。登录入口是 Spring Boot 自己提供的 /login 接口,前端把账号密码提交过来,后端要做的是拿着账号密码去向 CAS 的 /cas/v1/tickets 发请求,全程不经过浏览器跳转。这种情况下如果硬套 shiro-cas,你会发现它根本没有提供“拿用户名密码换 TGT”这一步的逻辑。
所以我的做法是:保留 Shiro 的 Realm 机制,自写一个认证过滤器和一个 Realm,把“调用 CAS REST 接口完成认证”的逻辑放进 Realm。自定义认证器本身,把 Shiro 和 CAS 黏合起来。
3.2 认证器设计与代码骨架
先定义一个自定义 Token,用于携带 SSO 场景下的信息。REST 方式登录时,前端可能直接提交账号密码,也可能提交 CAS 回调带来的 ticket。所以 token 上既要能放用户名密码,也要能放 ST。
public class CasRestToken implements AuthenticationToken { private final String username; private final String password; private final String service; private final String ticket; public CasRestToken(String username, String password, String service, String ticket) { this.username = username; this.password = password; this.service = service; this.ticket = ticket; } @Override public Object getPrincipal() { return username; } @Override public Object getCredentials() { // 如果走ticket方式,这里放ticket;走账号密码方式则放密码 return ticket != null ? ticket : password; } // getter / setter 略 }然后自定义一个 Realm。核心逻辑在 doGetAuthenticationInfo 里:调用 CAS REST 接口完成认证,CAS 通过后再把用户身份放给 Shiro。
public class CasRestRealm extends AuthorizingRealm { private String casServerPrefix; private String callbackService; @Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { CasRestToken casToken = (CasRestToken) token; // 第一步:去 CAS 端认证账号密码,拿到 TGT String tgt = requestTgt(casToken.getUsername(), casToken.getPassword(), callbackService); // 第二步:用 TGT 换取 ST String st = requestSt(tgt, callbackService); if (st == null || st.isEmpty()) { throw new AuthenticationException("CAS 票据换取失败"); } // 第三步:拿 ST 到 serviceValidate 校验,返回的是 CAS 用户名 String casUser = validateSt(st, callbackService); // 这里可以进一步查询本地用户,做用户信息承载 SimpleAuthenticationInfo info = new SimpleAuthenticationInfo( casUser, st, getName() ); return info; } // requestTgt 内部实现:POST /cas/v1/tickets // requestSt 内部实现:POST /cas/v1/tickets/{tgt},带 service 参数 // validateSt 内部实现:GET /cas/serviceValidate,解析 XML 里的 user 节点 }这段代码有三个关键点。
第一,获取 TGT 的 HTTP 调用必须捕获 401 状态。如果用户名密码出错,CAS 返回的不是 201,而是 401 Unauthorized。这块要映射成 Shiro 的 AuthenticationException,否则前端会收到一个莫名其妙的 500。
第二,换取 ST 时,service 参数要和注册服务完全一致,协议、域名、端口、路径差一个字符都不行。比如注册时写的是 https,请求里传了 http,校验时就会失败。
第三,doGetAuthenticationInfo 返回的 credentials 是 ST,而不是密码。因为密码在换 TGT 时已经交给 CAS 了,本地不再保存明文密码。后续如果 Shiro 要做 credentials 匹配,得保证不会拿用户输入的密码和 ST 比对,所以我把 token 的 credentials 和 Realm 的 credentials 都设成 ST,保证同源。
3.3 Shiro 配置里 Filter 的挂载顺序
认证器写完之后,要把自定义的过滤器挂到 Shiro 的过滤器链上。Shiro 5.x 时代的配置可能走 shiro-spring-boot-starter,也可能走原生 shiro.ini,我这边用的是 Java Config 方式。
@Bean public ShiroFilterFactoryBean shiroFilterFactoryBean(SecurityManager securityManager, CasRestAuthenticationFilter casRestFilter) { ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean(); factoryBean.setSecurityManager(securityManager); Map<String, Filter> filters = new HashMap<>(); filters.put("casRest", casRestFilter); factoryBean.setFilters(filters); Map<String, String> chain = new LinkedHashMap<>(); chain.put("/login", "anon"); chain.put("/logout", "logout"); chain.put("/**", "casRest"); factoryBean.setFilterChainDefinitionMap(chain); return factoryBean; }过滤器链的顺序不要乱。登录接口本身必须是匿名可访问的,否则就死循环了。受保护资源统一挂自定义的 casRest 过滤器,这个过滤器要先判断当前 Subject 是否已经认证过,认证过就直接放行;没有认证的,才继续走登录逻辑。
还有一个容易搞错的点:REST 场景下,未必每个请求都会带账号密码。业务方更多是把受保护的接口访问作为一个触发点,用户如果还没登录,过滤器会返回 401 并提示前端去登录;前端拿到登录表单后再把账号密码 POST 过来。所以过滤器里的 isAccessAllowed 和 onAccessDenied 必须分开处理,不能把“未登录”和“登录失败”混成一种响应。
4. 登录时序中的小细节:service 参数、ST 时效与回跳
4.1 一次完整登录的时序细节
把服务端和客户端都准备好之后,一次完整的 REST 方式登录流程是这样的:
- 用户打开前端页面,前端发现 401 或者某个接口返回未登录,于是展示登录表单。
- 用户输入账号密码,前端发起 POST /system/login,请求体包含 username、password、service。
- Spring Boot 应用的自定义过滤器解析出账号密码,构造 CasRestToken。
- Realm 调用 CAS 的 /cas/v1/tickets,CAS 返回 201 和 TGT。
- Realm 拿 TGT 重新调 /cas/v1/tickets/{TGT},换取 ST。
- Realm 拿 ST 调 /cas/serviceValidate,校验通过,得到 CAS 用户名。
- Realm 返回 SimpleAuthenticationInfo,Shiro 创建 Subject,并在 Session 中记录登录态。
- 系统返回 token 或 sessionId 给前端,后续请求带上就会直接命中已认证的 Subject。
这里第 4、5、6 步分别对应 CAS 协议里“认证”“授权票据”“验票”三个动作。业务系统没有直接拿账号密码去访问某个数据库或第三方接口去验证,而是 CAS 统一发了通行证和入场券,所有接入系统只需要认 CAS 的签名,这就是 SSO 的协作方式。
4.2 service 参数参与校验的机制
service 参数在整个流程里不只是“回调地址”那么简单的含义,它还参与了 ST 的绑定。CAS 在签发 ST 时,会把 service 作为票据的一部分写进去;业务系统拿 ST 来验票时,必须带上同一个 service。只要 service 不同,token 就是无效的。
因为这个机制,我通常会要求各个接入应用把 service 写成“只属于自己应用”的一个稳定地址,不要直接用根域名。更不建议两个应用共用同一个 service 正则,否则 A 应用签发的 ST 可以被 B 应用拿去验,而且 A、B 两个应用会把登录态混掉。
举一个实际例子,我这边两个接入应用,一个叫业务后台,service 固定是https://admin.server.local:8443/admin/login,另一个叫开放平台,service 固定是https://open.server.local:8443/open/login。CAS 里各注册一个正则,互不干扰。
4.3 ST 时效带来的并发控制问题
CAS 的 ST 有效期默认只有几十秒,而且是一次性的。这在正常流程里没问题,但联调时如果哪一步网络慢了,或者代码里做了重试,就会踩到“第一次验票失败,第二次再验显示票已经被用过了”的问题。
我踩过的一个场景是:业务系统校验 ST 时,第一次因为 SSL 证书握手失败抛了异常,代码里的重试机制自动又跑了一次,结果第二轮拿着同一个 ST 去验,CAS 直接返回 INVALID_TICKET。原因很简单,ST 被第一轮请求消费了,虽然第一轮异常了,但 CAS 那边已经把它标记为已使用。
处理方式也比较朴素:校验 ST 的代码不要写成无条件重试,至少不能对同一张 ST 重试。如果第一次失败是因为网络抖动,正确的做法是重新走一遍“获取 TGT -> 换取 ST -> 验票”的完整流程,而不是复用同一个 ST 去重试。
另外 Redis 或分布式缓存在这种 RPC 调用频繁的场景里也比较有用。我后来在 TGT 和 ST 的获取结果上加了短暂缓存,避免用户的同一个请求在校验环节因为网络重放出多个调用。不过缓存时间一定要严格控制,ST 存活时间本来就短,缓存里放超过几十秒就失去意义了。
5. 联调排错记录:高概率踩雷点与排查手段
5.1 401/403 与 TGT 获取失败的排查
最常见的一组问题是第一步都走不通:POST /cas/v1/tickets 返回 401 而不是 201。
第一步,先确认账号密码本身是否正确。CAS 默认账号体系是它内部的用户存储,可以是 LDAP、数据库、或者默认内存用户。如果直接拿着业务系统的用户表去 CAS 验,CAS 当然说我 401。
第二,确认用户名密码参数名拼写。REST 协议里就是 username、password,不要自己改成 userName 或者 passwd。
第三,确认请求有没有被 Web 层拦截。有的环境里,CAS 前端可能还有一层网关或 Apache,导致 /cas/v1/tickets 被当作普通页面请求做了过滤。比如有些团队在 Nginx 里只允许 /cas/login 通过,其他请求一律拦截,结果 curl 测接口时死活不通。
再看 403。403 一般不是认证失败,而是权限或 IP 白名单的问题。CAS 5.2 里如果开启了“仅允许来自特定 IP 的 REST 调用”之类的策略,业务服务器 IP 不在白名单里就会被拒绝。还有一种情况是 CSRF 或 SOP 策略,但这通常只影响浏览器场景,不影响 Postman 和 curl,如果 curl 能通过但前端 fetch 不过,多半要检查 CORS。
5.2 serviceValidate 返回 INVALID_TICKET 的常见原因
第二步、第三步报 INVALID_TICKET 的坑更隐蔽。我列一下实际遇到过的原因:
- ticket 参数被 URL 转义对了,但 ST 是空字符串。用 TGT 换 ST 时,返回的 201 响应体里才是 ST 值,如果代码里是拿响应头去解析,就会得到空值。
- 验票时用的 service 和换取 ST 时的 service 不一致。大小写差异、路径多一个斜杠、协议从 https 变成 http,都不行。
- ST 过期或已经消费过一次。CAS 日志里能看到对应记录,去 CAS 服务端的 logs 目录搜 ST 值,能直接看到被标记成 invalid 的原因。
- 验票地址用错。5.2 兼容 /serviceValidate 和 /p3/serviceValidate,但有的版本里,旧接口返回的 XML 结构可能缺少某些属性,建议统一使用 CAS 协议版本对应的验票端点。
排查这类问题,我的习惯是先开 CAS 服务端的 DEBUG 日志,或者去看 cas.log 审计记录。CAS 有个专门的 Authentication Audit 日志,票据生成、校验、消费都会留痕,比在业务系统一行行打日志高效得多。
5.3 一处容易忽略的 SSL 与编码问题
这个话题看起来基础,实际在团队协作时踩得最多。
Spring Boot 调用 CAS 时如果 CAS 是 HTTPS,就要求本地 JVM 信任 CAS 的证书链。开发环境很多是自签名证书,调试的时候可以在代码里临时忽略证书,但测试和生产环境绝不能这么干。我见过一次线上事故,就是因为测试环境为了省事,把所有 SSL 校验全关掉了,结果换到生产环境后,HTTPS 握手失败,所有登录请求在第一步就全军覆没。
正确做法是把 CAS 站点证书导入应用服务器的信任库,或者让 CAS 链路上层使用已被信任的正式证书。
编码问题主要出在中文用户名和带特殊符号的密码上。REST 请求构造时,username 和 password 建议用表单编码的格式提交,参数的 URLEncoder 编码要显式声明字符集。我曾经遇到一个用户名里带 @ 符号的用户,因为没有编码,后端按 Query 参数拼接,结果 CAS 把 @ 后面的一段当成额外参数解析,认证直接失败。
6. 落地后的一些工程化补充:用户落库、会话处理、单点退出
6.1 认证与授权信息的落库策略
登录过程中,CAS 只负责回答“这个用户是谁”,也就是身份认证。至于“这个用户在业务系统里拥有哪些角色”“能访问哪些菜单”,CAS 不管,Shiro 也不直接管跨系统的权限。
所以我们在登录成功拿到用户名后,通常要本地查一遍用户表。用户第一次登录时,根据 CAS 返回的用户名自动创建一条本地用户记录;老用户则直接用本地记录关联角色和权限。
这块我踩过一个姿势上的坑:把用户信息同步放在“每次登录都全量查询”里。如果 CAS 用户数达到几千上万,每来一次登录都做一次全表扫描键查询,性能明显下降。更好的方式是走异步任务,从 CAS 端拉取用户变更事件或增量用户列表,提前同步到本地用户表,登录时只做索引查询。
权限方面,Shiro 的 AuthorizationInfo 实现里,角色和权限建议从本地关联表中加载。不要尝试每次授权都远程请求 CAS,CAS 不是权限中心,它也管不了业务系统内部五花八门的权限模型。
6.2 会话保持与单点退出
Shiro 默认的 Session 是存在 JVM 内存里的。开发调试够用,但如果 Spring Boot 应用做了多实例部署,用户随机打到不同节点,Session 就在各节点间互不认账。用户可能在 A 节点登录了,下一次请求被负载均衡发到 B 节点,B 节点发现没有这个 Session,又踢去重新认证。
所以只要部署实例数大于 1,就建议把 Shiro 的 SessionDAO 换成 Redis 实现,让多个实例共享同一个 Session。CAS 回跳和 REST 认证这套流程才可以真正在集群环境下顺畅跑起来。
单点退出也是一个容易被忽略的点。REST 协议下,CAS 的登出入口是 /cas/logout,业务系统登出要走两步:先销毁本地 Shiro Session,再调用 CAS 的 logout 接口。如果不调 CAS 登出,那 CAS 那边的 TGC 还是有效状态,用户下次访问其他系统可能又被直接登录了,这就不算真正的单点退出。
业务系统里一般是在 /logout 接口的过滤器或 Kontroller 里做两件事:clear 当前 Subject,同时向 CAS 发送一个 logout 请求。发送不能阻塞太长时间,可以用异步方式发。
6.3 安全收尾:密码相关安全部分建议
整个 RESTful 流程里,账号密码在业务系统和 CAS 之间传输,如果这条链路走的是明文 HTTP,账号密码就等于裸奔。生产环境必须保证 CAS 对外提供的 /cas/v1/tickets 接口走 HTTPS。
应用内部记录日志时,也要注意不要把密码打进日志。换票据时 CAS 返回的响应体只有 TGT 或 ST,这几个票据型东西可以和业务参数一起打印,但 username、password 这类登录表单参数不要无脑日志输出。我见过有同事把整个请求参数对象直接 toString,然后密码就安安静静躺在日志文件里,这种习惯迟早出问题。
补充一个细节:如果系统还开了“记住我”功能,REST 登录方式下不建议把账号密码长期缓存在前端,更不建议在后端把密码字段长期存到 Session 或 Redis。记住我正确的做法是签发短时访问令牌,而不是把原始凭证囤在会话里。
这套 CAS + Shiro 的 RESTful 单点登录方案,从协议选型到服务端开启,再到 Shiro 的 Realm 改造,再到联调踩坑,整体链路看上去不长,但每一层都有自己的细节。我自己的体会是,这类集成的核心难点通常不在某一个单独的技术点上,而在“协议本身假设的使用场景”和“你实际的使用场景”之间的落差。CAS 默认假设你是浏览器,REST 协议把它拉回到了接口世界,而你要做的就是把这个接口世界的桥接逻辑在 Shiro 里补齐。照着这个思路去排查问题,基本就不会跑偏。