Zadig v1.18.0 文档
  • 快速入门

    • 什么是 Zadig
    • 核心概念
    • 快速安装
    • 第一个项目
    • 接入准备
  • 安装与配置

    • 安装简介
    • All in One 模式
    • 使用 Sealos 一键安装
    • 基于 Helm 命令安装
    • 基于现有 Kubernetes 安装
    • 本地安装
    • 兼容性列表
  • 最佳实践
  • 项目

    • 项目

      • 项目介绍
      • K8s YAML 项目
      • K8s Helm Chart 项目
      • K8s 托管项目
      • 主机项目
      • 权限管理
    • 工作流

      • 简介
      • 产品工作流
      • 自定义工作流
      • 工作流任务
      • 工作流审批
      • 工作流通知
      • 工作流触发器
    • 环境

      • K8s YAML 环境
      • K8s Helm Chart 环境
      • K8s 托管环境
      • 主机环境
      • 服务详情
    • 服务

      • 简介
      • K8s YAML 测试服务
      • K8s Helm Chart 服务
      • K8s 托管服务
      • 服务组件
      • 服务变量
      • 主机服务
    • 构建
    • 测试
    • 代码扫描
  • 模板库

    • K8s YAML 模板
    • Helm Chart 模板
    • Dockerfile 模板
    • 构建模板
  • 工作流

    • 工作流的触发
    • 工作流的构建缓存
    • 工作流任务的并发运行
    • 工作流的镜像版本回退
    • 工作流的变量传递
  • 环境

    • 环境的负载均衡
    • 配置多套环境
    • 自测联调环境
  • 测试中心

    • 测试中心
    • 测试工具实践
    • Apipost 测试平台接入
    • Apifox 测试平台接入
  • 交付中心

    • 交付物追踪
  • 数据视图

    • 数据概览
    • 效能洞察
  • 仪表盘

    • 仪表盘
  • 集成管理

    • 账号系统

      • LDAP
      • OAuth 2.0
      • GitHub
    • 项目管理
    • 代码源

      • 简介
      • GitLab 集成
      • GitHub 集成
      • Gerrit 集成
      • Gitee 公有云集成
      • 其他代码源集成
    • CI/CD 工具
    • 代码扫描
    • 镜像仓库
    • 对象存储
    • Helm 仓库
    • Kubernetes 集群
    • 主机管理
    • 其他系统
    • 软件包管理
    • 自定义镜像管理
  • 系统设置

    • 系统配置
    • 用户管理
    • 公告管理
    • 操作日志
    • 通知设置
  • 账号设置

    • 偏好设置
  • 开发者中心

    • OpenAPI 开发文档

      • API 调用方式
      • 交付中心
      • 工作流
      • 效能洞察
    • 外部系统接入开发文档

      • 自定义账号系统
      • 自定义工作流任务
    • VS Code Toolkit 插件

      • 使用指南
  • 常见问题

    • 产品使用 FAQ
    • 部署运维 FAQ
    • 集群故障 FAQ
    • 认证鉴权 FAQ
    • 诊断手册
  • 版本发布历史

    • Zadig Roadmap
    • v1.18.0 Release Notes
    • v1.17.0 Release Notes
    • v1.16.0 Release Notes
    • v1.15.0 Release Notes
    • v1.14.0 Release Notes
    • v1.13.0 Release Notes
    • v1.12.0 Release Notes
    • v1.11.0 Release Notes
    • v1.10.0 Release Notes
    • v1.9.0 Release Notes
    • v1.8.0 Release Notes
    • v1.7.1 Release Notes
    • v1.7.0 Release Notes
    • v1.6.0 Release Notes
    • v1.5.0 Release Notes
    • v1.4.0 Release Notes
    • v1.3.1 Release Notes
    • v1.3.0 Release Notes
    • v1.2.0 Release Notes
    • v1.1.0 Release Notes
    • v1.0.0 Release Notes
    • v1.0.0-rc.1 Release Notes
  • 术语表

    • 术语表

目前 Zadig 用户系统采用了开源项目 Dex 作为身份连接器,但 Dex 支持的协议列表(Dex 支持的协议)有限,Zadig 则基于 Dex 官方库实现了一些标准的扩展,如 OAuth 协议等。如果用户自身的账号系统在 Zadig 官方支持之外,可以通过 Fork koderover/dex 编写 Connector 实现自定义账号系统的集成。

自定义账号系统登录流程图

account_custom

自定义账号系统集成流程

步骤 1 :编写自定义 Dex Connector

  1. fork koderover/dex
  2. 编写 Dex 自定义 Connector (基于最新 Branch release-1.10.0)
  • 目前 Dex Connector 接口定义于 dex/connector/connector.go 中,官方抽象了PasswordConnector、CallbackConnector、RefreshConnector、SAMLConnector 四种接口,用户可以根据自己账号系统情况实现自己的 Connector,参考 dex/connector 的各种 Connector 组合实现这四种接口。

以下是对各个接口的简要说明,根据账号系统登录的具体交互方式需要实现对应的接口:

接口名使用说明目前可参考已有实现
PasswordConnector1. 简单使用用户名密码方式登录授权获取用户信息的情况。
2. Dex 和账号系统的交互是同步的情况
keystone、atlassiancrowed、ldap、mock/passwordConnector、passwordDB(位于 dex/server/server.go)
CallbackConnector1. 通过重定向获取用户信息的情况
2. Dex 和账号系统的交互是异步的情况
mock/Callback、bitbucketcloud、authproxy、gitea、github、gitlab、google、linkedin、microsoft、oauth、oidc、openshift
SAMLConnector实现 HTTP POST 绑定的 SAML 连接器。RelayState 由服务器处理saml
RefreshConnector实现后可以更新客户端 claimsmock/Callback、bitbucketcloud、atlassiancrowed、gitea、github、gitlab、google、ldap、linkedin、microsoft、mock/passwordConnector、oidc、passwordDB(位于dex/server/server.go)
  • 将添加的自定义 connector type 和名称加入 dex/server/server.go 的 ConnectorsConfig 中。

参考例子

以 dex/connector 目录下的 OAuth connector 为例讲解。

  1. 实现 connector interface

此 connector 实现了 CallbackConnector interface。

以下为 OAuth connector 代码
详情
package oauth

import (
   "context"
   "crypto/tls"
   "crypto/x509"
   "encoding/base64"
   "encoding/json"
   "errors"
   "fmt"
   "io/ioutil"
   "net"
   "net/http"
   "strings"
   "time"

   "golang.org/x/oauth2"

   "github.com/dexidp/dex/connector"
   "github.com/dexidp/dex/pkg/log"
)

type oauthConnector struct {
   clientID             string
   clientSecret         string
   redirectURI          string
   tokenURL             string
   authorizationURL     string
   userInfoURL          string
   scopes               []string
   userIDKey            string
   userNameKey          string
   preferredUsernameKey string
   emailKey             string
   emailVerifiedKey     string
   groupsKey            string
   httpClient           *http.Client
   logger               log.Logger
}

type connectorData struct {
   AccessToken string
}

type Config struct {
   ClientID           string   `json:"clientID"`
   ClientSecret       string   `json:"clientSecret"`
   RedirectURI        string   `json:"redirectURI"`
   TokenURL           string   `json:"tokenURL"`
   AuthorizationURL   string   `json:"authorizationURL"`
   UserInfoURL        string   `json:"userInfoURL"`
   Scopes             []string `json:"scopes"`
   RootCAs            []string `json:"rootCAs"`
   InsecureSkipVerify bool     `json:"insecureSkipVerify"`
   UserIDKey          string   `json:"userIDKey"` // defaults to "id"
   ClaimMapping       struct {
      UserNameKey          string `json:"userNameKey"`          // defaults to "user_name"
      PreferredUsernameKey string `json:"preferredUsernameKey"` // defaults to "preferred_username"
      GroupsKey            string `json:"groupsKey"`            // defaults to "groups"
      EmailKey             string `json:"emailKey"`             // defaults to "email"
      EmailVerifiedKey     string `json:"emailVerifiedKey"`     // defaults to "email_verified"
   } `json:"claimMapping"`
}

func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
   var err error

   if c.UserIDKey == "" {
      c.UserIDKey = "id"
   }

   if c.ClaimMapping.UserNameKey == "" {
      c.ClaimMapping.UserNameKey = "user_name"
   }

   if c.ClaimMapping.PreferredUsernameKey == "" {
      c.ClaimMapping.PreferredUsernameKey = "preferred_username"
   }

   if c.ClaimMapping.GroupsKey == "" {
      c.ClaimMapping.GroupsKey = "groups"
   }

   if c.ClaimMapping.EmailKey == "" {
      c.ClaimMapping.EmailKey = "email"
   }

   if c.ClaimMapping.EmailVerifiedKey == "" {
      c.ClaimMapping.EmailVerifiedKey = "email_verified"
   }
   oauthConn := &oauthConnector{
      clientID:             c.ClientID,
      clientSecret:         c.ClientSecret,
      tokenURL:             c.TokenURL,
      authorizationURL:     c.AuthorizationURL,
      userInfoURL:          c.UserInfoURL,
      scopes:               c.Scopes,
      redirectURI:          c.RedirectURI,
      logger:               logger,
      userIDKey:            c.UserIDKey,
      userNameKey:          c.ClaimMapping.UserNameKey,
      preferredUsernameKey: c.ClaimMapping.PreferredUsernameKey,
      groupsKey:            c.ClaimMapping.GroupsKey,
      emailKey:             c.ClaimMapping.EmailKey,
      emailVerifiedKey:     c.ClaimMapping.EmailVerifiedKey,
   }

   oauthConn.httpClient, err = newHTTPClient(c.RootCAs, c.InsecureSkipVerify)
   if err != nil {
      return nil, err
   }

   return oauthConn, err
}

func newHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, error) {
   pool, err := x509.SystemCertPool()
   if err != nil {
      return nil, err
   }

   tlsConfig := tls.Config{RootCAs: pool, InsecureSkipVerify: insecureSkipVerify}
   for _, rootCA := range rootCAs {
      rootCABytes, err := ioutil.ReadFile(rootCA)
      if err != nil {
         return nil, fmt.Errorf("failed to read root-ca: %v", err)
      }
      if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) {
         return nil, fmt.Errorf("no certs found in root CA file %q", rootCA)
      }
   }

   return &http.Client{
      Transport: &http.Transport{
         TLSClientConfig: &tlsConfig,
         Proxy:           http.ProxyFromEnvironment,
         DialContext: (&net.Dialer{
            Timeout:   30 * time.Second,
            KeepAlive: 30 * time.Second,
            DualStack: true,
         }).DialContext,
         MaxIdleConns:          100,
         IdleConnTimeout:       90 * time.Second,
         TLSHandshakeTimeout:   10 * time.Second,
         ExpectContinueTimeout: 1 * time.Second,
      },
   }, nil
}

func (c *oauthConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
   if c.redirectURI != callbackURL {
      c.logger.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
      return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
   }

   oauth2Config := &oauth2.Config{
      ClientID:     c.clientID,
      ClientSecret: c.clientSecret,
      Endpoint:     oauth2.Endpoint{TokenURL: c.tokenURL, AuthURL: c.authorizationURL},
      RedirectURL:  c.redirectURI,
      Scopes:       c.scopes,
   }

   return oauth2Config.AuthCodeURL(state), nil
}

func (c *oauthConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
   q := r.URL.Query()
   if errType := q.Get("error"); errType != "" {
      c.logger.Errorf("get error:%s", q.Get("error_description"))
      return identity, errors.New(q.Get("error_description"))
   }

   oauth2Config := &oauth2.Config{
      ClientID:     c.clientID,
      ClientSecret: c.clientSecret,
      Endpoint:     oauth2.Endpoint{TokenURL: c.tokenURL, AuthURL: c.authorizationURL},
      RedirectURL:  c.redirectURI,
      Scopes:       c.scopes,
   }

   ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient)

   token, err := oauth2Config.Exchange(ctx, q.Get("code"))
   if err != nil {
      c.logger.Errorf("OAuth connector: failed to get token: %v", err)
      return identity, fmt.Errorf("OAuth connector: failed to get token: %v", err)
   }

   client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))

   userInfoResp, err := client.Get(c.userInfoURL)
   if err != nil {
      c.logger.Errorf("OAuth Connector: failed to execute request to userinfo: %v", err)
      return identity, fmt.Errorf("OAuth Connector: failed to execute request to userinfo: %v", err)
   }
   defer userInfoResp.Body.Close()

   if userInfoResp.StatusCode != http.StatusOK {
      c.logger.Errorf("OAuth Connector: failed to execute request to userinfo: status %d", userInfoResp.StatusCode)
      return identity, fmt.Errorf("OAuth Connector: failed to execute request to userinfo: status %d", userInfoResp.StatusCode)
   }

   var userInfoResult map[string]interface{}
   err = json.NewDecoder(userInfoResp.Body).Decode(&userInfoResult)
   if err != nil {
      c.logger.Errorf("OAuth Connector: failed to parse userinfo: %v", err)
      return identity, fmt.Errorf("OAuth Connector: failed to parse userinfo: %v", err)
   }

   userID, found := userInfoResult[c.userIDKey].(string)
   if !found {
      c.logger.Errorf("OAuth Connector: not found %v claim", c.userIDKey)
      return identity, fmt.Errorf("OAuth Connector: not found %v claim", c.userIDKey)
   }

   identity.UserID = userID
   identity.Username, _ = userInfoResult[c.userNameKey].(string)
   identity.PreferredUsername, _ = userInfoResult[c.preferredUsernameKey].(string)
   identity.Email, _ = userInfoResult[c.emailKey].(string)
   identity.EmailVerified, _ = userInfoResult[c.emailVerifiedKey].(bool)

   if s.Groups {
      groups := map[string]struct{}{}

      c.addGroupsFromMap(groups, userInfoResult)
      c.addGroupsFromToken(groups, token.AccessToken)

      for groupName := range groups {
         identity.Groups = append(identity.Groups, groupName)
      }
   }

   if s.OfflineAccess {
      data := connectorData{AccessToken: token.AccessToken}
      connData, err := json.Marshal(data)
      if err != nil {
         c.logger.Errorf("OAuth Connector: failed to parse connector data for offline access: %v", err)
         return identity, fmt.Errorf("OAuth Connector: failed to parse connector data for offline access: %v", err)
      }
      identity.ConnectorData = connData
   }
   return identity, nil
}

func (c *oauthConnector) addGroupsFromMap(groups map[string]struct{}, result map[string]interface{}) error {
   groupsClaim, ok := result[c.groupsKey].([]interface{})
   if !ok {
      return errors.New("cannot convert to slice")
   }

   for _, group := range groupsClaim {
      if groupString, ok := group.(string); ok {
         groups[groupString] = struct{}{}
      }
   }

   return nil
}

func (c *oauthConnector) addGroupsFromToken(groups map[string]struct{}, token string) error {
   parts := strings.Split(token, ".")
   if len(parts) < 2 {
      return errors.New("invalid token")
   }

   decoded, err := decode(parts[1])
   if err != nil {
      return err
   }

   var claimsMap map[string]interface{}
   err = json.Unmarshal(decoded, &claimsMap)
   if err != nil {
      return err
   }

   return c.addGroupsFromMap(groups, claimsMap)
}

func decode(seg string) ([]byte, error) {
   if l := len(seg) % 4; l > 0 {
      seg += strings.Repeat("=", 4-l)
   }

   return base64.URLEncoding.DecodeString(seg)
}
  1. 将添加的自定义 connector type 和名称加入 dex/server/server.go 的 ConnectorsConfig 中,如下所示。
详情
// ConnectorsConfig variable provides an easy way to return a config struct
// depending on the connector type.
var ConnectorsConfig = map[string]func() ConnectorConfig{
   "keystone":        func() ConnectorConfig { return new(keystone.Config) },
   "mockCallback":    func() ConnectorConfig { return new(mock.CallbackConfig) },
   "mockPassword":    func() ConnectorConfig { return new(mock.PasswordConfig) },
   "ldap":            func() ConnectorConfig { return new(ldap.Config) },
   "gitea":           func() ConnectorConfig { return new(gitea.Config) },
   "github":          func() ConnectorConfig { return new(github.Config) },
   "gitlab":          func() ConnectorConfig { return new(gitlab.Config) },
   "google":          func() ConnectorConfig { return new(google.Config) },
   "oidc":            func() ConnectorConfig { return new(oidc.Config) },
   // 这个位置需要加自定义 connector type
   "oauth":           func() ConnectorConfig { return new(oauth.Config) },
   "saml":            func() ConnectorConfig { return new(saml.Config) },
   "authproxy":       func() ConnectorConfig { return new(authproxy.Config) },
   "linkedin":        func() ConnectorConfig { return new(linkedin.Config) },
   "microsoft":       func() ConnectorConfig { return new(microsoft.Config) },
   "bitbucket-cloud": func() ConnectorConfig { return new(bitbucketcloud.Config) },
   "openshift":       func() ConnectorConfig { return new(openshift.Config) },
   "atlassian-crowd": func() ConnectorConfig { return new(atlassiancrowd.Config) },
   // Keep around for backwards compatibility.
   "samlExperimental": func() ConnectorConfig { return new(saml.Config) },
}

步骤 2:构建自定义的 Dex 镜像

  1. 更改根目录下 Makefile 中的 DOCKER_REPO 变量为自己的公开镜像仓库
  2. 运行 make docker-image 构建镜像,并上传镜像至自己的公开镜像仓库
  3. 用自定义的镜像覆盖 Dex 的原始镜像
kubectl get deployment -n <Zadig Namespace> | grep dex # 获得 Dex 的 deployment
kubectl set image deployment/<Dex 的 deployment> dex=<新生成的 Dex 镜像> --record -n <Zadig Namespace> # 替换 Dex 镜像

步骤 3:在 Zadig 平台中录入配置

登录 Zadig 平台后,在系统设置 -> 账号系统集成-> 自定义账户类型录入 YAML 配置,如下图所示。

YAML 配置由 Connector 中的 Config 结构体生成。

account_custom

步骤 4:使用自定义账号系统登录 Zadig

访问 Zadig 登录页面,点击第三方登录,如下图所示。跳转到对应的登录页面,输入用户名密码即可登录 Zadig 系统。

account_custom

[可选]设置为默认账号系统

参考设置默认账号系统。

Next
自定义工作流任务