# lcxm-cas-sso
**Repository Path**: basic-support/lcxm-cas-sso
## Basic Information
- **Project Name**: lcxm-cas-sso
- **Description**: 基于cas的sso编码实现
- **Primary Language**: Unknown
- **License**: Not specified
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2024-05-23
- **Last Updated**: 2025-08-25
## Categories & Tags
**Categories**: Uncategorized
**Tags**: basic-support
## README
## lcxm-cas-sso
> 基于cas的sso单点登录实现,包含以下三个模块
1. lcxm-cas-sso-common:基础模块,包含一些公共的类,如:工具类,常量类,枚举类等
2. lcxm-cas-sso-client:客户端模块,包含客户端的配置,主要提供登录登出过滤器
3. lcxm-cas-sso-server:服务端模块,包含服务端的配置,是一个独立的的springboot项目
- start at 2024-05-23
### 版本更新说明
- 参见:[CHANGELOG](CHANGELOG.md)
**maven坐标**
```
cn.xuqiudong.basic
lcxm-cas-sso-{moduleName}
${version}
```
### 背景
> 同[lcxm-common](https://gitee.com/basic-support/lcxm-common/)
> 原来的所有的模块都放在[boot-support](https://gitee.com/xuqiudong/boot-support), 显得项目很臃肿,现在将其拆分,方便管理。
本项目是原来的[module-cas-sso](https://gitee.com/xuqiudong/boot-support/tree/master/module-cas-sso),现在独立拆分为一个独立项目。
### 打包:
> 打包和发布的时候跳过验证
1. 打包:mvn clean package '-Dskip.spotbugs=true'
2. install: mvn clean install '-Dskip.spotbugs=true'
3. 发布: mvn clean deploy '-Dskip.spotbugs=true' -Pdeploy
- lcxm-cas-sso-server 作为单独运行的项目,不在deploy到中央仓库:
- ` mvn deploy -pl '!lcxm-cas-sso-server' -Pdeploy`
4. 父项目修改后,统一修改子模块依赖的父版本:`mvn versions:update-child-modules`
### 自动发布
参见[jenkinsfile](Jenkinsfile)
原来的[boot-support](https://gitee.com/xuqiudong/boot-support/blob/master/Jenkinsfile) 一下子部署发布太多了,这里把他们拆开,按需发布。
`mvn clean package -Denv=prod`
[基于cas的sso编码实现](https://xuqiudong.cn/detail/24)
[cas前置常识](https://xuqiudong.gitee.io/technology/#/solution/cas-sso/front-of-cas)
> 项目地址:https://gitee.com/xuqiudong/boot
>
> 感谢:[smart-sso](https://gitee.com/a466350665/smart-sso)
请求流程:

#### 2 sso server
##### 2.1 server端主要配置参考SsoConfiguration.java
```java
@Configuration
@ConfigurationProperties(prefix = "sso")
public class SsoConfiguration {
/**
* 超时时间:默认2个小时
*
* 包括:登录凭证TicketGrantingTicket(tgt);
* 刷新凭证 RefreshToken;
*
*
*/
@Value("${sso.timeout:7200}")
private int timeout;
/**
* 授权码超时时间 默认10分钟
*/
@Value("${sso.codeTimeout:600}")
private int codeTimeout;
/**
* accessToken 的超时时间 默认为timeout的一半
*/
private int accessTokenTimeout;
/**
* 存储策略,只支持redis和local
*/
@Value("${sso.storageStrategy:local}")
private String storageStrategy;
/**
* 可用的sso client 列表
*/
private List clients;
}
/**
* app信息,用户server端校验client端的合法性 包含id和secret, 暂时通过配置的方式,后续考虑存入数据库
*/
public static class AppClient {
String id;
String secret;
}
```
##### 2.2 各类票据生成和校验参见包`cn.xuqiudong.sso.server.session`
- AccessTokenManager:调用凭证AccessToken管理器接口
- CodeManager:授权码管理器接口
- RefreshTokenManager:刷新凭证refreshToken管理器接口
- SessionManager:全局tgt管理器
- TicketGrantingTicketManager:登录凭证(TGC)管理器接口
##### 2.3 对各类票据的分布式和本地存储
> 最初设计为,2.2中每一类管理器接口分别做local以及redis方式的实现,从而实现本地或者分布式
>
> 后面,觉得这样设计编码稍多,进而修改为,管理器不变,设计存储接口,实现不同的存储策略
##### 2.4 存储策略参见包`pers.vic.sso.server.storage`
> 根据管理器的需要在接口中提供相应的方法
- LocalStorageStrategy: 本地存储策略,默认
- RedisStorageStrategy:redis存储策略
##### 2.5 控制器
- IndexController:提供登录登出,重定向相关功能
- Oauth2Controller:Oauth2接口提供,用以客户端校验code,刷新accessToken等
##### 2.6其他
- AppService:应用服务相关接口 主要是针对客户端的检测
- SsoUserService:用于登录相关
#### 3 sso client
> 主要提供登录登出过滤器,以及登出监听器
##### 登录监听器LoginFilter
- 流程参见上述流程图
- 下面为摘录的部分代码注释
```
/**
*
* 1. 判断本地session是否存在;
* 2. 如果存在,则判断是否过期
* 3. 如果过期,则使用refreshToken前往服务端获刷新token,延长周期,获取新的accessToken
* 4. 若果token不存在,或者过期,或者无法延期:
* 5. 则获取请求中的授权码
* 6. 若获取不到授权码,则前往登录页面
* 7. 通过授权码拿到accessToken(且存储到本地),则去掉url中的code,再次重定向到当前地址
*
*
* @param request
* @param response
* @return
* @throws IOException
*/
```
###### 前后端分离清理下的登录监听器SeparationLoginFilter (仅供参考)
```
/**
* 描述:
* 前后端分离的login过滤器,
* 前端在判断没有登录的情况下,直接在浏览器访问后端的某个url,然后后端进行相关重定向
* 怎么判断: 比如返回状态码为302 则操作
*
*
* 1. 前后端应该在一个域内,保证session一致
* 校验流程:
* 1. 访问后端某个接口, 判断没有登录
* 2. 跳转到sso, 但是携带的redirectUrl地址应是当前后端的特点地址
* 3. 进行一系列校验工作后,登录成功,判断当前访问的是这个特定地址,则重定向到配置的前端地址
* 区别:
* 1. 需要配置htmlUrl:即检验成功后 重定向的前端地址
* 2. 需要配置clientHost:当前后端的根地址,用户sso Server重定向回来
*
*
*
* @author Vic.xu
* @date 2021-11-12 11:13
*/
```
##### 登出过滤器LogoutFilter
> 退出登录过滤器:客户端不主动退出,而是跳转到sso server端 执行退出处理,然后sso端通过回调通知各个客户端分别退出;
> 但是服务端调用的地址是登录时候携带的redirectUrl地址,可能是任何地址(除非限制回跳地址就是首页)
- 需要配置客户端退出地址(默认为)/logout
- 等客户端退出的时候,重定向到sso server进行退出
- 然后客户端发起通知,通知各客户端分别退出
- LogoutFilter 收到server端的通知,获取accessToken对应的session,进行销毁
##### 登出监听器与SessionMappingStorage
> LogoutListener :登出Listener,用于本地session过期,删除accessToken和session的映射关系
>
> 默认为本地存储的方式,可配置不同SessionMappingStorage
###### accessToken和session的映射关系存储SessionMappingStorage
- LocalSessionMappingStorage:本地维持session 和 accessToken相互之间的关系,默认方式
- ShiroRedisSessionMappingStorage:如果客户端通过shiro实现session共享的话
- SpringRedisSessionMappingStorage:如果客户端使用spring-session-data-redis 管理session的话 (@EnableRedisHttpSession)
#### 4 sso common
- 提供一些工具类如http请求等
- 提供通用的model
- 提供通用的常量和枚举
#### 兼容移动端登录
> 移动端直接请求项目中的登录,在登录接口中调用本SsoOauth2Util方法的通过账号密码获取 accessToken
>
> **参见ApiLoginFilter 的注释说明**
```
/**
* 描述:基于接口的登录
*
* 1. 使用与基于API接口的项目,比如移动端或者前后端分离
* 2. 流程:
* 2.1 进入拦截器,没有登录 则返回401 前端自行跳转到登录页面,输入账号密码
* 2.2 后端进行基于账号密码的方式获取accessToken {@link cn.xuqiudong.sso.common.util.SsoOauth2Util#getAccessToken(String, String, String, String, String)} 并存入session
* 2.3 前端在访问接口的时候携带 sessionId或者token
*
*
* 原本是通过此 SeparationLoginFilter 进行控制的,但是这种控制对后端耦合太强
* @see SeparationLoginFilter
*/
```
## config demo
```java
package pers.vic.sso.demo.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.boot.web.servlet.ServletListenerRegistrationBean;
import org.springframework.context.ApplicationListener;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.session.events.AbstractSessionEvent;
import org.springframework.session.web.http.SessionEventHttpSessionListenerAdapter;
import pers.vic.sso.client.filter.LoginFilter;
import pers.vic.sso.client.filter.LogoutFilter;
import pers.vic.sso.client.listener.LogoutListener;
import pers.vic.sso.client.session.shiro.ShiroRedisSessionMappingStorage;
import pers.vic.sso.client.session.spring.SpringRedisSessionMappingStorage;
import pers.vic.sso.common.constant.SsoConstant;
import pers.vic.sso.demo.user.service.UserService;
import javax.annotation.Resource;
import javax.servlet.http.HttpSessionListener;
import java.util.ArrayList;
import java.util.List;
/**
* 描述:
* 集成单点登录的配置
*
* @author Vic.xu
* @since 2021-11-03 17:09
*/
@Configuration
public class SsoConfig {
@Value("${sso.server.url}")
private String serverUrl;
@Value("${sso.app.id}")
private String appId;
@Value("${sso.app.secret}")
private String appSecret;
@Value("${sso.html.url}")
private String htmlUrl;
@Value("${sso.client.host}")
private String clientHost;
@Resource
private UserService userService;
/**
* 单实例方式单点登出Listener,因为它的存储策略就是LocalSessionMappingStorage, 所以无需额外处理
*
* @return
*/
@Bean
public ServletListenerRegistrationBean logoutListener() {
ServletListenerRegistrationBean listenerRegBean = new ServletListenerRegistrationBean<>();
LogoutListener logoutListener = new LogoutListener();
listenerRegBean.setListener(logoutListener);
return listenerRegBean;
}
/**
* ★★
* 分布式的登出Listener ,需要为LogoutListener注入 SpringRedisSessionMappingStorage 或 ShiroRedisSessionMappingStorage(而这两种策略均依赖redis)
* 1. shiro的LogoutListener 注入方式:理应把Listener 放到shiro的SessionManage的sessionListeners中 ,以防止一些调用时机导致的session失效问题,
* 但是由于LogoutListener实现的是HttpSessionListener 而不是shiro的SessionListener,故此处直接通过spring的方式直接注入
* 2. spring-session的shiro的LogoutListener注入方式:然后使用Spring的方式注入 LogoutListener,把Listener放进SessionEventHttpSessionListenerAdapter 中防止监听器失效
* 以下分别给出示例代码
*/
/**
* 分布式spring-redis方式登出Listener:
* 先注入 SpringRedisSessionMappingStorage ,
*/
// @Autowired
private SpringRedisSessionMappingStorage springRedisSessionMappingStorage;
// @Bean
public SpringRedisSessionMappingStorage springRedisSessionMappingStorage() {
return new SpringRedisSessionMappingStorage();
}
// @Autowired
private ShiroRedisSessionMappingStorage shiroRedisSessionMappingStorage;
// @Bean
public ShiroRedisSessionMappingStorage shiroRedisSessionMappingStorage() {
return new ShiroRedisSessionMappingStorage();
}
/**
* 基于shiro 的分布式LogoutListener
*/
// @Bean
public ServletListenerRegistrationBean shiroRedisLogoutListener() {
ServletListenerRegistrationBean listenerRegBean = new ServletListenerRegistrationBean<>();
LogoutListener logoutListener = new LogoutListener();
//注入session的处理策略为shiro
logoutListener.setSessionMappingStorage(shiroRedisSessionMappingStorage);
listenerRegBean.setListener(logoutListener);
return listenerRegBean;
}
/**
* 基于spring session 的分布式LogoutListener
*/
// @Bean
public ApplicationListener springRedisLogoutListener() {
List httpSessionListeners = new ArrayList<>();
LogoutListener logoutListener = new LogoutListener();
//注入session的处理策略为spring-session
logoutListener.setSessionMappingStorage(springRedisSessionMappingStorage);
httpSessionListeners.add(logoutListener);
return new SessionEventHttpSessionListenerAdapter(httpSessionListeners);
}
/**
* 登录过滤器
*
* @return
*/
@Bean
public FilterRegistrationBean loginFilter() {
LoginFilter loginFilter = new LoginFilter();
//前后端分离的登录过滤器
// SeparationLoginFilter loginFilter = new SeparationLoginFilter(clientHost, htmlUrl);
loginFilter.setAppId(appId);
loginFilter.setAppSecret(appSecret);
loginFilter.setServerUrl(serverUrl);
loginFilter.addExcludeUrl(SsoConstant.LOGOUT_URL);
//登录成功之后的回调
loginFilter.setAfterLogin(accessToken -> {
userService.afterLogin(accessToken);
});
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(loginFilter);
filterRegistrationBean.addUrlPatterns("/*");
filterRegistrationBean.setOrder(2);
filterRegistrationBean.setName("loginFilter");
return filterRegistrationBean;
}
/**
* 登出过滤器
*
* @return
*/
@Bean
public FilterRegistrationBean logoutFilter() {
LogoutFilter logoutFilter = new LogoutFilter();
logoutFilter.setAppId(appId);
logoutFilter.setAppSecret(appSecret);
logoutFilter.setServerUrl(serverUrl);
//登出成功后的回调
logoutFilter.setAfterLogout(s -> userService.afterLogout(s));
FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean<>();
filterRegistrationBean.setFilter(logoutFilter);
filterRegistrationBean.addUrlPatterns("/*");
filterRegistrationBean.setOrder(1);
filterRegistrationBean.setName("logoutFilter");
return filterRegistrationBean;
}
}
```