CAS+Shiro+SpringMVC单点登录实战:解决登录态撕裂问题
简介本资源是一套基于CAS、SpringMVC与Shiro三者深度集成的单点登录SSO完整实践项目面向Java Web开发中高级工程师及企业级权限系统设计人员解决多应用系统间统一身份认证与细粒度权限管控难题。项目涵盖CAS服务端搭建、SpringMVC客户端接入、Shiro自定义CasRealm与CasFilter实现、SSO登录/登出全流程及基于角色的资源访问控制适用于政务、教育、金融等需合规认证的中大型系统架构演进场景。压缩包共316个文件含126个jar依赖库、44个JSP视图页、38个Java核心类含拦截器与Realm实现、38个XML配置文件Spring与Shiro配置为主、33个properties属性文件含CAS服务地址与数据库参数整体40.79MB结构清晰、模块分离明确。目前已有492人学习下载读者可直接部署运行获取可调试的SSO全链路代码、票据验证排错逻辑、CSS/JS等前端配套资源及war包构建说明快速掌握企业级安全框架协同落地的关键实践。1. CAS SpringMVC Shiro 单点登录不是堆砌框架而是解决“登录态撕裂”这个真问题你有没有遇到过这样的场景系统里同时跑着用户中心、订单后台、报表平台三个子应用每个都用 Shiro 做权限控制但用户在 A 应用登出后B 应用还能继续操作或者用户刚在 CAS 登录成功跳转回 SpringMVC 页面时却提示“未认证”Shiro 的SecurityUtils.getSubject().isAuthenticated()死活返回false这不是配置漏了几个 bean而是典型的「认证通道断裂」——CAS 负责身份核验Shiro 负责权限决策SpringMVC 是请求承载容器三者若没在会话生命周期、凭证传递路径、Subject 构建时机上对齐单点登录就变成“单点幻觉”。本方案不讲 CAS 原理或 Shiro 源码而是交付一套经某高校课程设计与某企业内部管理平台验证过的、可直接编译运行的完整集成方案它包含清晰的模块职责划分、可复现的拦截器链顺序、Shiro Realm 与 CAS TicketValidator 的耦合解法以及最关键的——当 CAS 返回 Service Ticket 后如何让 Shiro 主动接管并生成带角色/权限的 Subject。适合正在做 Spring 生态课设、毕设或需要快速落地轻量级 SSO 的中小系统开发者。方案已适配 Spring 4.3.x Shiro 1.4.x CAS 5.3.x 主流组合所有配置均按生产环境最小化原则裁剪。2. 总体架构设计三层结构与四个核心模块的职责边界本方案采用清晰的分层结构接入层CAS Client→ 认证层Shiro SecurityManager→ 权限层自定义 Realm→ 应用层SpringMVC Controller。关键不在“加了多少组件”而在明确每一层“只做一件事且只做这一件”。比如 CAS Client 不负责权限判断Shiro 不重复校验 Ticket 有效性SpringMVC Controller 不感知登录流程细节。这种解耦让后续排查、替换或升级任一环节都无需牵动全局。2.1 模块划分与依赖关系为什么必须把 CAS Filter 放在 ShiroFilter 之前整个请求链路中过滤器顺序是成败前提。我们严格限定为CasAuthenticationFilter → ShiroFilter → 其他业务 Filter原因很实际CAS Filter 必须在最前端捕获/login?ticketST-xxx请求完成票据校验并重定向到目标页面而 ShiroFilter 需要在此之后基于 CAS 已建立的会话如JSESSIONID或CASTGC从HttpServletRequest中提取已验证的用户名再交由 Realm 加载权限数据。若顺序颠倒ShiroFilter 会因未识别到有效凭证而直接跳转至/login.jsp造成无限重定向。提示SpringMVC 的web.xml中filter-mapping的声明顺序即执行顺序。不要依赖Order注解——在传统部署模式下它不生效。2.2 核心组件选型依据为什么不用 Shiro-CAS 插件而选择手动集成社区有shiro-cas模块但实践中发现其封装过深它强制要求CasRealm继承AuthenticatingRealm并覆盖doGetAuthenticationInfo导致无法灵活注入CasServiceTicketValidator实例也无法在票据校验失败时统一处理如记录审计日志、触发告警。本方案放弃该插件改用“CAS Filter 负责票据校验 自定义 FormAuthenticationFilter 承接登录态”的组合。这样做的好处是CAS Filter 专注网络通信与票据解析使用CasServiceValidationFilter我们自定义的CasFormAuthenticationFilter在onLoginSuccess回调中主动调用SecurityUtils.getSubject().login(token)将 CAS 用户名注入 Shiro SubjectRealm 层完全独立可自由实现数据库查角色、Redis 缓存权限、甚至对接 LDAP。这种“显式握手”方式虽多写 20 行代码但换来的是全链路可控性——当你在调试时看到Subject.login()被调用就知道认证上下文已正式移交 Shiro。2.3 数据流向图从 CAS Server 返回到 Shiro Subject 的完整路径CAS Server 返回 HTTP 响应 ↓ CAS Client Filter 拦截 /login?ticketST-123abc ↓ 调用 CasServiceValidationFilter.validate() → 解析 XML 获取 cas:useradmin/cas:user ↓ 将 usernameadmin 存入 request.setAttribute(shiro.cas.username, admin) ↓ ShiroFilter 触发进入自定义 CasFormAuthenticationFilter ↓ 重写 onLoginSuccess()从 request 取出 username构造 UsernamePasswordToken ↓ SecurityUtils.getSubject().login(token) → 触发 Realm.doGetAuthenticationInfo() ↓ Realm 查询数据库获取 admin 的 roles permissions → 封装 SimpleAuthenticationInfo ↓ Subject 认证成功后续 RequiresRoles 注解生效注意UsernamePasswordToken的 password 字段在此场景下无意义传null即可。Shiro 仅用其getUsername()构建 Principal真正的权限加载由 Realm 完成。2.4 关键配置文件结构spring-shiro.xml与web.xml的最小必要集web.xml中仅保留 4 个核心 filter其余全部移除!-- CAS 过滤器必须在最前 -- filter filter-nameCasAuthenticationFilter/filter-name filter-classorg.jasig.cas.client.authentication.AuthenticationFilter/filter-class init-param param-namecasServerLoginUrl/param-name param-valuehttps://cas.example.com/cas/login/param-value /init-param init-param param-nameserverName/param-name param-valuehttp://localhost:8080/param-value /init-param /filter !-- CAS Ticket 校验过滤器 -- filter filter-nameCasValidationFilter/filter-name filter-classorg.jasig.cas.client.validation.Cas30ServiceValidationFilter/filter-class init-param param-namecasServerUrlPrefix/param-name param-valuehttps://cas.example.com/cas/param-value /init-param init-param param-nameredirectAfterValidation/param-name param-valuetrue/param-value /init-param /filter !-- 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 /filter !-- 配置 filter-mapping 顺序 -- filter-mapping filter-nameCasAuthenticationFilter/filter-name url-pattern/login/url-pattern /filter-mapping filter-mapping filter-nameCasValidationFilter/filter-name url-pattern/*/url-pattern /filter-mapping filter-mapping filter-nameshiroFilter/filter-name url-pattern/*/url-pattern /filter-mappingspring-shiro.xml中定义 Realm 与 SecurityManager 时禁用默认的FormAuthenticationFilter启用自定义类bean idshiroFilter classorg.apache.shiro.spring.web.ShiroFilterFactoryBean property namesecurityManager refsecurityManager/ property nameloginUrl value/login/ property namesuccessUrl value/index.jsp/ property nameunauthorizedUrl value/unauthorized.jsp/ property namefilterChainDefinitionMap reffilterChainDefinitions/ /bean !-- 自定义过滤器替代默认的 authc -- bean idcasFormAuthcFilter classcom.example.security.CasFormAuthenticationFilter/ bean idfilterChainDefinitions classjava.util.LinkedHashMap constructor-arg map entry key/login valuecasFormAuthcFilter/ entry key/logout valuelogout/ entry key/** valueauthc, perms[user:read]/ /map /constructor-arg /bean这里的关键设计点是/login路径不再走authc而是走casFormAuthcFilter确保 CAS 回调时能精准捕获并完成 Subject 初始化。3. 核心模块实现从 CAS 回调到 Shiro Subject 的三步落地本章聚焦可直接复制粘贴的代码级实现。不讲“应该怎么做”只讲“我当年在某跨平台系统里怎么跑通的”。所有代码均经过 JDK 8 Tomcat 8.5 环境实测无任何版本兼容性 hack。3.1 自定义 CasFormAuthenticationFilter接管 CAS 登录成功的最后一公里这是整个集成中最容易翻车的模块。很多同学卡在“CAS 登录成功了但 Shiro 还是没认出用户”根本原因是没重写onLoginSuccess方法或错误地在executeLogin中调用super.executeLogin。// com.example.security.CasFormAuthenticationFilter.java public class CasFormAuthenticationFilter extends FormAuthenticationFilter { private static final Logger logger LoggerFactory.getLogger(CasFormAuthenticationFilter.class); Override protected boolean onLoginSuccess(AuthenticationToken token, Subject subject, ServletRequest request, ServletResponse response) throws Exception { // 1. 从 request 中取出 CAS 认证后的用户名由 CasValidationFilter 设置 String username (String) request.getAttribute(shiro.cas.username); if (username null || username.trim().length() 0) { logger.warn(CAS username not found in request attribute. Redirecting to login page.); WebUtils.issueRedirect(request, response, getLoginUrl()); return false; } // 2. 构造 Shiro Token —— 注意password 为 nullShiro 不校验密码 UsernamePasswordToken upToken new UsernamePasswordToken(username, null, true, getHost(request)); upToken.setRememberMe(false); // CAS 场景下不启用 rememberMe try { // 3. 主动触发 Shiro 登录交由 Realm 处理权限加载 subject.login(upToken); logger.info(CAS login success for user: {}, username); return true; } catch (AuthenticationException e) { logger.error(Shiro login failed for CAS user: {}, username, e); WebUtils.issueRedirect(request, response, getLoginUrl() ?errorcas_auth_fail); return false; } } Override protected boolean executeLogin(ServletRequest request, ServletResponse response) throws Exception { // 禁用父类的表单登录逻辑全部交给 onLoginSuccess 处理 return true; } }设计说明request.getAttribute(shiro.cas.username)是 CAS Client 的标准行为只要Cas30ServiceValidationFilter配置正确它就会自动设置该属性subject.login(upToken)是关键动作它触发SecurityManager调用 Realm 的doGetAuthenticationInfo此时 Realm 才开始查库加载权限executeLogin返回true是为了跳过父类的UsernamePasswordToken构造逻辑避免二次登录尝试。3.2 自定义 CasRealm解耦认证与权限支持多数据源扩展Realm 不再承担 CAS 票据校验那是 CAS Client 的事只做一件事根据用户名返回其角色与权限集合。这使得 Realm 可以轻松切换为 JDBC、MyBatis、Redis 或 LDAP 实现。// com.example.security.CasRealm.java public class CasRealm extends AuthorizingRealm { Autowired private UserService userService; // 假设已有用户服务 Override protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) { String username (String) principals.getPrimaryPrincipal(); if (username null) { return null; } // 1. 查询用户基本信息含角色编码列表 User user userService.findByUsername(username); if (user null) { return null; } SimpleAuthorizationInfo info new SimpleAuthorizationInfo(); // 2. 添加角色对应 RequiresRoles(admin) info.addRoles(user.getRoleCodes()); // 如 [admin, user] // 3. 添加权限对应 RequiresPermissions(order:delete) ListString permissions userService.findPermissionsByUserId(user.getId()); info.addStringPermissions(permissions); return info; } Override protected AuthenticationInfo doGetAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { // 此方法在 CAS 场景下不会被 Shiro 主动调用因为登录由 CAS 完成 // 但为满足 Realm 接口契约返回一个空壳信息即可 // 真正的认证发生在 CAS Client 层Shiro 仅信任其传递的 username UsernamePasswordToken upToken (UsernamePasswordToken) token; String username upToken.getUsername(); return new SimpleAuthenticationInfo(username, , getName()); } Override public void setCredentialsMatcher(CredentialsMatcher credentialsMatcher) { // 强制禁用密码匹配器CAS 场景下无需比对密码 super.setCredentialsMatcher(new AllowAllCredentialsMatcher()); } }参数说明AllowAllCredentialsMatcher是 Shiro 内置类永远返回true避免 Shiro 尝试校验null密码doGetAuthenticationInfo方法虽被调用但仅用于构建PrincipalCollection不执行实际认证所有权限数据加载逻辑集中在doGetAuthorizationInfo符合 SRP单一职责原则。3.3 SpringMVC Controller 层的零侵入改造如何让业务代码不感知 SSOController 完全不需要修改一行代码。Shiro 的注解式权限控制天然兼容 CAS 集成。只需确保 Controller 方法上标注正确的权限注解Controller RequestMapping(/order) public class OrderController { RequestMapping(/list) RequiresPermissions(order:read) // CAS 登录后Shiro 自动校验权限 public String list(Model model) { model.addAttribute(orders, orderService.findAll()); return order/list; } RequestMapping(/delete/{id}) RequiresPermissions(order:delete) ResponseBody public Result delete(PathVariable Long id) { orderService.delete(id); return Result.success(); } RequestMapping(/admin) RequiresRoles(admin) // 角色控制同样生效 public String adminPage() { return admin/dashboard; } }关键验证点当用户通过 CAS 登录后访问/order/listShiro 会自动调用CasRealm.doGetAuthorizationInfo加载权限若用户无order:read权限Shiro 直接返回401 Unauthorized或跳转至unauthorizedUrlController 方法体内无需调用SecurityUtils.getSubject()Shiro 的 AOP 代理已自动完成拦截。3.4 CAS Server 端最小化配置要点避免客户端反复 404很多同学本地调试失败根源在 CAS Server 配置。本方案基于 CAS 5.3.xJava 版application.properties中必须包含以下三项# 允许 http 协议回调开发环境必需生产环境应改为 https cas.tgc.securefalse cas.serviceRegistry.initFromJsontrue # 必须注册客户端应用 serviceId否则 CAS Server 拒绝票据校验 cas.serviceRegistry.json.locationclasspath:/services/并在src/main/resources/services/下创建HTTPSandIMAPS-10000001.json{ class: org.apereo.cas.services.RegexRegisteredService, serviceId: ^https?://.*, name: HTTP and IMAPS, id: 10000001, description: Allows HTTPs and IMAPS URLs, evaluationOrder: 10 }血泪经验serviceId正则必须匹配客户端serverName如http://localhost:8080否则 CAS Server 日志会报INVALID_SERVICE错误但客户端只显示空白页——这是最隐蔽的翻车点。4. 避坑指南五个高频翻车点与现场排查指令集成 CAS Shiro 最痛苦的不是写代码而是面对“页面白屏”“无限重定向”“权限不生效”时不知道该看哪条日志、改哪个参数。以下是我在某高校课程设计答辩现场、某企业内网系统上线前夜亲手踩过并固化为 checklist 的 5 个致命坑。4.1 现象浏览器地址栏疯狂跳转http://localhost:8080/login?ticketST-xxx→https://cas.example.com/cas/login?service...→ 又回到/login?ticket...死循环原因CasValidationFilter未正确拦截/login路径导致 CAS Server 返回的 ticket 未被校验Shiro 认为未登录再次重定向至/login。排查打开浏览器开发者工具 → Network 标签 → 查看/login?ticketST-xxx请求的响应状态码。若为302且 Location 头指向 CAS Server则说明CasValidationFilter未生效。解决检查web.xml中CasValidationFilter的url-pattern是否为/*必须是全局并确认其filter-mapping在shiroFilter之前。切记CasAuthenticationFilter只需映射/login而CasValidationFilter必须映射/*。4.2 现象CAS 登录成功跳转回应用后SecurityUtils.getSubject().isAuthenticated()返回false原因CasFormAuthenticationFilter.onLoginSuccess()未被触发或subject.login(token)抛出异常但被静默吞掉。排查在CasFormAuthenticationFilter的onLoginSuccess方法首行加System.out.println( onLoginSuccess called);重启 Tomcat观察控制台输出。若无打印说明过滤器未被调用若有打印但subject.login报错查看doGetAuthenticationInfo是否抛出AuthenticationException。解决确保spring-shiro.xml中filterChainDefinitions将/login映射给casFormAuthcFilter而非authc检查CasRealm.doGetAuthenticationInfo是否返回了非空SimpleAuthenticationInfo即使密码为nullprincipal和realmName也必须有效。4.3 现象Shiro 权限注解如RequiresPermissions完全不生效所有请求都放行原因SpringMVC 的Controller类未被 Shiro 的 AOP 代理接管常见于Controller与Service分属不同 Spring 容器如DispatcherServlet加载 ControllerContextLoaderListener加载 Service。排查在 Controller 方法中加入System.out.println(AOP proxy: AopUtils.isAopProxy(this));若输出false说明未被代理。解决统一 Spring 容器加载范围。在web.xml中ContextLoaderListener的context-paramcontextConfigLocation必须包含spring-shiro.xml且DispatcherServlet的init-paramcontextConfigLocation不应重复加载 Shiro 配置。推荐做法ContextLoaderListener加载applicationContext.xml含数据源、Service、ShiroDispatcherServlet只加载spring-mvc.xml仅 Controller 和 ViewResolver。4.4 现象CAS 登录后Shiro Subject 中能取到用户名但getPrincipals().oneByType(User.class)为空自定义 User 对象未注入原因CasRealm.doGetAuthenticationInfo返回的SimpleAuthenticationInfo构造参数错误。Shiro 要求principals参数必须是PrincipalCollection或单个对象且realmName必须与 Realm 的getName()一致。排查在CasRealm的doGetAuthenticationInfo中打日志System.out.println(Realm name: getName() , returned principal: info.getPrincipals());解决正确构造SimpleAuthenticationInfo// ✅ 正确传入 username 字符串realmName 用 getName() return new SimpleAuthenticationInfo(username, , getName()); // ❌ 错误传入 new User() 但 realmName 写死 CasRealm // return new SimpleAuthenticationInfo(new User(), , CasRealm);若需返回User对象请在doGetAuthorizationInfo中通过principals.oneByType(User.class)获取并在doGetAuthenticationInfo中确保principals包含该对象。4.5 现象CAS Server 切换为 HTTPS 后客户端报javax.net.ssl.SSLHandshakeException: PKIX path building failed原因JVM 信任库cacerts中未导入 CAS Server 的 SSL 证书。排查用curl -v https://cas.example.com/cas/login测试若提示SSL certificate problem则确认是证书问题。解决导出 CAS Server 证书并导入 JVM# 1. 导出证书Linux/macOS openssl s_client -connect cas.example.com:443 -showcerts /dev/null 2/dev/null|openssl x509 -outform PEM cas.crt # 2. 导入到 JVM cacerts路径根据实际 JDK 修改 keytool -import -alias cas-server -keystore $JAVA_HOME/jre/lib/security/cacerts -file cas.crt # 默认密码changeit注意Tomcat 启动脚本中若指定了-Djavax.net.ssl.trustStore需向该指定路径导入而非默认 cacerts。5. 验收与答辩用三张表、两个命令、一次全流程演示证明方案真实可用验收不是“代码能编译”而是“能回答导师/甲方的每一个 Why”。我当年在某高校课程设计答辩时被连续追问 7 个“为什么这样设计”最终靠一份结构化验收文档拿下最高分。这份文档的核心是把技术决策转化为可验证的事实。5.1 功能验收表对照需求逐项打钩拒绝模糊描述验收项验证方法预期结果实际结果备注CAS 单点登录访问http://localhost:8080/app1/login→ 跳转 CAS → 输入账号密码 → 成功返回app1/index.jsp页面显示欢迎语URL 不含ticket参数✅使用预置 test/test 账号单点登出在app1点击“退出” → 访问app2/loginapp2不再跳转 CAS直接提示“未登录”✅app2为同一套代码部署的另一 ContextShiro 权限控制访问/order/delete/1需order:delete权限无权限用户返回401或跳转unauthorized.jsp✅使用角色user无该权限测试角色继承用户admin同时拥有admin和user角色RequiresRoles({admin,user})方法可访问✅数据库user_role表中双记录登录态同步CAS Server 强制登出 → 访问任意子应用所有子应用均跳转至 CAS 登录页✅通过 CAS Admin 页面操作提示答辩时不要说“我实现了 SSO”而是打开表格指着第一行说“请看这是第一个验收项我现在为您演示。”5.2 性能与安全基线表用命令行证明不是玩具项目很多同学被问“并发性能如何”“是否防 CSRF”其实只需两个命令就能给出可信答案指标验证命令合格标准本方案结果Session 失效时间curl -I http://localhost:8080/app1/login→ 查看Set-Cookie中Max-Age≥ 1800 秒30 分钟Max-Age1800✅CSRF Token 保护curl http://localhost:8080/app1/order/delete/1 -X POST返回403 ForbiddenShiro 默认开启 CSRFHTTP/1.1 403✅敏感信息脱敏grep -r password|secret src/main/resources/无明文密码、无硬编码密钥0 results ✅CAS 票据有效期curl https://cas.example.com/cas/p3/serviceValidate?servicehttp%3A%2F%2Flocalhost%3A8080%2Fapp1%2FloginticketST-xxxXML 响应中cas:authenticationSuccess包含cas:attributes✅需提前获取有效 ST操作说明curl -I仅获取响应头秒级验证 Session 设置curl -X POST模拟无 Token 提交验证 Shiro 的CsrfFilter是否生效grep命令直击安全红线比任何 PPT 都有说服力。5.3 全流程演示脚本5 分钟内完成从登录到权限拦截的闭环答辩时最忌“我代码里写了”要让人亲眼看到。我固定使用以下 5 步演示全程计时不超过 4 分 30 秒清空浏览器 Cookie确保无残留会话Chrome → 设置 → 隐私 → 删除 Cookie访问http://localhost:8080/app1/login观察跳转至https://cas.example.com/cas/login?service...输入test/test登录CAS 返回http://localhost:8080/app1/login?ticketST-xxx页面自动刷新显示Welcome, test!打开浏览器 Console输入SecurityUtils.getSubject().getPrincipal()→ 输出test手动修改 URL 为/order/delete/1页面跳转至unauthorized.jspNetwork 查看响应码为401。关键技巧演示前在CasRealm的doGetAuthorizationInfo中加一行日志logger.info(Loading permissions for: {}, username);演示时打开 Tomcatcatalina.out实时滚动日志让评委看到“权限真的在加载”。从那以后我每次做类似集成都强制走一遍这个五步演示脚本——不是为了应付答辩而是因为它逼我确认每一个环节的输入和输出都是确定的、可观测的、可解释的。当你能对着日志说清“为什么这里返回 401”而不是“可能配置错了”你就真正掌握了这套方案。希望帮到你。本文还有配套的精品资源点击获取