SSO(Single Sign On,即单点登录),允许用户在多个网站或者应用程序之间使用一组凭据(例如用户名和密码)进行身份验证。用户只需要在登录一个网站或者应用程序后,就可以访问其他网站或者应用程序,而无需再输入凭据。
Tips
重点是在多个网站之间使用一组凭据,并且用户只需要登录一个网站或应用程序,其他网站或者应用程序就无需再输入凭据了。
目前实现SSO的协议或标准很多,如SAML、OAuth、LADP和OIDC等,都能实现SSO。
OIDC是一个协议,简单来讲就是OIDC规定有一个中心的Provider能够认证用户的凭据并且授权,即会返回AccessToken和IDToken等给受信任的Client,因为IDToken是JWT格式且包含登录用户的唯一标识,所以Client能够轻松地解析IDToken获取用户信息甚至存储到自身的数据库中。
这里选用OIDC的原因是Client能够很好地解析IDToken获取用户信息。
这里的OIDC Provider的实现是golang的dex库:https://dexidp.io/docs/getting-started/
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.com
和b.example.com
都能够访问到这个域。
所以我如果在A网站的服务器中设置Cookie的域为.example.com
,那么在B网站中可以使用到该Cookie,反之亦然。
Tips
如果想要结合OIDC和Cookie实现SSO,那么网站的域名应该拥有相同的父域名。
现在学校有一个课程系统(lessons)和书籍系统(books),要求就是用户在要求登录一次后,访问另一系统就不需要再登录了。
使用OIDC Provider进行用户认证和授权,返回AccessToken和IDToken给Client,并且Client要求浏览器使用Cookie保存AccessToken和IDToken,并且域设置为课程系统和书籍系统都能访问到的域。在访问课程系统和书籍系统对应的服务器的时候读取存放在Cookie中的token并且访问后端。
要点:
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)
}
}
这样books.college.edu
和lessons.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)
}
}
如果读取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))
}
}
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
}
课程系统和书籍系统同理。
配置host模拟真实环境
## sso-demo
127.0.0.1 books.college.edu
127.0.0.1 lessons.college.edu
127.0.0.1 sso.college.edu
配置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'
首次访问books:http://books.college.edu:8000/
,要求登录
登录完成
访问lessons:http://lessons.college.edu:8001/
,成功访问并且不需要登录
再次访问books
至此,实现了只需要在某一系统中登录过一次,在另外的系统就不需要再次登录直接就能进行访问了。
依赖Cookie
显而易见,该实现依赖于Cookie中存储token,并且在访问Web服务器的时候携带Cookie。在无法使用Cookie或浏览器禁用Cookie的时候就需要使用其他的方法了,如URL参数或者Web Storage等。
父域名必须相同
由于浏览器限制,只有父域名相同才能使用同一以.
开头的域的Cookie。
没有记录登录状态
以上代码没有在OIDC Provider处记录用户的登录状态,即如果用户在某一系统中退出了账号,但是只要其他任何地方的浏览器Cookie中存有该token,仍然可以使用token进行访问,所以用户其实并没有完全退出,只是在某一个浏览器中退出了而已。解决办法是在OIDC Provider中添加对用户的登录状态管理即可。
https://github.com/FanGaoXS/sso-demo