一、单点登录介绍
单点登录(Single Sign On),简称为 SSO,是比较流行的企业业务整合的解决方案之一。
SSO的定义是在多个[应用],用户只需要登录一次就可以访问所有相互信任的应用系统。
一般这种单点登录的实现方案,分为两种,即:中心化方式 与 去中心化方式;两者核心区别‘
是对身份的认证操作是否集中。
1、中心化方式
中心化所有认证请求统一交由一个中央认证服务器(中心)(如CAS Server、Keycloak、
Okta)处理系统完全依赖该中心节点,以访问百度搜索为例看下中心化单点登录的认证流程,
认证流程如下:
1)用户访问“百度搜索”,被重定向到中央认证服务器登录,先发起认证请求
2)认证通过后,服务器生成全局票据(如:Token),并返回给用户。
3)用户访问“百度网盘”时,百度网盘应用向中央服务器验证Token有效性
4)所有应用的认证状态由中央服务器统一维护(如Session或Token注销)
2、去中心化方式
去中心化认证分散,无单一中心节点,依赖分布式协议或密码学技术实现信任传递;去中心
化一般是通过JWT来实现的,以百度搜索为例看下去中心化的认证流程,如下所示:
1)用户首次登录后,认证服务器签发一个自包含的JWT令牌(含用户信息、签名、过期时
间)。
2)用户访问“百度网盘”时直接携带JWT,“百度网盘”应用通过本地验证JWT令牌
3)各应用独立验证JWT令牌有效性,无需实时依赖中心认证节点
3、中心化与去中心化的优缺点
1)中心化方式:
缺点:
存在单点故障,单台服务的访问压力较大,每次请求认证身份都需要访问认证服务器,
导致压力相对比较大,效率也比较低。
优点:
集中管理,用户权限、会话生命周期统一控制,安全性高。
一致性,所有应用共享同一套认证逻辑,维护简单。
中心化适用场景:
企业内部系统、封闭生态(如公司内网、校园网)
中心化方式的实现方案:
(1)CAS
(2)SAML
(3)部分OAuth 2.0实现(如授权服务器集中管理)
2)去中心化方式:
缺点:
撤销困难,JWT过期前无法强制失效,需依赖短有效期或黑名单。
实现复杂,需处理分布式信任问题(如密钥分发、共识机制)
优点:
不存在单点故障,并且在访问时,可以减少网络IO所占用的时间,并且针对认证服务
器没有请求压力。去中心化的方式一般采用JWT实现
适用场景:
区块链应用、物联网(IoT)、开放生态(如Web3)
二、搭建CAS服务
1、CAS介绍
CAS是一个开源项目,CAS是应用于企业级别的单点登录的服务,CAS分为CAS Server,
CAS Client;
CAS Server是需要一个单独部署的Web工程
CAS Client是一个项目中的具体业务服务,并且在需要认证或授权时,找到CAS Server即
可整体CAS的认证和授权流程就是中心化的方式
2、搭建CAS
CAS Server的5.x版本更改为使用gradle构建,平时更多的是使用Maven,这里采用CAS的4.x
版本
下载CAS:https://github.com/apereo/cas/archive/refs/tags/v4.1.10.zip
CAS下载完成后使用IDEA打开CAS Server,并修改一些配置信息,将CAS Server进行打包,
扔到Tomcat服务中运行
2.1、使用IDEA打开CAS
CAS下载后,我们只需要关注 cas-server-webapp
CAS默认只支持HTTPS协议,我们需要修改一些配置,让CAS支持http,具体修改如下
1)修改 Apereo-10000002.json
将 Apereo-10000002.json 中的serviceId的值修改成匹配Http协议
2)HTTPSandIMAPS-10000001.json
将 HTTPSandIMAPS-10000001.json 中的serviceId的值修改成匹配Http协议
3)ticketGrantingTicketCookieGenerator.xml
将id=ticketGrantingTicketCookieGenerator 的<bean> 中的 p:cookieSecure 修改为false
4)warnCookieGenerator.xml
将id=warnCookieGenerator的<bean> 中的 p:cookieSecure 修改为false
5)deployerConfigContext.xml
在deployerConfigContext.xml中id=proxyAuthenticationHandler的<bean>中追加
p:requireSecure="false"
2.2、将项目进行打包,采用项目中的Maven插件,war的形式打包
执行plugins中提供的war:war执行打包
2.3、将war包扔到Tomcat的webapps里,并运行即可
2.4、访问CAS Server首页,并且完成认证
1)配置CAS默认用户名和密码
2)浏览器访问http://localhost:8080/cas/login 访问CAS登录页面
三、Shiro + pac4j + CAS 实现单点登录
注意:
1)CAS只是用来做认证,授权还是要基于前边的Shiro
2)shiro-cas 依赖包中虽然提供了基于CAS的Relam类 CasRelam ,但该类在1.2版本后
已经过期,不建议使用;CasRelam 中的注释中建议使用 buji-pac4j 提供的CasRelam
1、Shiro + pac4j + CAS 认证流程
本质上和ShiroWeb的流程没有变化,只不过内部使用的一些Realm和过滤器交由pac4j提供;
认证流程如下图所示:
2、Shiro + pac4j + CAS 认证实现步骤
1)准备Spring boot环境,省略
3)导入依赖
<dependency>
<groupId>org.apache.shiro</groupId>
<artifactId>shiro-spring</artifactId>
<version>1.4.0</version>
</dependency>
<dependency>
<groupId>io.buji</groupId>
<artifactId>buji-pac4j</artifactId>
<version>4.0.0</version>
</dependency>
<dependency>
<groupId>org.pac4j</groupId>
<artifactId>pac4j-cas</artifactId>
<version>3.0.2</version>
</dependency>
</dependencies>
3)CAS必要相关配置
4)定义Relam
定义 CasRelam,注意与前边springboot 整合shiro中的Relam 的区别;CasRelam 需要继
承 Pac4j包下的 Pac4jRealm;Pac4jRealm已经实现了具体的认证流程,我们不需要重写认证;
但授权流程需要我们重写;因为CAS只做认证,授权还是由Shiro 完成的,所以授权可以把前边
CustRelam 中的授权方法直接拿过来,这里就省略了。
示例代码如下:
/**
* 自定义基于CAS的Relam,继承 Pac4jRealm
*
* todo 注意:
* 认证方法直接用父类 中的认证方法doGetAuthenticationInfo,不需要重写
* 授权需要自己编写,授权操作跟前边 Shiro 授权操作一致,可以把前边的CustRelam 中的授权直接拿过来
*/
@Component
public class CasRealm extends Pac4jRealm {
/**
* 授权操作,需要自己编写,并且也可以基于RedisSessionDAO实现缓存……
* 授权操作跟前边 Shiro 授权操作一致,可以把前边的CustRelam 中的授权直接拿过来
*
* @param principals
* @return
*/
@Override
protected AuthorizationInfo doGetAuthorizationInfo(PrincipalCollection principals) {
// do something , find DB or Cache
return null;
}
}
5)配置Shiro
在与 Pac4j 的配置中,与前边Springboot-shiro 的配置差不多,需要注意的是:
(1)Subject 是使用CAS的,所以需要手动注入 SubjectFactory 工厂类,用于创建Subject
(2)因为我们导入的包不是 spring-boot-shiro shiro整合springboot 的包,所以需要自己手
动配置过滤器,并指定过滤器名称为 “shiroFilter”类似于我们在shiro整合Web 时,在
web.xml配置的名称为“shiroFilter”的过滤器 proxyAuthenticationHandler
Shiro配置如下:
@Configuration
public class ShiroConfig {
//CAS服务地址
@Value("${cas.server.url:http://localhost:8080/cas}")
private String casServerUrl;
@Value("${cas.project.url:http://localhost:81}")
private String casProjectUrl;
@Value("${cas.clientName:test}")
private String clientName;
/**
* 主体工厂
* todo 注意:
* 这里认证时,Subject 需要使用 Pac4j 中的,所以这里需要手动注入
* Pac4j 包中的 SubjectFactory 用于生成 Subject
* 将 SubjectFactory 交给 SecurityManager 管理
* @return
*/
@Bean
public SubjectFactory subjectFactory(){
return new Pac4jSubjectFactory();
}
/**
* 安全管理器
* todo 注意: 还是使用shiro包下的 SecurityManager
*
* @param casRealm
* @param subjectFactory Subject 需要使用 Pac4j 中的SubjectFactory,用于生成 Subject
* @return
*/
@Bean
public SecurityManager securityManager(CasRealm casRealm,SubjectFactory subjectFactory){
DefaultWebSecurityManager securityManager = new DefaultWebSecurityManager();
securityManager.setRealm(casRealm);
securityManager.setSubjectFactory(subjectFactory);
return securityManager;
}
/**
* 配置核心过滤器
* 因为我们导入的包不是 spring-boot-shiro shiro整合springboot 的包,所以需要自己
* 手动配置过滤器,并指定过滤器名称为 “shiroFilter”
* 类似于我们在shiro整合Web 时,在web.xml配置的名称为“shiroFilter”的过滤器
*
* @return
*/
@Bean
public FilterRegistrationBean filterRegistrationBean(){
FilterRegistrationBean filterRegistration =new FilterRegistrationBean();
filterRegistration.setFilter(new DelegatingFilterProxy("shiroFilter"));
filterRegistration.addUrlPatterns("/*");
return filterRegistration;
}
/**
* shiroFilter核心配置
* @return
*/
@Bean
public ShiroFilterFactoryBean shiroFilter(SecurityManager securityManager, Config config){
ShiroFilterFactoryBean factoryBean = new ShiroFilterFactoryBean();
factoryBean.setSecurityManager(securityManager);
putFilterChain(factoryBean);
// 声明好pac4j提供的过滤器后
Map<String, Filter> filters = factoryBean.getFilters();
//1. 准备SecurityFilter
SecurityFilter securityFilter = new SecurityFilter();
securityFilter.setConfig(config);
securityFilter.setClients(clientName);
filters.put("security",securityFilter);
//2. 设置回调的拦截器
CallbackFilter callbackFilter = new CallbackFilter();
callbackFilter.setConfig(config);
callbackFilter.setDefaultUrl(casProjectUrl);
filters.put("callback",callbackFilter);
//3. 退出登录
LogoutFilter logoutFilter = new LogoutFilter();
logoutFilter.setConfig(config);
logoutFilter.setCentralLogout(true);
logoutFilter.setLocalLogout(true);
logoutFilter.setDefaultUrl(casProjectUrl + "/callback?client_name=" + clientName);
filters.put("logout",logoutFilter);
return factoryBean;
}
/**
* 构建过滤器链
* @param factoryBean
*/
private void putFilterChain(ShiroFilterFactoryBean factoryBean) {
Map<String,String> filterChain = new LinkedHashMap<>();
// 后面在声明好pac4j提供的过滤器后,需要重新设置!
filterChain.put("/test","security");
filterChain.put("/logout","logout");
filterChain.put("/callback","callback");
//采用 pac4j提供的过滤器进行校验
//filterChain.put("/**","security");
//放行,shiro的过滤器
filterChain.put("/**","anon");
factoryBean.setFilterChainDefinitionMap(filterChain);
}
}
6)自定义 CasClient
自定义需要继承类 CasClientorg.pac4j.cas.client.CasClient,并重写方法getRedirectAction,
用于处理退出后的重定向,让点击退出登录时,重定向到登录页面,示例代码如下:
public class CasClient extends org.pac4j.cas.client.CasClient {
public CasClient() {
super();
}
public CasClient(CasConfiguration configuration) {
super(configuration);
}
@Override
public RedirectAction getRedirectAction(final WebContext context) {
init();
AjaxRequestResolver ajaxRequestResolver = getAjaxRequestResolver();
RedirectActionBuilder redirectActionBuilder = getRedirectActionBuilder();
// it's an AJAX request -> appropriate action
if (ajaxRequestResolver.isAjax(context)) {
logger.info("AJAX request detected -> returning the appropriate action");
RedirectAction action = redirectActionBuilder.redirect(context);
cleanRequestedUrl(context);
return ajaxRequestResolver.buildAjaxResponse(action.getLocation(), context);
}
// authentication has already been tried -> unauthorized
final String attemptedAuth = (String) context.getSessionStore().get(context, getName() + ATTEMPTED_AUTHENTICATION_SUFFIX);
if (CommonHelper.isNotBlank(attemptedAuth)) {
cleanAttemptedAuthentication(context);
cleanRequestedUrl(context);
// 跑抛出异常,页面401,只修改这个位置!!
// throw HttpAction.unauthorized(context);
return redirectActionBuilder.redirect(context);
}
return redirectActionBuilder.redirect(context);
}
private void cleanRequestedUrl(final WebContext context) {
SessionStore<WebContext> sessionStore = context.getSessionStore();
if (sessionStore.get(context, Pac4jConstants.REQUESTED_URL) != null) {
sessionStore.set(context, Pac4jConstants.REQUESTED_URL, "");
}
}
private void cleanAttemptedAuthentication(final WebContext context) {
SessionStore<WebContext> sessionStore = context.getSessionStore();
if (sessionStore.get(context, getName() + ATTEMPTED_AUTHENTICATION_SUFFIX) != null) {
sessionStore.set(context, getName() + ATTEMPTED_AUTHENTICATION_SUFFIX, "");
}
}
}
7)配置Pac4j
@Configuration
public class Pac4jConfig {
//cas服务地址
@Value("${cas.server.url:http://localhost:8080/cas}")
private String casServerUrl;
//CA回调地址
@Value("${cas.project.url:http://localhost:81}")
private String casProjectUrl;
//客户端名称
@Value("${cas.clientName:test}")
private String clientName;
/**
* 核心Config
* CAS服务的配置信息
*
* @param casClient
* @return
*/
@Bean
public Config config(CasClient casClient){
Config config = new Config(casClient);
return config;
}
/**
* casClient,主要设置回调
* @param casConfiguration
* @return
*/
@Bean
public CasClient casClient(CasConfiguration casConfiguration){
CasClient casClient = new CasClient(casConfiguration);
// 设置CAS访问后的回调地址
casClient.setCallbackUrl(casProjectUrl + "/callback?client_name=" + clientName);
casClient.setName(clientName);
return casClient;
}
/**
* CAS服务地址
* @return
*/
@Bean
public CasConfiguration casConfiguration(){
CasConfiguration casConfiguration = new CasConfiguration();
// 设置CAS登录页面
casConfiguration.setLoginUrl(casServerUrl + "/login");
// 设置CAS协议
casConfiguration.setProtocol(CasProtocol.CAS20);
//设置请求前缀,必须加“/”
casConfiguration.setPrefixUrl(casServerUrl + "/");
//true表示允许代理
casConfiguration.setAcceptAnyProxy(true);
return casConfiguration;
}
}
8)测试