单点登录

登录态

大家都知道,HTTP是一个无状态的协议,我们站在服务器这一端,一个用户请求过来怎么判断他有没有登录呢?
在验证用户名和密码之后,我们可以发给客户端一个凭证(isLogin = true),如果请求中有这个凭证,那么他就是登陆之后的用户。 cookie和session的区别在于,凭证的存储位置。换言之,如果凭证存储在客户端,那就是cookie。如果凭证存储在服务端,那就是session。

cookie其实是HTTP头部的一个字段,本质上可以存储任何信息,早年用于实现登录态,所以有了一层别的含义——客户端存储。把凭证存储到cookie中,每次浏览器的请求会自动带上cookie里的凭证,方便服务端校验,就像下面这样:
cookie.png
客户端请求调用/login接口,服务端验证通过后给客户端颁发的登录凭证isLogin=true
但是这样面临的问题是:
用户本人可以通过修改document.cookie=”isLogin = true”伪造登陆凭证。

Session

session本意是指客户端与服务器的会话状态,由于凭证存储到了服务端,后来也把这些存在服务端的信息称为session。
现在服务器决定自己维护登录状态,仅发给客户端一个key,然后在自己维护一个key-value表,如果请求中有key,并且在表中可以找到对应的value,则视为合法:
session.png
sessionid是一个会话的key,客户端请求调用/login接口服务器端生成一个session,有一个sessionid和它对应。tomcat生成的sessionid叫做jsessionid。
session在访问tomcat服务器HttpServletRequest的getSession(true)的时候创建,tomcat的ManagerBase类提供创建sessionid的方法:随机数+时间+jvmid;
存储在服务器的内存中,tomcat的StandardManager类将session存储在内存中,也可以持久化到file,数据库,memcache,redis等。
服务端将seesion-id返回到客户端,客户端只保存sessionid到cookie中,而不会保存session,session销毁只能通过invalidate或超时,关掉浏览器并不会关闭session。
这样即使客户端自行修改了sessionID,在服务端那里没有对应的记录,也无法获取数据。
session是一个好的解决方案,但是他的问题是:如果存在多个服务器如负载均衡时,每个服务器的状态表必须同步,默认保存在内存中,负载均衡每分配到一个新机器就得重新登录。解决方法就是抽离出来统一管理,如使用Redis等服务。

Token

cookie方法不需要服务器存储,但是凭证容易被伪造,那有什么办法判断凭证是否伪造呢?
和HTTPS一样,我们可以使用签名的方式帮助服务器校验凭证。
JSON Web Token(简称JWT)是以JSON格式存储信息的Token,其结构图如下:
jwt.png
JWT由3部分构成:头部,负载和签名。
根据官网介绍

  1. 头部存储Token的类型和签名算法(上图中,类型是jwt,加密算法是HS256)
  2. 负载是Token要存储的信息(上图中,存储了用户姓名和昵称信息)
  3. 签名是由指定的算法,将转义后的头部和负载,加上密钥一同加密得到的。

最后将这三部分用.号连接,就可以得到了一个Token了。
使用JWT维护登陆态,服务器不再需要维护状态表,他仅给客户端发送一个加密的数据token,每次请求都带上这个加密的数据,再解密验证是否合法即可。由于是加密的数据,即使用户可以修改,命中几率也很小。
token.png
客户端如何存储token呢?

  1. 存在cookie中,虽然设置HttpOnly可以有效防止XSS攻击中token被窃取,但是也就意味着客户端无法获取token来设置CORS头部。
  2. 存在sessionStorage或者localStorage中,可以设置头部解决跨域资源共享问题,同时也可以防止CSRF,但是就需要考虑XSS的问题防止凭证泄露。

    单点登录

    举个场景,假设我们的系统被切割为N个部分:商城、论坛、直播、社交…… 如果用户每访问一个模块都要登录一次,那么用户将会疯掉, 为了优化用户体验,我们急需一套机制将这N个系统的认证授权互通共享,让用户在一个系统登录之后,便可以畅通无阻的访问其它所有系统。
    单点登录——就是为了解决这个问题而生!
    简而言之,单点登录可以做到:在多个互相信任的系统中,用户只需登录一次,就可以访问所有系统。

    共享cookie同步会话

首先我们分析一下多个系统之间,为什么无法同步登录状态?

  1. 前端的 Token 无法在多个系统下共享。
  2. 后端的 Session 无法在多个系统间共享。

所以单点登录第一招,就是对症下药:

  1. 使用 共享Cookie 来解决 Token 共享问题。
  2. 使用 Redis 来解决 Session 共享问题。

所谓共享Cookie,就是主域名Cookie在二级域名下的共享,举个例子:写在父域名stp.com下的Cookie,在s1.stp.com、s2.stp.com等子域名都是可以共享访问的。
cookie-sso.gif

URL重定向传播会话

如上,我们使用简单的步骤实现了同域下的单点登录,但这种模式有着一个不小的限制:

所有子系统的域名,必须同属一个父级域名

如果我们的子系统在完全不同的域名下,那么可以使用URL重定向传播会话。

url-sso.gif
1.用户在 子系统 点击 [登录] 按钮。
2.用户跳转到子系统登录接口 client1.com/sso/login,并携带 back参数 记录初始页面URL。
形如:http://client1.com/sso/login?back=xxx
3.子系统检测到此用户尚未登录(前端传过来的token,存储在cookie或者localstorage中,查看sso-Redis是否有token key,无则代表未登录),再次将其重定向至SSO认证中心,并携带redirect参数记录子系统的登录页URL。
形如:http://server.com/sso/auth?redirect=xxx?back=xxx
4.用户进入了 SSO认证中心 的登录页面,开始登录。http://server.com/sso/auth?redirect=xxx?back=xxx
5.用户 输入账号密码 并 登录成功,SSO认证中心再次将用户重定向至子系统的登录接口/sso/login,并携带ticket码参数。
形如:http://client1.com/sso/login?back=xxx&ticket=xxxxxxxxx
6.子系统根据 ticket码 从 SSO-Redis 中获取账号id,并在子系统登录此账号会话。
7.子系统将用户再次重定向至最初始的 back 页面。

用户登录client2.com/sso/login,在进行到第5步时,因为server.com cookie保存了token.所以不用再输入。

前后端分离ticket登录

前端react:

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
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import axios from "axios";

const instance = axios.create({
timeout: 20000,
})

instance.defaults.headers.post['Content-Type'] = 'application/json'

/**
* 添加请求拦截器
*/
instance.interceptors.request.use(config => {
console.log("config:", config)
let token = localStorage.getItem('satoken')
console.log("token:", token)
if (token) {
config.headers['satoken'] = token
}
return config
}, error => {
return Promise.reject(error)
})

/**
* 从url中查询到指定名称的参数值
*/
function getParam(name, defaultValue) {
let query = window.location.search.substring(1);
let vars = query.split("&")
for(let i = 0;i<vars.length;i++){
let pair = vars[i].split("=");
if(pair[0] == name){
return pair[1];
}
}
return (defaultValue == undefined ? null :defaultValue);
}


export const doLogin = (onlyTicket)=>{
let baseUrl = "http://server.zzmd.tech:9777"
let back = getParam("back", "/")
let ticket = getParam("ticket");
if(ticket){
return post(baseUrl+'/sso/doLoginByTicket',{ticket:ticket}).then(response=>{
localStorage.setItem("satoken", response);
location.href= decodeURIComponent(back)
})
}else{
if(!onlyTicket){
//重定向到登录中心
return post(baseUrl+'/sso/getSsoAuthUrl',{clientLoginUrl:location.href, back:location.href}).then(response=>{
console.log("auth url:",response)
location.href = response;
})
}
}
}

/**
* 添加响应拦截器
*/
instance.interceptors.response.use(response => {
console.log('response:', response)
if (response.data.success) {
console.log('请求成功:', response)
return Promise.resolve(response.data.data)

} else {
console.log('code:', response.data.code, response.data.code == 401)
if (response.data.code == 401) {
doLogin(false)
} else {
return Promise.reject(response.data.msg)
}

}
}, error => {
return Promise.reject('请求失败, 请刷新重试')
})

/**
* 统一封装Get请求
*/
export const get = (url, params = {}, config = {}) => {
return new Promise((resolve, reject) => {
instance({
method: 'get',
url,
params,
...config
}).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
}

/**
* 统一封装Post请求
*/
export const post = (url, params = {}, config = {}) => {
return new Promise((resolve, reject) => {
instance({
method: 'post',
url,
params,
...config
}).then(response => {
resolve(response)
}).catch(error => {
reject(error)
})
})
}



Http请求获取会话

我们先来分析一下,当后端不使用共享 Redis 时,会对架构产生哪些影响:

  1. Client 端无法直连 Redis 校验 ticket,取出账号id。
  2. Client 端无法与 Server 端共用一套会话,需要自行维护子会话。
  3. 由于不是一套会话,所以无法“一次注销,全端下线”,需要额外编写代码完成单点注销。

解决方案:校验ticket不使用共享redis,可以使用http或者rpc调用来进行校验。

cors跨域

当客户端与服务端不在同一域中,需要进入跨域处理。
CORS 是一个 W3C 标准,全称叫做”跨域资源共享”(Cross-Origin resource sharing); 在详细介绍 CORS 之前先简单介绍下什么是同源政策,这样才能了解到 CORS 的由来|必要性。

浏览器同源策略

“同源政策”是浏览器安全的基石,目前所有浏览器都实行这个政策
所谓”同源”,是指以下三个相同:

  • 协议相同
  • 域名相同
  • 端口相同

举个例子:

当前网址 被请求页面地址 是否跨域(不同源) 原因
http://byj.zzmd.tech/page.html http://byj.zzmd.tech/main.html 同协议(http)、同域名(byj.zzmd.tech)、同端口(80)
http://byj.zzmd.tech/page.html https://byj.zzmd.tech/other.html 协议不同(http 与 https)
http://byj.zzmd.tech/page.html http://byj.zzmd.com/page.html 域名不同,一个tech域名,一个com域名
http://byj.zzmd.tech/page.html http://byj.zzmd.tech:8090/other.html 端口不同(80 与 8090)

CORS 两类请求

浏览器将 CORS 请求分为两类:简单请求非简单请求

简单请求

只要同时满足以下两大条件,就属于简单请求:

  • 请求方法是以下三种类型之一
    • HEAD
    • GET
    • POST
  • HTTP 头信息不超出以下几种字段
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type: 只限于 application/x-www-form-urlencoded、multipart/form-data、text/plain 三者之一

凡是不同时满足以上两个条件的就属于非简单请求。
如下就是一个 CORS 简单请求:
cors-simple.png

非简单请求

非简单请求是那种对服务器有特殊要求的请求,比如请求方法是 PUT 或 DELETE,或者 Content-Type 字段类型是 application/json。
非简单请求的 CORS 请求的一大特点,就是会在正式通信前增加一次 HTTP 查询请求,称为“预检”请求(Preflight request)。该”预检”请求的方法为 OPTIONS,”预检”请求的使用,可以避免跨域请求对服务器的用户数据产生未预期的影响。
如下是一个需要执行预检请求的 HTTP 请求:
cors-not-simple.png

Spring Boot 解决跨域问题

通过 Filter 过滤器手动设置响应头

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
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

@Component
@Slf4j
@WebFilter(urlPatterns = {"/*"}, filterName = "corsFilter")
public class CorsFilter implements Filter {
@Override
public void init(FilterConfig filterConfig) throws ServletException {
log.info("启动跨域过滤器");
}

@Override
public void doFilter(ServletRequest request, ServletResponse resp, FilterChain chain) throws IOException, ServletException {
HttpServletResponse response = (HttpServletResponse) resp;
// 手动设置响应头解决跨域访问
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "POST, PUT, GET, OPTIONS, DELETE");
// 设置过期时间
response.setHeader("Access-Control-Max-Age", "86400");
response.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, uuid");
// 支持 HTTP 1.1
response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
// 支持 HTTP 1.0. response.setHeader("Expires", "0");
response.setHeader("Pragma", "no-cache");
// 编码
response.setCharacterEncoding("UTF-8");
chain.doFilter(request, resp);
}

@Override
public void destroy() {
log.info("销毁跨域过滤器");
}
}

使用 @CrossOrigin 注解(局部跨域)

@CrossOrigin 注解源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface CrossOrigin {

@AliasFor("origins")
String[] value() default {};

@AliasFor("value")
String[] origins() default {};

String[] allowedHeaders() default {};

String[] exposedHeaders() default {};

RequestMethod[] methods() default {};

String allowCredentials() default "";

long maxAge() default -1L;
}

使用 @CrossOrigin 注解:

1
2
3
4
5
6
@CrossOrigin(origins = "*", allowedHeaders = "*", maxAge = 86400)
@PostMapping("/login")
public String login(@RequestBody User user) {
TODO..
}

不过通过 @CrossOrigin 注解的源代码注定了它只能针对单个接口进行跨域配置,即局部跨域。虽然它比如上的 Filter 过滤器更简便,但这明显不是我们想要的,实际开发中也很少使用该注解。

实现 WebMvcConfigurer 接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
// 表明允许哪些域访问, 简单点可为 *
.allowedOrigins("http://localhost:3000")
.allowedHeaders("*")
.allowedMethods("*")
// allowCredentials(true): 表示附带身份凭证
// 一旦使用 allowCredentials(true) 方法,则 allowedOrigins("*") 需要指明特定的域,而不能是 *
.allowCredentials(true)
.maxAge(86400);
}
}

以上这种方式在没有定义拦截器(Interceptor)的时候,使用起来一切正常,但如果你有一个全局的拦截器,比如检测用户登录的拦截器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class AuthenticationInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object object) throws Exception {
// 从 http 请求头中取出 token
String token = httpServletRequest.getHeader("token");
// 检查是否登录
if (token == null) {
throw new InvalidTokenException(ResultCode.INVALID_TOKEN.getCode(), "登录信息已过期,请重新登录");
}
return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {

}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {

}
}

当自定义拦截器返回 true 时,一切正常,但是当拦截器抛出异常(或者返回 false)时,后续的 CORS 配置将不会生效。

注入 CorsFilter 过滤器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Configuration
public class CorsFilterConfiguration {

@Bean
public CorsFilter corsFilter() {
// 创建 CorsConfiguration 对象后添加配置
CorsConfiguration corsConfiguration = new CorsConfiguration();
// 设置放行哪些原始域
corsConfiguration.addAllowedOrigin("*");
// 放行哪些原始请求头部信息
corsConfiguration.addAllowedHeader("*");
// 放行哪些请求方法
corsConfiguration.addAllowedMethod("*");

// 添加映射路径
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);

return new CorsFilter(source);
}

}

为什么过滤器可以避免冲突而拦截器不行呢?
因为过滤器依赖于 Servlet 容器,基于函数回调,它可以对几乎所有请求进行过滤。而拦截器是依赖于 Web 框架(如 Spring MVC 框架),基于反射通过 AOP 的方式实现的。

参考文章

前端应该懂的登录态Cookie、Session、Token
教你四招实现 CORS 跨域资源共享


单点登录
http://byj.zzmd.tech/2023/05/24/单点登录/
作者
白玉京
发布于
2023年5月24日
许可协议