云原生丨三步教你使用DEX轻松实现单点登录(SSO)


前言

通常,我们在登录单系统时,都希望只需要登录⼀次,就能访问本系统中包含的所有资源。但实际中,单系统往往⽆法囊括所有内容,总会出现其他系统资源的情况,⽽访问其他系统时,⼜需要重新登录。因此⼀次登录,访问多个系统的资源,成了⼤多⽤户的痛点。

然而,多系统的访问需要解决以下⼏个问题:

①⽤户只需要登录⼀次,就能访问所有系统的资源。

②⽤户退出登录(主动退出或超时⾃动退出),所有系统资源都不能访问。

本期我们就基于上述问题一起来探讨分析,看看如何解决实现。


一、分析思路

1、单点登录

单点登录(SSO,Single sign-on)⽤来解决第⼀个问题:⽤户只需要登录⼀次,就能访问所有系统的资源。

单点登录是⼀种身份验证解决⽅案,可让⽤户通过⼀次性⽤户身份验证登录多个应⽤程序和⽹站。

本次采⽤DEX来实现单点登录。DEX是基于OpenID Connect协议实现的⼀个认证服务,OpenID Connect是从oauth2认证协议演进过来的。

DEX⼤致分为两个部分:

  • ⼀个是实现OpenID Connect协议的服务端。服务端包含登录⻚⾯以及⼀些⽤于验证的http后端接⼝。
  • ⼀个是⽤于验证账号的连接器。连接器将⽤户输⼊的账号密码发送到账号系统进⾏认证。DEX官⽅⽀持的连接器有:LDAP,GitHub,SAML
    2.0,Gitlab,OpenID Connect,OAuth2.0,Google,LinkedIn,Microsoft,AuthProxy,Bitucket
    Cloud,OpenShift,Atlassian,Crowd,Gitea,Open Stack Keystone,Integration
    kubelogin and Active Directory。

OpenID Connect协议中包含了三种认证模式:授权码认证,隐式认证,混合认证。

授权码认证

OpenID Connect授权代码流程通过以下步骤进⾏:

1、客户端准备⼀个包含所需请求参数的身份验证请求。

2、 客户端将请求发送到授权服务器。

3、授权服务器对最终⽤户进⾏身份验证。

4、授权服务器获得最终⽤户同意/授权。

5、授权服务器使⽤授权代码将最终⽤户发送回客户端。

6、客户端使⽤令牌端点的授权代码请求响应。

7、 客户端在响应主体中收到包含ID令牌和访问令牌的响应。

8、客户端验证ID令牌并检索最终⽤户的主题标识符。

结合DEX给出的实现⽅案如下:
在这里插入图片描述

授权码

授权码在请求⼀次token端点后就会失效,超过⼀定时间,也会⾃动失效。

token

返回的token信息中,包含了access_token,id_token,refresh_token。

  • access_token:可⽤于应⽤内部的请求验证。其hash值包含于id_token中,即可通过id_token直接验证access_token。
  • id_token:可⽤于跨应⽤的请求验证。跨应⽤时,需在client中设置跨应⽤权限。id_token超过⼀定时间,会⾃动失效。id_token的验证需要通过dex提供的接⼝进⾏验证。
  • refresh_token:access_token或id_token失效时,⽤于刷新access_token,id_token。
  • refresh_token超过⼀定时间后,会⾃动失效。实际场景中,会将refresh_token的超时时间设置的⽐较⼤。

隐式认证

隐式流程按照以下步骤操作:

1、客户端准备⼀个包含所需请求参数的身份验证请求。

2、客户端将请求发送到授权服务器。

3、授权服务器对最终⽤户进⾏身份验证。

4、授权服务器获得最终⽤户同意/授权。

5、 授权服务器将最终⽤户发送回客户端,并带有ID令牌,如果需要,则发送访问令牌。

6、客户端验证ID令牌并检索最终⽤户的主题标识符。

结合DEX给出的实现⽅案如下:

在这里插入图片描述
token信息

隐式认证返回的token信息中,只包含了access_token和id_token。id_token到期后,需要重新认证。适⽤于认证周期⽐较短的场景。

混合认证

混合流遵循以下步骤:

1、客户端准备⼀个包含所需请求参数的身份验证请求。

2、客户端将请求发送到授权服务器。

3、授权服务器对最终⽤户进⾏身份验证。

4、授权服务器获得最终⽤户同意/授权。

5、授权服务器使⽤授权代码将最终⽤户发送回客户端,并根据响应类型发送⼀个或多个附加参数。

6、客户端使⽤令牌端点的授权代码请求响应。

7、 客户端在响应主体中收到包含ID令牌和访问令牌的响应。

8、 客户端验证ID令牌并检索最终⽤户的主题标识符。

结合DEX给出的实现⽅案如下:
在这里插入图片描述
授权码和token

认证完成后,DEX会返回授权码,access_token和id_token。

2、会话管理

会话管理⽤来解决第⼆个问题:⽤户退出登录(主动退出或超时⾃动退出),所有系统资源都不能访问。

⽤户登录后,开始会话,⽤户登出(主动登出或超时⾃动登出)后结束会话,整个会话期间,认为是同⼀个⽤户进⾏操作。

此时会话需要有以下⼏个要求:

  • 每个会话独⽴,所有系统共有同⼀个会话;
  • 不做任何操作时,会话⾃动到期;
  • 访问任意系统时,会话⾃动续期。

redis完美符合。redis中,key的唯⼀性,区分不同的会话,value可以存储会话⾥⾯的数据。redis超时删除机制,符合会话⾃动到期。redis重新设置超时时间,可以实现会话⾃动续期。


二、实现过程

1、搭建DEX认证中⼼

Step 1: 使⽤docker-compose搭建Openldap账号系统,DEX服务端和Redis认证中⼼。


version: "3"
services:
openldap:
image: bitnami/openldap:latest
ports:
- 1389:1389
environment:
- LDAP_ADMIN_USERNAME=admin
- LDAP_ADMIN_PASSWORD=adminpassword
dex:
image: bitnami/dex:latest
ports:
- 5556:5556
- 5557:5557
command:
- serve
- /dex/config.yaml
volumes:
- config.yaml:/dex/config.yaml
redis:
image: redis:latest
ports:
- 6379:6379

Step 2: 启动DEX时需要⽤到的配置⽂件,示例如下:


enablePasswordDB: true
# dex服务地址
issuer: http://localhost:5556/dex
oauth2:
# 可⽤的返回类型
responseTypes: [ "code","token","id_token" ]
skipApprovalScreen: true
staticClients:
- id: app1
name: app1
redirectURIs:
- http://localhost:8080/callback
secret: app1-secret
# trustedPeers表app2⽣成的token可⽤于app1的认证。
trustedPeers:
- app2
- id: app2
name: app2
redirectURIs:
- http://localhost:8081/callback
secret: app2-secret
trustedPeers:
- app1
storage:
type: sqlite3
config:
file: local-example/dex.db
web:
# http 接⼝地址
http: 0.0.0.0:5556
grpc:
# grpc接⼝地址。⽀持通过grpc来扩充dex配置。
addr: 0.0.0.0:5557
# # Server certs. If TLS credentials aren't provided dex will run in
plaintext (HTTP) mode.
# tlsCert: /Users/liujian/work/006-yhplatform/code/yunhang-platformservice/cert/dex-server.crt
# tlsKey: /Users/liujian/work/006-yhplatform/code/yunhang-platformservice/cert/dex-server.key
#
# # Client auth CA.
# tlsClientCA: /Users/liujian/work/006-yhplatform/code/yunhang-platformservice/cert/dex-client.crt
# enable reflection
reflection: true
connectors:
# 指定账号连接器。这⾥配置的是openldap
- type: ldap
name: OpenLDAP
id: ldap
config:
# The following configurations seem to work with OpenLDAP:
#
# 1) Plain LDAP, without TLS:
host: openldap:1389
insecureNoSSL: true
#
# 2) LDAPS without certificate validation:
#host: localhost:636
#insecureNoSSL: false
#insecureSkipVerify: true
#
# 3) LDAPS with certificate validation:
#host: YOUR-HOSTNAME:636
#insecureNoSSL: false
#insecureSkipVerify: false
#rootCAData: 'CERT'
# ...where CERT
=
"$( base64 -w 0 your-cert.crt )"
# This would normally be a read-only user.
bindDN: cn=admin,dc=example,dc=org
bindPW: adminpassword
usernamePrompt: LDAP ⽤户名
userSearch:
baseDN: ou=users,dc=example,dc=org
filter: "(objectClass=person)"
username: cn
# "DN" (case sensitive) is a special attribute name. It
indicates that
# this value should be taken from the entity's DN not an
attribute on
# the entity.
idAttr: DN
emailAttr: mail
nameAttr: cn
groupSearch:
baseDN: ou=Groups,dc=example,dc=org
filter: "(objectClass=groupOfNames)"
userMatchers:
# A user is a member of a group when their DN matches
# the value of a "member" attribute on the group entity.
- userAttr: DN
groupAttr: member
# The group name should be the "cn" value.
nameAttr: cn
# 超时时间设置
expiry:
deviceRequests: "5m"
signingKeys: "6h"
idTokens: "24h"
refreshTokens:
reuseInterval: "30s"
validIfNotUsedFor: "2160h" # 90 days
absoluteLifetime: "3960h" # 165 days

issuer:配置dex的服务地址。

oauth2:配置⽀持的oauth2认证类型。

staticClients:配置可以通过dex进⾏认证的客户端应⽤。这⾥配置了两个应⽤,app1和app2。⼀般情况下,应⽤⽣成的token只能⽤于本应⽤的认证,配置trustedPeers后,可以进⾏跨应⽤资源认证。

storage:DEX的数据存储。DEX需要存储的数据如下:

在这里插入图片描述
web:dex认证http服务。

grpc:dex配置修改的grpc服务。

connectors:配置账号连接器。

expiry:配置超时时间

2、登录

流程说明

在这里插入图片描述
Step 1:⽤户访问应⽤1前端,应⽤1前端根据路由进⾏鉴权,鉴权不通过跳转到SSO登录⻚⾯(DEX提供);

Step 2:通过LDAP账号进⾏登录,登录成功,回调应⽤1前端的callback⻚⾯,返回Authorization Code;

Step 3:应⽤1前端调⽤login接⼝,传⼊Authorization Code值;

Step 4:应⽤1后端根据Authorization Code从DEX进⾏认证;

Step 5:DEX认证成功,返回AccessToken,RefreshToken,IdToken;

Step 6:应⽤1后端在redis上构建⼀个全局会话(redis中通过随机⽣成的key值sid来表示),将AccessToken,RefreshToken,和IdToken存⼊全局会话,并⽣成应⽤1的局部认证⽅式(这⾥采⽤AccessToken1和RefreshToken1)返回到应⽤1前端;

Step 7:应⽤1前端将RefreshToken1和AccessToken1缓存到Local Storage中;

Step 8:应⽤1前端每次请求接⼝时携带AccessToken1到应⽤1后端;

Step 9:应⽤1后端校验AccessToken1是否有效和AccessToken1中包含的sid全局会话是否有效,当AccessToken1失效时,云航前端调取RefreshToken1接⼝,重新获取AccessToken1;

Step 10:应⽤1前端SSO认证应⽤2前端时,获取会话中的数据sid和全局IdToken传⼊到应⽤2前端;

Step 11:应⽤2调⽤登录接⼝,校验IdToken的有效性和全局会话sid的有效性,校验通过,⽣成应⽤2⾃⼰的认证⽅式⽤于前后端交互;

Step 12:应⽤2前端登录成功,跳转到应⽤2主⻚;

授权码认证示例代码

1、访问登陆页面

curl http://localhost:5556/dex/auth/ldap?
client_id=app1&redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fcallback&response_t
ype=code&scope=openid+profile+email+federated:id+offline_access+audience:server
:client_id:zadig+audience:server:client_id:app2&state=gHoisYYgsmpc

2、使⽤code获取token

func TestAuthCode(t *testing.T) {
    
    
ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10)
defer cancel()
// 连接dex
provider, err := oidc.NewProvider(ctx, "http://localhost:5556/dex")
if err != nil {
    
    
t.Error(err)
}
oauth2Config := &oauth2.Config{
    
    
ClientID: "app1",
ClientSecret: "app1-secret",
Endpoint: provider.Endpoint(),
RedirectURL: "http://localhost:8080/callback",
Scopes: []string{
    
    "openid", "profile", "email", "groups"},
}
// 请求dex的token端点获取token
oauth2Token, err := oauth2Config.Exchange(ctx, authCode)
if err != nil {
    
    
t.Error(err)
}
rawIDToken, _ = oauth2Token.Extra("id_token").(string)
t.Logf("accessToken:%v", oauth2Token.AccessToken)
t.Logf("refreshToken:%v", oauth2Token.RefreshToken)
t.Logf("idToken:%v", rawIDToken)
}

3、验证idToken

func TestIDToken(t *testing.T) {
    
    
// 连接dex
provider, err := oidc.NewProvider(ctx, "http://localhost:5556/dex")
if err != nil {
    
    
t.Error(err)
}
// 验证idToken
idTokenVerifier := provider.Verifier(&oidc.Config{
    
    ClientID: "app1"})
idToken, err := idTokenVerifier.Verify(ctx, rawIDToken)
if err != nil {
    
    
t.Error(err)
}
ac := make(map[string]any)
if err := idToken.Claims(ac); err != nil {
    
    
t.Error(err)
}
t.Logf("claims:%v", ac)
}

4、创建全局会话,并构建局部会话

func TestSession(t *testing.T){
    
    
sk := "xxxxx" // base64格式的pem私钥
// ⽣成局部会话
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.StandardClaims{
    
    
Subject: "user1",
ExpiresAt: time.Now().Add(time.Hour).Unix(), // Second
})
accessTokenStr, err := accessToken.SignedString([]byte(sk))
if err != nil {
    
    
t.Error(err)
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256,
jwt.StandardClaims{
    
    
Subject: "user1",
ExpiresAt: time.Now().Add(time.Hour*2).Unix(), // Second
})
refreshTokenStr, err := refreshToken.SignedString([]byte(sk))
if err != nil {
    
    
t.Error(err)
}
// 构建全局会话
ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10)
defer cancel()
sid := uuid.New().String()
rc := redis.NewClient(&redis.Options{
    
    Addr: "localhost:6379", DB: 0})
rc.Set(ctx, sid, map[string]any{
    
    
"dex": map[string]any{
    
     // 存储dex信息
"accessToken": "xxxx",
"refreshToken": "xxxx",
"idToken": "xxxx",
},
"local": map[string]string {
    
     // 存储局部会话
accessTokenStr: refreshTokenStr,
},
}.Hour)
}

5、局部会话滚动更新

func TestRefreshToken(t *testing.T){
    
    
sid := "xxxx" //全局会话
refreshTokenLocal := "xxxxx" // 局部会话的refreshToken
refreshTokenDex := "xxxxx" // dex的refreshToken
// 解析局部会话的refreshToken中的jwt.Cl。重新⽣成accessToken
...
// 获取全局会话
ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10)
defer cancel()
sid := uuid.New().String()
rc := redis.NewClient(&redis.Options{
    
    Addr: "localhost:6379", DB: 0})
val := make(map[string]any)
err := rc.Get(ctx, sid).Scan(val)
if err != nil {
    
    
t.Error(err)
}
// 更新dex的token
oauth2Config := &oauth2.Config{
    
    
ClientID: "app1",
ClientSecret: "app1-secret",
Endpoint: provider.Endpoint(),
RedirectURL: "http://localhost:8080/callback",
Scopes: []string{
    
    "openid", "profile", "email", "groups"},
}
oauth2Token, err := oauth2Config.TokenSource(ctx,
&oauth2.Token{
    
    RefreshToken: ac.Metadata["refreshToken"]}).Token()
if err != nil {
    
    
t.Error(err)
}
rawIDToken, _ = oauth2Token.Extra("id_token").(string)
newLocal := val["local"].(map[string]string)
newLocal[newAccessTokenLocal] = newRefreshTokenLocal
// 更新全局会话
rc.Set(ctx, sid, map[string]any{
    
    
"dex": map[string]any{
    
     // 存储dex信息
"accessToken": oauth2Token.AccessToken,
"refreshToken": oauth2Token.RefreshToken,
"idToken": rawIDToken,
},
"local": newLocal,
}.Hour)
}

3、登出

流程说明

在这里插入图片描述

Step1:⽤户主动登出时,调⽤登出接⼝,失效全局会话(删除redis中的sid);

Step2:应⽤1,应⽤2全部不操作时,失效全局会话(redis的超时机制);

Step3:应⽤1或应⽤2进⾏访问时,检测到全局会话已经失效,需要失效本地局部会话。

登出代码示例

删除全局会话

func TestLogout(t *testing.T) {
    
    
sid := "xxxxx" // 前端传⼊
ctx, cancel := context.WithTimeout(context.TODO(), time.Second*10)
defer cancel()
rc := redis.NewClient(&redis.Options{
    
    Addr: "localhost:6379", DB: 0})
rc.Set(ctx, sid, map[string]any{
    
    
"accessToken": accessTokenStr,
"refreshToken": refreshTokenStr,
}, time.Hour)
}

通过以上操作,就能够实现DEX的单点登录(SSO),解决了⽤户只需要登录⼀次,就能访问所有系统的资源。⽤户退出登录(主动退出或超时⾃动退出),所有系统资源都不能访问。

版权申明:文章由神州数码武汉云基地团队实践整理输出,转载请注明出处。
微信公众号后台回复“技术合集”,可获取更多干货内容!


猜你喜欢

转载自blog.csdn.net/CBGCampus/article/details/129013199