当前位置: 首页 > news >正文

SpringSecurity 集成 CAS Client 处理单点登录 - Higurashi

推荐阅读:CAS 单点登录详细流程

背景

当前业务系统基于 Spring Security,现在需要集成 CAS,当用户访问业务系统时,如果用户没有登录,则跳转到 CAS Server 统一登录页面完成登录。

而当用户从 CAS Server 退出登录后,业务系统需要清空登录信息,再次访问业务系统时,需要重新登录。

实现思路

CAS 提供了 AuthenticationFilter 过滤业务接口,过滤请求时,会先访问 CAS Server 判断用户是否登录,如果未登录,则跳转到 CAS Server 登录页面,已登录则请求回原有业务接口,并传递 ticket 参数。为了尽可能减小对当前业务系统的影响,只会让拦截器拦截指定接口,而保持业务系统原有授权逻辑不变,

用户登出时,CAS Server 会调用业务系统的接口,被 SingleSignOutFilter 拦截处理,清空用户登录信息。

登录

首先需要引入 CAS Client 依赖:

<dependency><groupId>org.jasig.cas.client</groupId><artifactId>cas-client-core</artifactId><version>3.6.4</version>
</dependency>

然后新增一个接口,前端页面加载时,如果用户未登录,则请求该接口,让拦截器拦截处理。

/*** CAS 单点登录状态检查,未登录跳转至 CAS 登录页面,已登录重定向并传递 ticket*/
@GetMapping("/casLogin")
public void casLogin(@RequestParam(value = "ticket", required = false) String ticket, HttpServletResponse response) throws IOException {if (StrUtil.isNotBlank(ticket)) {String url = StrUtil.format("{}/#/authRedirect?ticket={}", casProperties.getAppFrontUrl(), ticket);response.sendRedirect(url);}
}

方法内重定向时,会带上 ticket 参数,前端拿到 ticket 参数后,请求业务系统登录接口,业务接口验证 ticket 是否有效,并返回登录成功信息。

现在来看,实际上也可以直接在该接口中完成登录,返回登录状态信息给前端。

因为该接口需要被拦截处理,所以注册过滤器为 Bean:

@Configuration
@Slf4j
@RequiredArgsConstructor
@EnableConfigurationProperties(CasProperties.class)
public class CasFilterConfiguration {private final CasProperties casProperties;/*** 单点登录认证*/@Beanpublic AuthenticationFilter CasAuthenticationFilter() {String appServerUrl = casProperties.getAppServerUrl();String casServerUrl = casProperties.getCasServerUrl();String casServerLoginUrl = casProperties.getCasServerLoginUrl();AuthenticationFilter authenticationFilter = new AuthenticationFilter();authenticationFilter.setServerName(appServerUrl);authenticationFilter.setCasServerUrlPrefix(casServerUrl);authenticationFilter.setCasServerLoginUrl(casServerLoginUrl);authenticationFilter.init();return authenticationFilter;}}

其中 CasProperties 配置类,用于获取 CAS 的配置信息:

@Data
@ConfigurationProperties(prefix = "security.cas")
public class CasProperties {/*** CAS Server 地址*/private String casServerUrl = "http://192.168.1.103:8008/cas";/*** CAS Server 登录地址,未登录时,重定向到该地址登录*/private String casServerLoginUrl = "http://192.168.1.103:8008/cas/login";/*** 应用访问地址,拦截器重定向回源,和 {@link CasProperties#casLoginPath} 拼接后即回调地址<br>* {@link CasProperties#casServerUrl} 要能够访问到该地址完成登出*/private String appServerUrl = "http://192.168.1.103:8011/api/auth";/*** CAS 登录地址,未登录时,前端调用该地址,拦截器校验登录成功后重定向回该地址*/private String casLoginPath = "/token/casLogin";/*** 应用前端地址,拦截器校验登录成功后重定向回该地址*/private String appFrontUrl = "http://192.168.1.103:8011";
}

然后将过滤器加入 Spring Security 过滤器链中:

@Slf4j
@Configuration
@RequiredArgsConstructor
public class AuthorizationServerConfiguration {private final Optional<AuthenticationFilter> authenticationFilter;private final Optional<CustomSingleSignOutFilter> customSingleSignOutFilter;private final CasProperties casProperties;@Bean@Order(Ordered.HIGHEST_PRECEDENCE)public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http,AuthenticationSuccessHandler successEventHandler,AuthenticationFailureHandler failureEventHandler) throws Exception {// 添加 CAS 认证过滤器applyCas(http);}private void applyCas(HttpSecurity http) {customSingleSignOutFilter.ifPresent(singleSignOutFilter -> {http.addFilterBefore(singleSignOutFilter, UsernamePasswordAuthenticationFilter.class);});if (authenticationFilter.isPresent()) {String casLoginPath = casProperties.getCasLoginPath();ConditionalFilter<AuthenticationFilter> authenticationFilter =new ConditionalFilter<>(this.authenticationFilter.get(), casLoginPath);http.addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class);}}}

其中 ConditionalFilter 是一个条件过滤器,用于只处理指定路径的请求,这里让拦截器只处理对 casLoginPath 的请求:

/*** 包装过滤器,匹配指定的 url 才过滤* @param <T> Filter 的具体实现*/
public class ConditionalFilter<T extends Filter> implements Filter {/*** 过滤器*/private final T delegateFilter;private final AntPathMatcher pathMatcher = new AntPathMatcher();/*** 过滤的 url*/private final String pattern;public ConditionalFilter(T delegateFilter, String pattern) {this.delegateFilter = delegateFilter;this.pattern = pattern;}@Overridepublic void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)throws IOException, ServletException {HttpServletRequest httpRequest = (HttpServletRequest) request;String path = httpRequest.getServletPath();if (pathMatcher.match(pattern, path)) {delegateFilter.doFilter(request, response, chain);} else {chain.doFilter(request, response);}}@Overridepublic void init(FilterConfig filterConfig) throws ServletException {delegateFilter.init(filterConfig);}@Overridepublic void destroy() {delegateFilter.destroy();}
}

这里还注册了单点登出过滤器 CustomSingleSignOutFilter,后面会分析。

这样,当前端访问 casLogin 接口时,AuthenticationFilter 会先访问 CAS Server 检查用户是否登录,未登录时跳转到 CAS Server 的登录页面,已登录时 CAS Server 重定向回 casLogin 接口,并携带 ticket 参数,casLogin 接口获取 CAS Server 返回的 ticket 参数,重定向回应用前端地址,此时前端再获取到 ticket 参数,并调用 Spring Security 登录接口,完成登入。

在 Spring Security 登录接口中,需要验证 ticket 参数,并获取用户信息,保存登录状态,下面是一个示例:

private final CasProperties casProperties;@Override
@SneakyThrows
public String identify(String ticket) {// CAS Server 的 URLCas30ProxyTicketValidator cas30ProxyTicketValidator = new Cas30ProxyTicketValidator(casProperties.getCasServerUrl());Assertion validate = cas30ProxyTicketValidator.validate(ticket, casProperties.getAppServerUrl());String name = validate.getPrincipal().getName();// ...// 缓存 ticketString ticketKey = StrUtil.format("{}:{}", SecurityConstants.CAS_TICKET_KEY, ticket);redisTemplate.opsForValue().set(ticketKey, name);log.info("CAS 登录: {}", name);return name;
}

注意这里保存了 ticket,用于后续登出时获取对应的用户,并清理缓存。

登出

在用户从 CAS Server 登出时,CAS Server 会调用业务系统的接口,并传递对应 ticket,下面的拦截器会根据 ticket 获取到对应的用户,然后将用户从 Spring Security 中登出:

/*** 自定义 CAS 单点登出过滤器* 处理 CAS 服务端发送的登出请求,清理本地会话和相关缓存*/
@Slf4j
@RequiredArgsConstructor
@Component
public class CustomSingleSignOutFilter extends GenericFilterBean {/*** CAS 单点登出处理器*/private final SingleSignOutHandler HANDLER = new SingleSignOutHandler();{HANDLER.init();}private final StringRedisTemplate redisTemplate;/** OAuth2 授权信息服务,用于删除用户授权信息 */private final MyRedisOAuth2AuthorizationService myRedisOAuth2AuthorizationService;/*** 过滤器核心方法,处理 CAS 登出请求** @param servletRequest  Servlet 请求对象* @param servletResponse Servlet 响应对象* @param chain           过滤器链* @throws IOException      IO 异常* @throws ServletException Servlet 异常*/@Overridepublic void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {final HttpServletRequest request = (HttpServletRequest) servletRequest;final HttpServletResponse response = (HttpServletResponse) servletResponse;// 判断是否是 CAS 登出请求boolean isLogoutRequest = ReflectUtil.invoke(HANDLER, "isLogoutRequest", request);if (isLogoutRequest) {log.info("CAS 登出请求: {}", request.getRequestURI());// 处理登出逻辑final String logoutParam = CommonUtils.safeGetParameter(request, ConfigurationKeys.LOGOUT_PARAMETER_NAME.getDefaultValue());if (CommonUtils.isNotBlank(logoutParam)) {// 提取 SessionIndex 作为票据String regex = "<samlp:SessionIndex>([^<]+)</samlp:SessionIndex>";String ticket = ReUtil.get(regex, logoutParam, 1);String ticketKey = StrUtil.format("{}:{}", SecurityConstants.CAS_TICKET_KEY, ticket);// 获取票据对应的用户名String name = redisTemplate.opsForValue().get(ticketKey);if (StrUtil.isNotBlank(name)) {// 删除 value 为 name 的所有 ticket 缓存ScanOptions scanOptions = ScanOptions.scanOptions().match(SecurityConstants.CAS_TICKET_KEY + ":*").count(1000).build();try (Cursor<String> scan = redisTemplate.scan(scanOptions)) {scan.forEachRemaining(key -> {String value = redisTemplate.opsForValue().get(key);if (name.equals(value)) {redisTemplate.delete(key);}});}// 删除该用户的授权信息UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(name, null);myRedisOAuth2AuthorizationService.removeByUsername(auth);log.info("CAS 登出: {}", name);}}} else {// 非登出请求,继续执行过滤器链chain.doFilter(servletRequest, servletResponse);}}
}

拦截方法会判断当前请求是否为来自 CAS Server 的登出请求,如果是则获取请求中的 ticket,并获取 ticket 对应的用户名,然后清理 ticket,并删除该用户的授权信息,完成业务系统的登出。

http://www.jsqmd.com/news/45963/

相关文章:

  • NOIP2025模拟赛12(炼石计划NOIP模拟赛第 19 套题目)
  • [nanoGPT] GPT模型架构 | `LayerNorm` | `CausalSelfAttention` |`MLP` | `Block` - 实践
  • duckdb索引介绍
  • 25.11.20 最长不升序列LNIS和最长升序列LIS
  • 周赛提高组(栈与队列)
  • 2025.11.20 B 题解
  • 重组干扰素蛋白的结构特点与分子性质综述
  • 2025 门窗十大品牌权威榜单:依托行业评估报告 + 选购白皮书,省心采购指南!
  • 实用指南:OpenCV下载安装教程(非常详细)从零基础入门到精通,看完这一篇就够了(附安装包)
  • 详解 DPO
  • 程序员手记
  • Object.entries() 和 Object.formEntries()的用法详解
  • 详细介绍:MyBatis 与 Spring Data JPA 核心对比:选型指南与最佳实践
  • 详细介绍:【从0开始学习Java | 第23篇】动态代理
  • 安卓中执行 root 命令
  • UniApp缓存系统详解 - 详解
  • FreeSWITCH使用mod_fail2ban模块来提升安全
  • 【ArcMap】使用拓扑(Topology)检查线是否存在断点
  • 电动汽车行业时序数据库选型指南:以 TDengine 为例的四大关键维度与评估标准
  • CF2165 VP 记录
  • 如何在SPM混编中实现不同target之间的通信?
  • Python在线教育广告精准投放:SEM结构方程、XGBoost、KDE核密度、聚类、因子分析、随机森林集成优化融合用户满意度渠道效能|附代码数据
  • 完整教程:Spring Boot Actuator全解析
  • 专题:2025年AI Agent智能体行业价值及应用分析报告:技术落地与风险治理|附140+ 份报告PDF、数据、可视化模板汇总下载
  • 2025/11/20-Why brushing teeth twice a day is not always best
  • uos安装idea
  • HDU3586-Information Disturbing
  • 【App Service】.NET 应用在App Service上内存无法占用100%的问题原因
  • 深入解析:css 的 clip-path 属性,绘制气泡
  • 快速构建一个基础、现代化的 WinForm 管理系统!