我的学习日志 我的学习日志
首页
  • Go

    • Go基础知识
  • Python

    • Python进阶
  • 操作系统
  • 计算机网络
  • MySQL
  • 学习笔记
  • 常用到的算法
其他技术
  • 友情链接
  • 收藏
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)

xuqil

一介帆夫
首页
  • Go

    • Go基础知识
  • Python

    • Python进阶
  • 操作系统
  • 计算机网络
  • MySQL
  • 学习笔记
  • 常用到的算法
其他技术
  • 友情链接
  • 收藏
关于
  • 分类
  • 标签
  • 归档
GitHub (opens new window)
  • Redis

  • Nginx

  • Linux

  • SSO和OAuth2

    • SSO实战
      • 什么是 SSO
      • 为什么要使用 SSO
      • 怎么实现 SSO
        • 方案1 共享父域的多系统
        • 方案2 跨域的多系统
      • 知识点
        • 前置知识
        • SSO 知识
      • 参考
  • Other

  • F5

  • 其他技术
  • SSO和OAuth2
Xu Qil
2022-12-28
0
目录

SSO实战

# SSO 实战

# 什么是 SSO

SSO(Single sign-on,单点登录)是一种会话和用户身份验证服务,它允许用户仅使用一组凭据就安全地访问多个应用程序和服务。

  • 同一体系的不同系统,登陆一次就行,共用session或cookie
  • 单点登录就是在一个多系统共存的环境下,用户在一处登录后,就不用在其他系统中登录,也就是用户的一次登录能得到其他所有系统的信任。

image-20221227165747922

# 为什么要使用 SSO

  1. 同一登录流程。实现用一套用户名和密码登录多个系统。
  2. 同步登录状态。如果登录任意某个系统,其他系统也是登录状态,登出任意某个系统,其他系统也都是登出状态。

# 怎么实现 SSO

# 方案1 共享父域的多系统

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 服务器下发的身份认证

# 过程

  1. 用户通过浏览器向 a server 请求资源,a server 发现用户没有在该浏览器登录,302 跳转至 sso server;
  2. sso server 返回一个登录页面给浏览器,让用户登录;
  3. 用户提交登录信息给 sso server;
  4. sso server 验证登录信息通过后,生成和保存session,返回响应,并带上 token,浏览器设置 token(例如 cookie);
  5. 用户再次向 a server 请求资源,请求带上 token,a server 拿着 token 向 sso server 校验;
  6. token 通过校验后,a server 返回用户请求的资源;
  7. 用户使用同一个浏览器访问 b server,这个请求也会带上刚刚那个 token,b server 同样会拿 token 向 sso server 校验;
  8. token 通过校验后,b server 返回对应的资源。

SSO Server 作为单点登录,为多个系统提供登录凭证,同时充当每个系统的认证服务器。业务系统每收到一次请求都需要向 SSO Server 校验凭证,token 在多个系统是属于共享的。

# 代码实现

项目结构:

│  go.sum
│  sso_server.go
│
├─server_a
│      server.go
│
├─server_b
│      server.go
│
└─template
        login.gohtml
1
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
    77
    • whiteList:白名单列表,用于重定向返回原始服务 URL
    • Login:用户的登录逻辑
    • 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
    75

    server_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
    74

    server_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
    10

    SSO server 返回的登录页面。

# 演示

在hosts设置好域名:

127.0.0.1   aaa.demo.com
127.0.0.1   bbb.demo.com
127.0.0.1   sso.demo.com
1
2
3
  1. 在浏览器输入 URL:http://aaa.demo.com:8081/profile,并按回车。

  2. 由于 a server 还没有登录,所以会重定向至 sso server 的登录页面。

    image-20221228140208880

  3. 输入邮箱和密码并点击“登录”按钮,向 sso server 提交登录信息;sso server 验证通过后,根据client_id重定向至 a 服务的http://aaa.demo.com:8081/profile,并设置了 cookie。

    image-20221228140350564

  4. 浏览器带上 cookie 请求http://aaa.demo.com:8081/profile,a server 响应对应的资源。

  5. 现在在同一个浏览器访问 b server 的 URL:http://bbb.demo.com:8082/profile;此时不需要登录也可以访问成功了。

虽然共享父域的子系统跨域实现单一登录,但是跨域的就不行了。

# 方案2 跨域的多系统

SSO-sso 方案1

跨域方案二:核心是解析 token 之后,会本地种一个 session(cookie)

  • 登出了通知其他服务器,其它服务器要清理掉自己的 session:

    • 回调:可以是回调一个 http 接口,可以是一个 RPC 接口
    • 消息队列:
    • 共享 session:只需要删掉session 就可以
    • 轮询 SSO
  • token 是一次性的,解析过一次,就不能再解析了

  • session 是否需要共享:

    • 可以共享
    • 可以不共享
  • 单一登录:你只需要在 SSO server 上校验一下上次登录的来源,和这一次登录的来源,是不是同一个;把上一次登录的,全部踢掉

  • 登出要不要通知其它服务,是取决于你的业务的:

    • 如果要是关联性不强的业务,是可以不必要同步登出的
    • 关联性强的业务,是一起登出的

# 过程

  1. 用户通过浏览器向 a server 请求资源,a server 发现用户没有在该浏览器登录,302 跳转至 sso server;
  2. sso server 返回一个登录页面给浏览器,让用户登录;
  3. 用户提交登录信息给 sso server;
  4. sso server 验证登录信息通过后,生成和保存session,返回响应,并带上 token(a server 一次性校验的 token),浏览器设置 sso 的 cookie;
  5. 浏览器访问 a server 的/token路径,请求带上 token,a server 拿着 token 向 sso server 校验;
  6. token 通过校验后,a server 自己也会生成session和设置cookie;
  7. 带上 a 的 cookie 向a server 请求资源,此时 a server 返回用户请求的资源;
  8. 通过同一浏览器访问 b server 的资源也一样,只不过不需要再次登录,即少了2和3步骤。

# 代码实现

项目结构:

│  sso_server.go    
│                   
├─encrypt
│      encrypt.go   
│
├─server_a
│      server.go    
│
├─server_b
│      server.go    
│
└─template
        login.gohtml
1
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
    111
    • LoginMiddleware几乎不变,变的是根据自己的缓存校验自己的 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
    111
  • encrypt/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
1
2
3
  1. 在浏览器输入 URL:http://aaa.com:8081/profile,并按回车。

  2. 由于 a server 还没有登录,所以会重定向至 sso server 的登录页面。

    image-20221228163015853

  3. 输入邮箱和密码并点击“登录”按钮,向 sso server 提交登录信息;sso server 验证通过后,根据client_id重定向至 a 服务的http://aaa.com:8081/profile,并设置了 cookie。

    image-20221228163044669

  4. 浏览器带上 cookie 请求http://aaa.com:8081/profile,a server 响应对应的资源。

  5. 现在在同一个浏览器访问 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 的压力
    • 跨域问题,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

评论

#SSO
上次更新: 2023/02/28, 23:02:18
Ubuntu安装常用工具
WSL 中配置 Ubuntu 22.04 开发环境

← Ubuntu安装常用工具 WSL 中配置 Ubuntu 22.04 开发环境→

最近更新
01
Golang 逃逸分析
03-22
02
深入理解 channel
03-04
03
深入理解 sync.WaitGroup
03-01
更多文章>
Theme by Vdoing | Copyright © 2018-2023 FeelingLife | 粤ICP备2022093535号-1
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式