单点登录

单点登录

第一节 背景

1.1 HTTP无状态协议

Web应用采用B/S架构,HTTP作为通信协议。HTTP是无状态协议,所以浏览器的每一次请求,服务器都会做独立处理,请求之间没有任何关联。

所以任何用户都可以通过浏览器来访问服务器资源,为了提高服务器安全性,必然要对访问者进行鉴别,响应合法请求,拦截非法请求。

而HTTP协议无状态,就需要浏览器和服务器共同维护对话的状态,即会话机制。

1.2 会话机制

浏览器首次请求服务器,服务器会创建一个会话,并将生成的会话ID通过响应返回给浏览器,浏览器收到后存储在客户端,并在之后的请求中携带会话ID,服务器就可以根据会话ID来判断请求者的身份。一般浏览器通过cookie来存储会话ID,发送HTTP请求自动携带cookie信息。如Tomcat应用服务器,会生成jsessionid的cookie信息,即会话ID。

1.3 用户登录状态

当浏览器和服务器之间实现了会话机制,就可以维护用户的登陆状态,通过引入登陆机制,密码验证等技术,来保证通信的安全性。

1.4 多系统

Web网站系统由单系统发展为多系统组成的应用群,用户不应该一次次的登陆每一个子系统,系统本身复杂性越来越高,但对用户应该保持其简洁性,所以单点登陆的实现有其必要性。但单系统的登陆方案对于多系统来讲并不是很合适,单系统登陆的核心是cookie,而cookie会限制域,浏览器发出请求时只会携带对应域的cookie,所以最初的多系统登陆采用了统一顶级域名的放啊,将cookie域设置为最顶层的域名。但共享cookie有其弊端,首先所有系统域名要统一,然后各系统Web技术要相同,会话ID要统一管理,共享cookie无法实现跨语言平台,还有cookie本身不安全。


第二节 单点登陆

单点登陆,即Single Sign On,简称SSO。实现了登陆一次即可获取系统集的授权,跨系统无需再次登陆。

2.1 登陆

SSO需要一个独立的认证中心,认证中心担负了系统群的登陆模块,其他系统只需获取认证中心的间接授权。间接授权通过令牌Token来实现,当SSO认证中心完成用户登陆,创建全局会话和授权Token,在之后的跳转过程中将Token发送给子系统,子系统获取Token可以凭此创建局部会话,局部会话的登陆方式和单系统相同。

单点登陆授权验证流程

用户分别和SSO认证中心和各子系统建立会话,与认证中心创建全局会话,与各子系统创建局部会话。

观察淘宝,京东等网站的登陆过程,跳转URL和参数。

2.2 注销

子系统注销,会向认证中心发送注销请求,认证中心检查Token合法后,销毁全局会话,取出所有用此令牌注册的系统地址,向所有系统发送注销请求,子系统收到请求后销毁局部会话,最后SSO认证中心引导用户到登陆界面。

2.3 系统间通信

认证中心和子系统需要相互通信来交换令牌,校验令牌以及发起注销请求,所以子系统需要集成SSO的客户端,而SSO认证中心则部署服务端,单点登陆过程即客户端和服务端的通信过程。sso认证中心与sso客户端通信方式有多种,这里以简单好用的httpClient为例,web service、rpc、restful api都可以


第三节 单点登陆实现

只是简要介绍下基于java的实现过程,不提供完整源码,明白了原理,我相信你们可以自己实现。sso采用客户端/服务端架构,我们先看sso-client与sso-server要实现的功能(下面:sso认证中心=sso-server)

客户端sso-client要实现的功能

  1. 拦截子系统未登录用户请求,跳转至sso认证中心
  2. 接收并存储sso认证中心发送的令牌
  3. 与sso-server通信,校验令牌的有效性
  4. 建立局部会话
  5. 拦截用户注销请求,向sso认证中心发送注销请求
  6. 接收sso认证中心发出的注销请求,销毁局部会话

服务端sso-server要实现的功能

  1. 验证用户的登录信息
  2. 创建全局会话
  3. 创建授权令牌
  4. 与sso-client通信发送令牌
  5. 校验sso-client令牌有效性
  6. 系统注册
  7. 接收sso-client注销请求,注销所有会话

3.1 sso-client拦截未登录请求

java拦截请求的方式有servlet、filter、listener三种方式,我们采用filter。在sso-client中新建LoginFilter.java类并实现Filter接口,在doFilter()方法中加入对未登录用户的拦截

1
2
3
4
5
6
7
8
9
10
11
12
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
HttpSession session = req.getSession();

if (session.getAttribute("isLogin")) {
chain.doFilter(request, response);
return;
}
//跳转至sso认证中心
res.sendRedirect("sso-server-url-with-system-url");
}

3.2 sso-server拦截未登录请求

拦截从sso-client跳转至sso认证中心的未登录请求,跳转至登录页面,这个过程与sso-client完全一样

3.3 sso-server验证用户登录信息

用户在登录页面输入用户名密码,请求登录,sso认证中心校验用户信息,校验成功,将会话状态标记为“已登录”

1
2
3
4
5
6
@RequestMapping("/login")
public String login(String username, String password, HttpServletRequest req) {
this.checkLoginInfo(username, password);
req.getSession().setAttribute("isLogin", true);
return "success";
}

3.4 sso-server创建授权令牌

授权令牌是一串随机字符,以什么样的方式生成都没有关系,只要不重复、不易伪造即可,下面是一个例子:

1
String token = UUID.randomUUID().toString();

3.5 sso-client取得令牌并校验

sso认证中心登录后,跳转回子系统并附上令牌,子系统(sso-client)取得令牌,然后去sso认证中心校验,在 LoginFilter.javadoFilter() 中添加几行:

1
2
3
4
5
6
7
8
9
10
11
// 请求附带token参数
String token = req.getParameter("token");
if (token != null) {
// 去sso认证中心校验token
boolean verifyResult = this.verify("sso-server-verify-url", token);
if (!verifyResult) {
res.sendRedirect("sso-server-url");
return;
}
chain.doFilter(request, response);
}

verify() 方法使用httpClient实现,这里仅简略介绍,httpClient详细使用方法请参考官方文档

1
2
HttpPost httpPost = new HttpPost("sso-server-verify-url-with-token");
HttpResponse httpResponse = httpClient.execute(httpPost);

3.6 sso-server接收并处理校验令牌请求

用户在sso认证中心登录成功后,sso-server创建授权令牌并存储该令牌,所以,sso-server对令牌的校验就是去查找这个令牌是否存在以及是否过期,令牌校验成功后sso-server将发送校验请求的系统注册到sso认证中心(就是存储起来的意思)

令牌与注册系统地址通常存储在key-value数据库(如redis)中,redis可以为key设置有效时间也就是令牌的有效期。redis运行在内存中,速度非常快,正好sso-server不需要持久化任何数据。

令牌与注册系统地址可以用下图描述的结构存储在redis中,可能你会问,为什么要存储这些系统的地址?如果不存储,注销的时候就麻烦了,用户向sso认证中心提交注销请求,sso认证中心注销全局会话,但不知道哪些系统用此全局会话建立了自己的局部会话,也不知道要向哪些子系统发送注销请求注销局部会话

3.7 sso-client校验令牌成功创建局部会话

令牌校验成功后,sso-client将当前局部会话标记为“已登录”,修改LoginFilter.java,添加几行

1
2
3
if (verifyResult) {
session.setAttribute("isLogin", true);
}

sso-client还需将当前会话id与令牌绑定,表示这个会话的登录状态与令牌相关,此关系可以用java的hashmap保存,保存的数据用来处理sso认证中心发来的注销请求。

3.8 注销过程

用户向子系统发送带有“logout”参数的请求(注销请求),sso-client拦截器拦截该请求,向sso认证中心发起注销请求

1
2
3
4
String logout = req.getParameter("logout");
if (logout != null) {
this.ssoServer.logout(token);
}

sso认证中心也用同样的方式识别出sso-client的请求是注销请求(带有“logout”参数),sso认证中心注销全局会话

1
2
3
4
5
6
7
8
@RequestMapping("/logout")
public String logout(HttpServletRequest req) {
HttpSession session = req.getSession();
if (session != null) {
session.invalidate();//触发LogoutListener
}
return "redirect:/";
}

sso认证中心有一个全局会话的监听器,一旦全局会话注销,将通知所有注册系统注销

1
2
3
4
5
6
7
8
public class LogoutListener implements HttpSessionListener {
@Override
public void sessionCreated(HttpSessionEvent event) {}
@Override
public void sessionDestroyed(HttpSessionEvent event) {
//通过httpClient向所有注册系统发送注销请求
}
}

第四节 CAS

4.1 基于CAS+Spring Boot实现客户端

4.1.1 新建Spring Boot项目

4.1.2 pom.xml添加依赖

1
2
3
4
5
6
<!-- https://mvnrepository.com/artifact/org.jasig.cas.client/cas-client-core -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.2.1</version>
</dependency>

4.1.3 application.properties添加配置

1
2
3
casServerUrlPrefix=https://xxxx/authserver/
casServerLoginUrl=https://xxxx/authserver/login
serverName=http://xxxx:8080/

4.1.4 添加配置类CasConfig.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
@Configuration
public class CasConfig {
@Value("${casServerUrlPrefix}")
private String casServerUrlPrefix;
@Value("${casServerLoginUrl}")
private String casServerLoginUrl;
@Value("${serverName}")
private String serverName;

/**
* 用于单点退出,该监听器用于实现单点登出功能
* @return
*/
@Bean
public SingleSignOutHttpSessionListener singleSignOutHttpSessionListener(){
return new SingleSignOutHttpSessionListener();
}

/**
* 该过滤器用于实现单点登出功能
* @return
*/
@Bean
public FilterRegistrationBean singleSignOutFilter() {
FilterRegistrationBean singleSignOutFilter = new FilterRegistrationBean();
singleSignOutFilter.setFilter(new SingleSignOutFilter());
singleSignOutFilter.setName("singleSignOutFilter");
singleSignOutFilter.addUrlPatterns("/*");
Map<String, String> initParameters = new HashMap<>();
initParameters.put("casServerLoginUrl", casServerLoginUrl);
initParameters.put("serverName", serverName);
singleSignOutFilter.setInitParameters(initParameters);
return singleSignOutFilter;
}
/**
* 注册authenticationFilter,该过滤器负责用户的认证工作
* @return
*/
@Bean
public FilterRegistrationBean authenticationFilter() {
FilterRegistrationBean authenticationFilter = new FilterRegistrationBean();
authenticationFilter.setFilter(new AuthenticationFilter());
authenticationFilter.setName("authenticationFilter");
authenticationFilter.addUrlPatterns("/*");
Map<String, String> initParameters = new HashMap<>();
initParameters.put("casServerLoginUrl", casServerLoginUrl);
initParameters.put("serverName", serverName);
authenticationFilter.setInitParameters(initParameters);
return authenticationFilter;
}

/**
* 该过滤器负责对Ticket的校验工作
* @return
*/
@Bean
public FilterRegistrationBean validationFilter(){
FilterRegistrationBean validationFilter = new FilterRegistrationBean();
validationFilter.setFilter(new Cas20ProxyReceivingTicketValidationFilter());
validationFilter.setName("validationFilter");
validationFilter.addUrlPatterns("/*");
Map<String, String> initParameters = new HashMap<>();
initParameters.put("casServerUrlPrefix", casServerUrlPrefix);
initParameters.put("serverName", serverName);
initParameters.put("encoding", "UTF-8");
validationFilter.setInitParameters(initParameters);
return validationFilter;
}

/**
* 该过滤器负责实现HttpServletRequest请求的包裹,比如允许开发者通过HttpServletRequest的getRemoteUser()方法获得SSO登录用户的登录名
* @return
*/
@Bean
public FilterRegistrationBean httpServletRequestWrapperFilter() {
FilterRegistrationBean httpServletRequestWrapperFilter = new FilterRegistrationBean();
httpServletRequestWrapperFilter.setFilter(new HttpServletRequestWrapperFilter());
httpServletRequestWrapperFilter.setName("httpServletRequestWrapperFilter");
httpServletRequestWrapperFilter.addUrlPatterns("/*");
Map<String, String> initParameters = new HashMap<>();
initParameters.put("casServerLoginUrl", casServerLoginUrl);
initParameters.put("serverName", serverName);
httpServletRequestWrapperFilter.setInitParameters(initParameters);
return httpServletRequestWrapperFilter;
}
}

4.1.5 启动项目,测试并观察结果

4.1.6 获取用户信息

4.1.7 退出登录

4.1.8 获取Server SSL证书

4.2 基于CAS+Pa4j+SpringBoot+Shiro实现单点登录

基于CAS+Pa4j+Spring Boot+Shiro实现单点登录


参考:

🔗 现在用的比较多的单点登录技术是什么

🔗 springboot+shiro+session+redis+ngnix共享