结合OIDC和Cookie实现SSO

结合OIDC和Cookie实现SSO

1 什么是SSO

SSO(Single Sign On,即单点登录),允许用户在多个网站或者应用程序之间使用一组凭据(例如用户名和密码)进行身份验证。用户只需要在登录一个网站或者应用程序后,就可以访问其他网站或者应用程序,而无需再输入凭据。

Tips

重点是在多个网站之间使用一组凭据,并且用户只需要登录一个网站或应用程序,其他网站或者应用程序就无需再输入凭据了。

目前实现SSO的协议或标准很多,如SAML、OAuth、LADP和OIDC等,都能实现SSO。

2 什么是OIDC

OIDC是一个协议,简单来讲就是OIDC规定有一个中心的Provider能够认证用户的凭据并且授权,即会返回AccessToken和IDToken等给受信任的Client,因为IDToken是JWT格式且包含登录用户的唯一标识,所以Client能够轻松地解析IDToken获取用户信息甚至存储到自身的数据库中。

这里选用OIDC的原因是Client能够很好地解析IDToken获取用户信息

这里的OIDC Provider的实现是golang的dex库:https://dexidp.io/docs/getting-started/

3 什么是Cookie

Cookie是一种在Web服务器和Web浏览器之间传递的小型文本。当访问Web应用程序或者浏览器时,Web服务器可能通过设置Cookie将一些信息存储到用户的浏览器上。接着Web浏览器在发送HTTP请求时,会将该网站相关的Cookie一并发送给Web服务器,Web服务器可以非常轻松的读取这些Cookie。

Cookie通常由一个名称(Name)、一个值(Value)、一个过期时间(Expires)和一个域名(Domain)组成。名称和值指定了Cookie中存储的信息,过期时间指定了Cookie的有效期,域名指定了允许访问该Cookie的域名。

这里选用Cookie的原因是利用了Cookie的域的特性:如果Cookie的域是.example.com,那么a.example.comb.example.com都能够访问到这个域。

所以我如果在A网站的服务器中设置Cookie的域为.example.com,那么在B网站中可以使用到该Cookie,反之亦然。

Tips

如果想要结合OIDC和Cookie实现SSO,那么网站的域名应该拥有相同的父域名。

4 需求

现在学校有一个课程系统(lessons)和书籍系统(books),要求就是用户在要求登录一次后,访问另一系统就不需要再登录了。

5 实现

使用OIDC Provider进行用户认证和授权,返回AccessToken和IDToken给Client,并且Client要求浏览器使用Cookie保存AccessToken和IDToken,并且域设置为课程系统和书籍系统都能访问到的域。在访问课程系统和书籍系统对应的服务器的时候读取存放在Cookie中的token并且访问后端。

要点:

  • Client从OIDC Provider处获得token,获取到token保存到Cookie中
  • Client在接受来自浏览器的请求时读取Cookie获得token,验证token并获取用户个人信息。

6 Books Client

获得token并写入Cookie

const (
	OidcProvider = "http://sso.college.edu:5556/dex"
	ClientId     = "books-college"
	ClientSecret = "books-college-secret"
	RedirectURL  = "http://books.college.edu:8000/callback"
)

func Login() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		provider, err := oidc.NewProvider(ctx, OidcProvider)
		if err != nil {
			http.Error(w, fmt.Sprintf("init oidc provider failed: %s", err), http.StatusInternalServerError)
			return
		}

		oauth2Config := Oauth2Config(provider)
		url := oauth2Config.AuthCodeURL("state")
		http.Redirect(w, r, url, http.StatusFound)
	}
}

func LoginCallback() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		provider, err := oidc.NewProvider(ctx, OidcProvider)
		if err != nil {
			http.Error(w, fmt.Sprintf("init oidc provider failed: %s", err), http.StatusInternalServerError)
			return
		}
		config := Oauth2Config(provider)
		oauth2Token, err := config.Exchange(ctx, r.URL.Query().Get("code"))
		if err != nil {
			http.Error(w, fmt.Sprintf("exchange token with server failed: %s", err), http.StatusUnauthorized)
			return
		}

		rawIDToken, ok := oauth2Token.Extra("id_token").(string)
		if !ok {
			http.Error(w, fmt.Sprintf("get rawIDToken with token failed"), http.StatusUnauthorized)
			return
		}
		idTokenVerifier := provider.Verifier(&oidc.Config{ClientID: ClientId})
		idToken, err := idTokenVerifier.Verify(ctx, rawIDToken)
		if err != nil {
			http.Error(w, fmt.Sprintf("verify IDToken with oidc provider failed: %s", err), http.StatusUnauthorized)
			return
		}

		setTokenIntoCookie(w, oauth2Token)
		bytes, _ := json.Marshal(idToken)
		w.Write(bytes)
	}
}

将token写入Cookie中并设置合适的域

这样books.college.edulessons.college.edu都能访问到该Cookie

const (
	CookieDomain = ".college.edu"
)

func setTokenIntoCookie(w http.ResponseWriter, oauth2Token *oauth2.Token) {
	rawIDToken, _ := oauth2Token.Extra("id_token").(string)
	cookies := []*http.Cookie{
		{Name: "access_token", Value: oauth2Token.AccessToken},
		{Name: "token_type", Value: oauth2Token.TokenType},
		{Name: "refresh_token", Value: oauth2Token.RefreshToken},
		{Name: "expiry", Value: oauth2Token.Expiry.Format(time.RFC3339)},
		{Name: "id_token", Value: rawIDToken},
	}
	for _, c := range cookies {
		c.Domain = CookieDomain
		c.Path = "/"
		c.MaxAge = 60 * 5 // 5 minutes
		c.HttpOnly = true
		http.SetCookie(w, c)
	}
}

访问接口时,读取来自Cookie中的token

如果读取token失败(Cookie中不存在token,token过期等)则要求用户重新登录

func MyBook() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		ctx := r.Context()

		ui, err := auth.GetUserInfo(ctx, r)
		if err != nil {
			http.Redirect(w, r, "/login", http.StatusFound)
			return
		}

		msg := fmt.Sprintf("These are your books, %s!", ui.Name)
		w.Write([]byte(msg))
	}
}

验证token是否合法,并解析token获得用户的个人信息

func GetUserInfo(ctx context.Context, r *http.Request) (*userinfo.UserInfo, error) {
	token, err := getTokenFromCookie(r)
	if err != nil {
		return nil, fmt.Errorf("get userinfo failed: %v", err)
	}

	provider, err := oidc.NewProvider(ctx, OidcProvider)
	if err != nil {
		return nil, fmt.Errorf("initialize provider failed: %v", err)
	}
	idTokenVerifier := provider.Verifier(&oidc.Config{SkipClientIDCheck: true})
	idToken, err := idTokenVerifier.Verify(ctx, token)
	if err != nil {
		return nil, fmt.Errorf("verify rawIDToken failed: %v", err)
	}

	var ui *userinfo.UserInfo
	if err = idToken.Claims(&ui); err != nil {
		return nil, fmt.Errorf("parse idToken failed: %v", err)
	}

	return ui, nil
}

func getTokenFromCookie(r *http.Request) (string, error) {
	rawExpiry, err := r.Cookie("expiry")
	if err != nil {
		return "", fmt.Errorf("get token from cookie failed: %v", err)
	}
	expiry, err := time.Parse(time.RFC3339, rawExpiry.Value)
	if err != nil {
		return "", fmt.Errorf("parse expiry which is from cookie failed: %v", err)
	}
	if expiry.Before(time.Now()) {
		return "", fmt.Errorf("token is expired")
	}

	rawIDToken, err := r.Cookie("id_token")
	if err != nil {
		return "", fmt.Errorf("get token from cookie failed: %v", err)
	}

	return rawIDToken.Value, nil
}

7 Lessons Client

课程系统和书籍系统同理。

8 演示测试

  1. 配置host模拟真实环境

    ## sso-demo
    127.0.0.1	books.college.edu
    127.0.0.1	lessons.college.edu
    127.0.0.1	sso.college.edu
    
  2. 配置dex的config-dev.yaml,将books和lessons加入staticClients

    staticClients:
    - id: books-college
      secret: books-college-secret
      name: 'Books College'
      redirectURIs:
          - 'http://books.college.edu:8000/callback'
    - id: lessons-college
      secret: lessons-college-secret
      name: 'Lessons College'
      redirectURIs:
          - 'http://lessons.college.edu:8001/callback'
    
  3. 首次访问books:http://books.college.edu:8000/,要求登录

    结合OIDC和Cookie实现SSO_第1张图片

  4. 登录完成

  5. 访问lessons:http://lessons.college.edu:8001/,成功访问并且不需要登录

    结合OIDC和Cookie实现SSO_第2张图片

  6. 再次访问books

    结合OIDC和Cookie实现SSO_第3张图片

至此,实现了只需要在某一系统中登录过一次,在另外的系统就不需要再次登录直接就能进行访问了。

缺陷

  • 依赖Cookie

    显而易见,该实现依赖于Cookie中存储token,并且在访问Web服务器的时候携带Cookie。在无法使用Cookie或浏览器禁用Cookie的时候就需要使用其他的方法了,如URL参数或者Web Storage等。

  • 父域名必须相同

    由于浏览器限制,只有父域名相同才能使用同一以.开头的域的Cookie。

  • 没有记录登录状态

    以上代码没有在OIDC Provider处记录用户的登录状态,即如果用户在某一系统中退出了账号,但是只要其他任何地方的浏览器Cookie中存有该token,仍然可以使用token进行访问,所以用户其实并没有完全退出,只是在某一个浏览器中退出了而已。解决办法是在OIDC Provider中添加对用户的登录状态管理即可。

演示代码

https://github.com/FanGaoXS/sso-demo

你可能感兴趣的:(go,后端,计算机网络,web安全)