扫码阅读
手机扫码阅读

云原生丨一文教你轻松借助DEX实现单点登录!

420 2023-09-22


Cloud Native

ESG服务BU云原生交付中心、云基地

在云原生上的尝试、调研与分享



本期内容

DEX在SSO项目中的实践

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

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


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


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


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

一、分析思路

单点登录

单点登录(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。

会话管理

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

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

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

  • 每个会话独⽴,所有系统共有同⼀个会话;

  • 不做任何操作时,会话⾃动到期;

  • 访问任意系统时,会话⾃动续期。

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

二、实现过程

搭建DEX认证中⼼

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

version: "3"services:openldap:image: bitnami/openldap:latestports:- 1389:1389environment:- LDAP_ADMIN_USERNAME=admin- LDAP_ADMIN_PASSWORD=adminpassworddex:image: bitnami/dex:latestports:- 5556:5556- 5557:5557command:- serve- /dex/config.yamlvolumes:- config.yaml:/dex/config.yamlredis:image: redis:latestports:- 6379:6379

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

enablePasswordDB: true# dex服务地址issuer: http://localhost:5556/dexoauth2:# 可⽤的返回类型responseTypes: [ "code","token","id_token" ]skipApprovalScreen: truestaticClients:- id: app1name: app1redirectURIs:- http://localhost:8080/callbacksecret: app1-secret# trustedPeers表app2⽣成的token可⽤于app1的认证。trustedPeers:- app2- id: app2name: app2redirectURIs:- http://localhost:8081/callbacksecret: app2-secrettrustedPeers:- app1storage:type: sqlite3config:file: local-example/dex.dbweb:# http 接⼝地址http: 0.0.0.0:5556grpc:# grpc接⼝地址。⽀持通过grpc来扩充dex配置。addr: 0.0.0.0:5557# # Server certs. If TLS credentials aren't provided dex will run inplaintext (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 reflectionreflection: trueconnectors:# 指定账号连接器。这⾥配置的是openldap- type: ldapname: OpenLDAPid: ldapconfig:# The following configurations seem to work with OpenLDAP:## 1) Plain LDAP, without TLS:host: openldap:1389insecureNoSSL: 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=orgbindPW: adminpasswordusernamePrompt: LDAP ⽤户名userSearch:baseDN: ou=users,dc=example,dc=orgfilter: "(objectClass=person)"username: cn# "DN" (case sensitive) is a special attribute name. Itindicates that# this value should be taken from the entity's DN not anattribute on# the entity.idAttr: DNemailAttr: mailnameAttr: cngroupSearch:baseDN: ou=Groups,dc=example,dc=orgfilter: "(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: DNgroupAttr: member# The group name should be the "cn" value.nameAttr: cn# 超时时间设置expiry:deviceRequests: "5m"signingKeys: "6h"idTokens: "24h"refreshTokens:reuseInterval: "30s"validIfNotUsedFor: "2160h" # 90 daysabsoluteLifetime: "3960h" # 165 days

issuer:配置dex的服务地址。

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

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

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

web:dex认证http服务。

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

connectors:配置账号连接器。

expiry:配置超时时间

登录

# 流程说明

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_type=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()// 连接dexprovider, 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端点获取tokenoauth2Token, 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) {// 连接dexprovider, err := oidc.NewProvider(ctx, "http://localhost:5556/dex")if err != nil {t.Error(err)}// 验证idTokenidTokenVerifier := 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" // 局部会话的refreshTokenrefreshTokenDex := "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的tokenoauth2Config := &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)}

登出

# 流程说明

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),解决了⽤户只需要登录⼀次,就能访问所有系统的资源。⽤户退出登录(主动退出或超时⾃动退出),所有系统资源都不能访问。

以上就是本期

DEX在SSO中的实践

如果大家感兴趣可以试一试

也欢迎关注云原生的你

加入我们一起讨论哦⬇


本期作者

刘健


原文链接: http://mp.weixin.qq.com/s?__biz=Mzg5MzUyOTgwMQ==&mid=2247519327&idx=1&sn=f774a3eb6c87dd5ae7cad90258f2786e&chksm=c02fb7f9f7583eef068ec5b8a650476e94b9ff385ba01dec628a6d0c19dfb8f76b0978fd8934#rd