SSO实战
# SSO 实战
# 什么是 SSO
SSO(Single sign-on,单点登录)是一种会话和用户身份验证服务,它允许用户仅使用一组凭据就安全地访问多个应用程序和服务。
- 同一体系的不同系统,登陆一次就行,共用
session
或cookie
- 单点登录就是在一个多系统共存的环境下,用户在一处登录后,就不用在其他系统中登录,也就是用户的一次登录能得到其他所有系统的信任。
# 为什么要使用 SSO
- 同一登录流程。实现用一套用户名和密码登录多个系统。
- 同步登录状态。如果登录任意某个系统,其他系统也是登录状态,登出任意某个系统,其他系统也都是登出状态。
# 怎么实现 SSO
# 方案1 共享父域的多系统
子域间 SSO 的实现方式一:共享 cookie 和 session。共享父域的子系统跨域设置cookie
的domain
为父域,如a.demo.com
和b.demo.com
共享父域。cookie
的domain
设置为demo.com
,那么a.demo.com
和b.demo.com
都可以使用这个cookie
。
缺点:
- 只能用于子域名形态
- 依赖于cookie 的 domain,不太安全
- 跳转地址依赖于前端传递的话,会被篡改
- 攻击者可以假装自己是一个子域名
- SSO server 是性能瓶颈。
SSO server 一定要校验跳过来的源头:
- client id 之类:源头带上一个 SSO 服务器下发的身份认证
# 过程
- 用户通过浏览器向 a server 请求资源,a server 发现用户没有在该浏览器登录,302 跳转至 sso server;
- sso server 返回一个登录页面给浏览器,让用户登录;
- 用户提交登录信息给 sso server;
- sso server 验证登录信息通过后,生成和保存
session
,返回响应,并带上 token,浏览器设置 token(例如 cookie); - 用户再次向 a server 请求资源,请求带上 token,a server 拿着 token 向 sso server 校验;
- token 通过校验后,a server 返回用户请求的资源;
- 用户使用同一个浏览器访问 b server,这个请求也会带上刚刚那个 token,b server 同样会拿 token 向 sso server 校验;
- token 通过校验后,b server 返回对应的资源。
SSO Server 作为单点登录,为多个系统提供登录凭证,同时充当每个系统的认证服务器。业务系统每收到一次请求都需要向 SSO Server 校验凭证,token 在多个系统是属于共享的。
# 代码实现
项目结构:
│ go.sum
│ sso_server.go
│
├─server_a
│ server.go
│
├─server_b
│ server.go
│
└─template
login.gohtml
2
3
4
5
6
7
8
9
10
11
sso_server.go
package main import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/patrickmn/go-cache" "net/http" "time" ) // 这里的 session 使用内存 cache,真实环境使用 Redis var ( sessions = cache.New(time.Minute*15, time.Second) ) // whiteList 白名单 var whiteList = map[string]string{ "server_a": "http://aaa.demo.com:8081/profile", "server_b": "http://bbb.demo.com:8082/profile", } func main() { r := gin.Default() r.LoadHTMLGlob("template/*.gohtml") r.GET("/login", func(ctx *gin.Context) { clientId, _ := ctx.GetQuery("client_id") ctx.HTML(http.StatusOK, "login.gohtml", map[string]string{"ClientId": clientId}) }) r.POST("/login", Login) r.POST("/token/validate", TokenValidate) // Listen and serve on 0.0.0.0:8000 err := r.Run(":8000") if err != nil { return } } // Login 用于用户登录 func Login(ctx *gin.Context) { email := ctx.PostForm("email") password := ctx.PostForm("password") clientId := ctx.PostForm("client_id") if email != "abc@demo.com" || password != "123" { ctx.String(http.StatusBadRequest, "%s", "用户账号名密码不对") return } // 认为登录成功 // 要防止 token 被盗走,不能使用 uuid id := uuid.New().String() ctx.SetCookie("sessid", id, 15*60, "", "demo.com", false, false) sessions.Set(id, nil, time.Minute*15) http.Redirect(ctx.Writer, ctx.Request, whiteList[clientId], 302) } // TokenValidate 验证 token func TokenValidate(ctx *gin.Context) { token, ok := ctx.GetQuery("token") if !ok { ctx.String(http.StatusBadRequest, "%s", "拿不到 token") return } // 验证 token _, ok = sessions.Get(token) if !ok { ctx.String(http.StatusBadRequest, "%s", "没登录") return } // 验证通过 _, _ = ctx.Writer.WriteString("ok") }
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
77whiteList
:白名单列表,用于重定向返回原始服务 URLLogin
:用户的登录逻辑TokenValidate
:用于校验 token 的有效性
server_a/server.go
package main import ( "errors" "fmt" "github.com/gin-gonic/gin" "io" "log" "net/http" ) // 这里的 session 使用内存 cache,真实环境使用 Redis var ( //sessions = cache.New(time.Minute*15, time.Second) ) type User struct { Name string Password string Age int } func main() { r := gin.Default() r.Use(LoginMiddleware()) r.GET("/profile", func(ctx *gin.Context) { ctx.JSON(200, &User{ Name: "Tom", Age: 18, }) }) // Listen and serve on 0.0.0.0:8081 err := r.Run(":8081") if err != nil { return } } func LoginMiddleware() gin.HandlerFunc { return func(ctx *gin.Context) { // 如果是登录 URL ,直接通过 if ctx.Request.URL.Path == "/login" { ctx.Next() return } redirect := fmt.Sprintf("http://sso.demo.com:8000/login?client_id=server_a") ssid, err := ctx.Cookie("sessid") log.Println(ssid) // 拿不到 cookie 则重定向到 sso server 进行登录 if err != nil { http.Redirect(ctx.Writer, ctx.Request, redirect, 302) return } // 验证 session,这里使用共享 session req, err := http.NewRequest(http.MethodPost, "http://sso.demo.com:8000/token/validate?token="+ssid, nil) resp, err := (&http.Client{}).Do(req) if err != nil { _ = ctx.Error(errors.New("解析 token 失败")) return } okString, _ := io.ReadAll(resp.Body) if string(okString) != "ok" { // 你没有登录 http.Redirect(ctx.Writer, ctx.Request, redirect, 302) return } log.Println(ssid) // 这边就是登录了 ctx.Next() } }
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
75server_a/server.go
模拟了子系统 a 的业务逻辑。LoginMiddleware
为登录认证的中间件。client_id
是 SSO 服务器下发的身份认证。
server_b/server.go
package main import ( "errors" "fmt" "github.com/gin-gonic/gin" "io" "log" "net/http" ) // 这里的 session 使用内存 cache,真实环境使用 Redis var ( //sessions = cache.New(time.Minute*15, time.Second) ) type User struct { Name string Password string Age int } func main() { r := gin.Default() r.Use(LoginMiddleware()) r.GET("/profile", func(ctx *gin.Context) { ctx.JSON(200, &User{ Name: "Jerry", Age: 20, }) }) // Listen and serve on 0.0.0.0:8082 err := r.Run(":8082") if err != nil { return } } func LoginMiddleware() gin.HandlerFunc { return func(ctx *gin.Context) { // 如果是登录 URL ,直接通过 if ctx.Request.URL.Path == "/login" { ctx.Next() return } redirect := fmt.Sprintf("http://sso.demo.com:8000/login?client_id=server_b") ssid, err := ctx.Cookie("sessid") // 拿不到 cookie 则重定向到 sso server 进行登录 if err != nil { http.Redirect(ctx.Writer, ctx.Request, redirect, 302) return } // 验证 session,这里使用共享 session req, err := http.NewRequest(http.MethodPost, "http://sso.demo.com:8000/token/validate?token="+ssid, nil) resp, err := (&http.Client{}).Do(req) if err != nil { _ = ctx.Error(errors.New("解析 token 失败")) return } okString, _ := io.ReadAll(resp.Body) if string(okString) != "ok" { // 你没有登录 http.Redirect(ctx.Writer, ctx.Request, redirect, 302) return } log.Println(ssid) // 这边就是登录了 ctx.Next() } }
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
74server_b/server.go
与server_b/server.go
除了端口和client_id
不一致,其余几乎一样。template/login.gohtml
<html> <body> <form action="/login" method="post"> 邮箱:<input name="email" type="email" placeholder="邮箱"> 密码:<input name="password" type="password"> 重定向:<input name="client_id" hidden="true" type="password" value="{{.ClientId}}"> <button type="submit">登录</button> </form> </body> </html>
1
2
3
4
5
6
7
8
9
10SSO server 返回的登录页面。
# 演示
在hosts
设置好域名:
127.0.0.1 aaa.demo.com
127.0.0.1 bbb.demo.com
127.0.0.1 sso.demo.com
2
3
在浏览器输入 URL:
http://aaa.demo.com:8081/profile
,并按回车。由于 a server 还没有登录,所以会重定向至 sso server 的登录页面。
输入邮箱和密码并点击“登录”按钮,向 sso server 提交登录信息;sso server 验证通过后,根据
client_id
重定向至 a 服务的http://aaa.demo.com:8081/profile
,并设置了 cookie。浏览器带上 cookie 请求
http://aaa.demo.com:8081/profile
,a server 响应对应的资源。现在在同一个浏览器访问 b server 的 URL:
http://bbb.demo.com:8082/profile
;此时不需要登录也可以访问成功了。
虽然共享父域的子系统跨域实现单一登录,但是跨域的就不行了。
# 方案2 跨域的多系统
跨域方案二:核心是解析 token 之后,会本地种一个 session(cookie)
登出了通知其他服务器,其它服务器要清理掉自己的 session:
- 回调:可以是回调一个 http 接口,可以是一个 RPC 接口
- 消息队列:
- 共享 session:只需要删掉session 就可以
- 轮询 SSO
token 是一次性的,解析过一次,就不能再解析了
session 是否需要共享:
- 可以共享
- 可以不共享
单一登录:你只需要在 SSO server 上校验一下上次登录的来源,和这一次登录的来源,是不是同一个;把上一次登录的,全部踢掉
登出要不要通知其它服务,是取决于你的业务的:
- 如果要是关联性不强的业务,是可以不必要同步登出的
- 关联性强的业务,是一起登出的
# 过程
- 用户通过浏览器向 a server 请求资源,a server 发现用户没有在该浏览器登录,302 跳转至 sso server;
- sso server 返回一个登录页面给浏览器,让用户登录;
- 用户提交登录信息给 sso server;
- sso server 验证登录信息通过后,生成和保存
session
,返回响应,并带上 token(a server 一次性校验的 token),浏览器设置 sso 的 cookie; - 浏览器访问 a server 的
/token
路径,请求带上 token,a server 拿着 token 向 sso server 校验; - token 通过校验后,a server 自己也会生成
session
和设置cookie
; - 带上 a 的 cookie 向a server 请求资源,此时 a server 返回用户请求的资源;
- 通过同一浏览器访问 b server 的资源也一样,只不过不需要再次登录,即少了2和3步骤。
# 代码实现
项目结构:
│ sso_server.go
│
├─encrypt
│ encrypt.go
│
├─server_a
│ server.go
│
├─server_b
│ server.go
│
└─template
login.gohtml
2
3
4
5
6
7
8
9
10
11
12
13
sso_server.go
package main import ( "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/patrickmn/go-cache" "io" "net/http" "sso/encrypt" "time" ) // 这里的 session 使用内存 cache,真实环境使用 Redis var ( sessions = cache.New(time.Minute*15, time.Second) ) // whiteList 白名单 var whiteList = map[string]string{ "server_a": "http://aaa.com:8081/token", "server_b": "http://bbb.com:8082/token", } func main() { r := gin.Default() r.LoadHTMLGlob("template/*.gohtml") r.GET("/login", func(ctx *gin.Context) { // 验证是否已经登录过 // 获取 sso token cookie, err := ctx.Cookie("token") clientId, _ := ctx.GetQuery("client_id") if err != nil { ctx.HTML(http.StatusOK, "login.gohtml", map[string]string{"ClientId": clientId}) return } // 如果 client id 和已有 session 归属不同的主体,那么还是要重新登陆 // 验证 sso token _, ok := sessions.Get(cookie) if !ok { ctx.HTML(http.StatusOK, "login.gohtml", map[string]string{"ClientId": clientId}) return } // 直接颁发 token token := uuid.New().String() sessions.Set(clientId, token, time.Minute) http.Redirect(ctx.Writer, ctx.Request, whiteList[clientId]+"?token="+token, 302) }) r.POST("/login", Login) r.POST("/token/validate", TokenValidate) // Listen and serve on 0.0.0.0:8000 err := r.Run(":8000") if err != nil { return } } // Login 用于用户登录 func Login(ctx *gin.Context) { email := ctx.PostForm("email") password := ctx.PostForm("password") clientId := ctx.PostForm("client_id") if email != "abc@demo.com" || password != "123" { ctx.String(http.StatusBadRequest, "%s", "用户账号名密码不对") return } // 认为登录成功 // 要防止 token 被盗走,不能使用 uuid id := uuid.New().String() ctx.SetCookie("token", id, 15*60, "", "", false, false) sessions.Set(id, nil, time.Minute*15) // 生成用于一次性校验的 token token := uuid.New().String() sessions.Set(clientId, token, time.Minute) http.Redirect(ctx.Writer, ctx.Request, whiteList[clientId]+"?token="+token, 302) } // TokenValidate 验证 token func TokenValidate(ctx *gin.Context) { token, ok := ctx.GetQuery("token") if !ok { ctx.String(http.StatusBadRequest, "%s", "拿不到 token") return } signature, err := io.ReadAll(ctx.Request.Body) if err != nil { ctx.String(http.StatusBadRequest, "%s", "拿不到签名") return } // 解析签名获取 client id clientId, _ := encrypt.Decrypt(signature) // 验证 token val, ok := sessions.Get(token) if !ok { // 可能过期了,或者说这个 client id 根本没有过来登录 ctx.String(http.StatusBadRequest, "%s", "没登录") return } if token != val { ctx.String(http.StatusBadRequest, "%s", "token 不对") return } // 只能使用一次 sessions.Delete(clientId) // 返回 access token + refresh token ctx.JSON(http.StatusOK, Tokens{ AccessToken: uuid.New().String(), RefreshToken: uuid.New().String(), }) } // Tokens 长短 token type Tokens struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` }
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
120
121
122
123
124
125
126
127- sso 在返回登录页面前新增了登录校验,如果已经登录过了就不再返回登录页面,而是生成一次性校验 token。
Login
新增了一次性校验 token,重定向值业务系统的/token
。TokenValidate
用于校验一次性 token,同时还会根据签名验证 client Id,验证通过后会删除该 token,并返回长短 token。
server_a/server.go
package main import ( "bytes" "encoding/json" "fmt" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/patrickmn/go-cache" "io" "log" "net/http" "sso/encrypt" "time" ) // 这里的 session 使用内存 cache,真实环境使用 Redis var ( sessions = cache.New(time.Minute*15, time.Second) ) type User struct { Name string Password string Age int } type Tokens struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` } func main() { r := gin.Default() r.Use(LoginMiddleware()) r.GET("/profile", func(ctx *gin.Context) { ctx.JSON(200, &User{ Name: "Tom", Age: 18, }) }) r.GET("/token", Token) // Listen and serve on 0.0.0.0:8081 err := r.Run(":8081") if err != nil { return } } func Token(ctx *gin.Context) { token, ok := ctx.GetQuery("token") if !ok { ctx.String(http.StatusBadRequest, "%s", "token 不对") return } // 生成签名,防止 token 被盗取 signature := encrypt.Encrypt("server_a") // 拿到了 token req, err := http.NewRequest(http.MethodPost, "http://sso.com:8000/token/validate?token="+token, bytes.NewBuffer([]byte(signature))) if err != nil { ctx.String(http.StatusBadRequest, "%s", "解析 token 失败") return } resp, err := (&http.Client{}).Do(req) if err != nil { ctx.String(http.StatusBadRequest, "%s", "解析 token 失败") return } tokensBs, _ := io.ReadAll(resp.Body) var tokens Tokens _ = json.Unmarshal(tokensBs, &tokens) // 设置自己的 session 和cookie ssid := uuid.New().String() sessions.Set(ssid, tokens, time.Minute*15) ctx.SetCookie("a_sessid", ssid, 15*60, "", "", false, false) // 你是要跳过去你最开始的 profile 那里 http.Redirect(ctx.Writer, ctx.Request, "http://aaa.com:8081/profile", 302) } func LoginMiddleware() gin.HandlerFunc { return func(ctx *gin.Context) { // 如果是登录 URL ,直接通过 if ctx.Request.URL.Path == "/token" { ctx.Next() return } redirect := fmt.Sprintf("http://sso.com:8000/login?client_id=server_a") ssid, err := ctx.Cookie("a_sessid") // 拿不到 cookie 则重定向到 sso server 进行登录 if err != nil { http.Redirect(ctx.Writer, ctx.Request, redirect, 302) return } token, ok := sessions.Get(ssid) if !ok { // 你没有登录 http.Redirect(ctx.Writer, ctx.Request, redirect, 302) return } log.Println(token) // 这边就是登录了 ctx.Next() } }
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
111LoginMiddleware
几乎不变,变的是根据自己的缓存校验自己的 token,不再需要每次请求时调用 sso 来校验。新增了
/token
接口,用于校验 sso 生成的一次性 token,同时生成自己的 token。
server_b/server.go
package main import ( "bytes" "encoding/json" "fmt" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/patrickmn/go-cache" "io" "log" "net/http" "sso/encrypt" "time" ) // 这里的 session 使用内存 cache,真实环境使用 Redis var ( sessions = cache.New(time.Minute*15, time.Second) ) type User struct { Name string Password string Age int } type Tokens struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` } func main() { r := gin.Default() r.Use(LoginMiddleware()) r.GET("/profile", func(ctx *gin.Context) { ctx.JSON(200, &User{ Name: "Jerry", Age: 20, }) }) r.GET("/token", Token) // Listen and serve on 0.0.0.0:8082 err := r.Run(":8082") if err != nil { return } } func Token(ctx *gin.Context) { token, ok := ctx.GetQuery("token") if !ok { ctx.String(http.StatusBadRequest, "%s", "token 不对") return } // 生成签名,防止 token 被盗取 signature := encrypt.Encrypt("server_b") // 拿到了 token req, err := http.NewRequest(http.MethodPost, "http://sso.com:8000/token/validate?token="+token, bytes.NewBuffer([]byte(signature))) if err != nil { ctx.String(http.StatusBadRequest, "%s", "解析 token 失败") return } resp, err := (&http.Client{}).Do(req) if err != nil { ctx.String(http.StatusBadRequest, "%s", "解析 token 失败") return } tokensBs, _ := io.ReadAll(resp.Body) var tokens Tokens _ = json.Unmarshal(tokensBs, &tokens) // 设置自己的 session 和cookie ssid := uuid.New().String() sessions.Set(ssid, tokens, time.Minute*15) ctx.SetCookie("b_sessid", ssid, 15*60, "", "", false, false) // 你是要跳过去你最开始的 profile 那里 http.Redirect(ctx.Writer, ctx.Request, "http://aaa.com:8081/profile", 302) } func LoginMiddleware() gin.HandlerFunc { return func(ctx *gin.Context) { // 如果是登录 URL ,直接通过 if ctx.Request.URL.Path == "/token" { ctx.Next() return } redirect := fmt.Sprintf("http://sso.com:8000/login?client_id=server_b") ssid, err := ctx.Cookie("b_sessid") // 拿不到 cookie 则重定向到 sso server 进行登录 if err != nil { http.Redirect(ctx.Writer, ctx.Request, redirect, 302) return } token, ok := sessions.Get(ssid) if !ok { // 你没有登录 http.Redirect(ctx.Writer, ctx.Request, redirect, 302) return } log.Println(token) // 这边就是登录了 ctx.Next() } }
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
111encrypt/encrypt.go
package encrypt import ( "github.com/google/uuid" "strings" ) // Encrypt 模拟生成签名 func Encrypt(clientId string) string { random := uuid.New().String() return clientId + ":" + random } // Decrypt 模拟解密 func Decrypt(signature []byte) (clientId string, err error) { seg := strings.Split(string(signature), ":") return seg[0], nil }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 演示
在hosts
设置好域名:
127.0.0.1 aaa.com
127.0.0.1 bbb.com
127.0.0.1 sso.com
2
3
在浏览器输入 URL:
http://aaa.com:8081/profile
,并按回车。由于 a server 还没有登录,所以会重定向至 sso server 的登录页面。
输入邮箱和密码并点击“登录”按钮,向 sso server 提交登录信息;sso server 验证通过后,根据
client_id
重定向至 a 服务的http://aaa.com:8081/profile
,并设置了 cookie。浏览器带上 cookie 请求
http://aaa.com:8081/profile
,a server 响应对应的资源。现在在同一个浏览器访问 b server 的 URL:
http://bbb.com:8082/profile
;此时不需要登录也可以访问成功了。
# 知识点
源自《Go 实战训练营》
# 前置知识
- 为什么需要 session?
- HTTP 是无状态的,不携带用户信息
- Cookie 和 Session
- Cookie 是客户端保存用户状态,可以被篡改,不安全,有大小限制
- Session 是服务端保存用户状态
- 为什么登录的 sess id 是需要 cookie 和 session 都设置过期时间的?
- 用户可以伪造 cookie
- 客户端输入都是不可信的
- 需要考虑 session 占用服务资源的问题
- JWT 类的 token 和 cookie 有啥区别?
- 本身就是两个东西
- 理论上来说,JWT token 可以放进去 cookie 里面,也可以放在 header 里面,还可以放在查询参数里面
- JWT token 它就是一个特殊的 sessid,还一个带了用户信息的 sess id
- 什么信息适合编码进去 JWT 里面?
- 不敏感
- 高频
- 怎么防止被人盗取 token?
- token 编码了客户端信息,例如 Agent,mac 地址
- 对编码后的信息进行加密,后台收到再解密
- csrf token 能够一定程度上缓解问题
- 不可以用 IP(remote address 都不能用)
- 公网 IP 是会变的(共享 IP)
- 你换 wifi 了,换网络了,IP 也变了
- 单点登录的难点?
- 异构服务端
- 一般是通过通信协议来调用:服务调用, http 调用,消息队列,进程间通信,文件系统
- session 共享:子系统要共享 session
- 借助第三方存储,比如说 Redis,数据库也可以
- 第三方存储瓶颈问题:所以引入了类似 JWT 的机制
- JWT 这种是牺牲应用服务器来保护第三方存储(JWT编码和解码都在应用服务器上)
- CPU 换存储空间
- 分系统 session
- 共性与个性的问题:即不同子系统自己独有的数据,自己存储
- 接近一种数据分片的概念,降低共享 session 的压力
- 第三方存储瓶颈问题:所以引入了类似 JWT 的机制
- 借助第三方存储,比如说 Redis,数据库也可以
- 跨域问题,cookie 作用域的问题
- 不同子域名:a.demo.com,b.demo.com
- 完全不同域名:demo.com,github.com
- 登出问题
- 第三方授权登录:微信扫码登录
- 异构服务端
- 多设备登录和单点登录的区别和联系
- 多设备登录指的是手机、平板电脑、Web 登录
- 设备之间登录是没有办法共享的,即手机 APP 登录了,你跑过去平板还是得重新登录
- 鉴权和登录的区别和联系
- 认证:身份认证,你是谁?
- 授权:有没有权限,我知道你是谁,我得看看你有没有权限
# SSO 知识
token 怎么生成?
- 能不能用 client id?肯定不能,因为这个 token 用完就丢的,不能让人猜出来
- 随机生成,存起来
SSO server 的解析 token 方案
- http 接口:
- 频率限制:
- 每秒钟只能有 100 个请求过来,多了就限流,有什么问题?——攻击者直接就占用了正常的流量,正常的请求就被丢掉了;
- 针对来源进行限制:如果来源是非法来源,你就直接全部拒绝掉
- 来源:
- IP 白名单限制:
- client id + 秘钥 生成一个签名:app key + app secret
client id + 密码?
双 token:
- access token:短期内使用,比如说访问资源,或者说登录
- refresh token:就是为了拿到一个新的 access token
类似于白名单,回调地址,app key 或者 app secret,加解密算法,都是走线下申请(或者协商)流程
安全性保证:
- 登录成功回调,类似于白名单机制:攻击者伪造重定向地址,窃取你的登录成果
- 验证 token 的地方,要带上客户端的身份信息(a server):攻击者可以拿到你的 token,然后自己发请求解析
- 验证 token,用完就丢
- 要保证同一个主体,才是免登录(即 a 和 b 归属同一个主体)
sso server 可用性保证:
一般前面会有一个网关:
负载均衡
来源验证(IP白名单之类的)
熔断、限流:
- 限流是针对合法的客户端:比如说每一个客户端一秒钟十个请求
- 限流是针对 login 无法验证来源的 API:一秒 1000 个请求
sso server 必然是一个集群,根据业务规模来决定实例数量
# 参考
- https://blog.lishunyang.com/2020/05/sso-summary.html