暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

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

1065





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: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-platform￾service/cert/dex-server.crt
      # tlsKey: Users/liujian/work/006-yhplatform/code/yunhang-platform￾service/cert/dex-server.key
      #
      # # Client auth CA.
      # tlsClientCA: Users/liujian/work/006-yhplatform/code/yunhang-platform￾service/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:配置超时时间


       登录 


      # 流程说明



      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)
                }


                 登出 


                # 流程说明



                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中的实践

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

                  也欢迎关注云原生的你

                  加入我们一起讨论哦⬇

                  本期作者 

                   刘健 



                  更多精彩内容 





                  了解云基地,就现在!


                  IT技术哪家

                  神州数码最在行

                  行业新星后起之秀

                  历史虽不长,但实 力 强




                  文章转载自神州数码云基地,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

                  评论