自定义账号系统

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

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

account_custom

自定义账号系统集成流程

步骤 1 :编写自定义 Dex Connector

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

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

接口名使用说明目前可参考已有实现
PasswordConnector1. 简单使用用户名密码方式登录授权获取用户信息的情况。
2. Dex 和账号系统的交互是同步的情况
keystone、atlassiancrowed、ldap、mock/passwordConnector、passwordDB(位于 dex/server/server.go自定义账号系统 - 图7 (opens new window))
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 代码

点击查看

  1. package oauth
  2. import (
  3. "context"
  4. "crypto/tls"
  5. "crypto/x509"
  6. "encoding/base64"
  7. "encoding/json"
  8. "errors"
  9. "fmt"
  10. "io/ioutil"
  11. "net"
  12. "net/http"
  13. "strings"
  14. "time"
  15. "golang.org/x/oauth2"
  16. "github.com/dexidp/dex/connector"
  17. "github.com/dexidp/dex/pkg/log"
  18. )
  19. type oauthConnector struct {
  20. clientID string
  21. clientSecret string
  22. redirectURI string
  23. tokenURL string
  24. authorizationURL string
  25. userInfoURL string
  26. scopes []string
  27. userIDKey string
  28. userNameKey string
  29. preferredUsernameKey string
  30. emailKey string
  31. emailVerifiedKey string
  32. groupsKey string
  33. httpClient *http.Client
  34. logger log.Logger
  35. }
  36. type connectorData struct {
  37. AccessToken string
  38. }
  39. type Config struct {
  40. ClientID string `json:"clientID"`
  41. ClientSecret string `json:"clientSecret"`
  42. RedirectURI string `json:"redirectURI"`
  43. TokenURL string `json:"tokenURL"`
  44. AuthorizationURL string `json:"authorizationURL"`
  45. UserInfoURL string `json:"userInfoURL"`
  46. Scopes []string `json:"scopes"`
  47. RootCAs []string `json:"rootCAs"`
  48. InsecureSkipVerify bool `json:"insecureSkipVerify"`
  49. UserIDKey string `json:"userIDKey"` // defaults to "id"
  50. ClaimMapping struct {
  51. UserNameKey string `json:"userNameKey"` // defaults to "user_name"
  52. PreferredUsernameKey string `json:"preferredUsernameKey"` // defaults to "preferred_username"
  53. GroupsKey string `json:"groupsKey"` // defaults to "groups"
  54. EmailKey string `json:"emailKey"` // defaults to "email"
  55. EmailVerifiedKey string `json:"emailVerifiedKey"` // defaults to "email_verified"
  56. } `json:"claimMapping"`
  57. }
  58. func (c *Config) Open(id string, logger log.Logger) (connector.Connector, error) {
  59. var err error
  60. if c.UserIDKey == "" {
  61. c.UserIDKey = "id"
  62. }
  63. if c.ClaimMapping.UserNameKey == "" {
  64. c.ClaimMapping.UserNameKey = "user_name"
  65. }
  66. if c.ClaimMapping.PreferredUsernameKey == "" {
  67. c.ClaimMapping.PreferredUsernameKey = "preferred_username"
  68. }
  69. if c.ClaimMapping.GroupsKey == "" {
  70. c.ClaimMapping.GroupsKey = "groups"
  71. }
  72. if c.ClaimMapping.EmailKey == "" {
  73. c.ClaimMapping.EmailKey = "email"
  74. }
  75. if c.ClaimMapping.EmailVerifiedKey == "" {
  76. c.ClaimMapping.EmailVerifiedKey = "email_verified"
  77. }
  78. oauthConn := &oauthConnector{
  79. clientID: c.ClientID,
  80. clientSecret: c.ClientSecret,
  81. tokenURL: c.TokenURL,
  82. authorizationURL: c.AuthorizationURL,
  83. userInfoURL: c.UserInfoURL,
  84. scopes: c.Scopes,
  85. redirectURI: c.RedirectURI,
  86. logger: logger,
  87. userIDKey: c.UserIDKey,
  88. userNameKey: c.ClaimMapping.UserNameKey,
  89. preferredUsernameKey: c.ClaimMapping.PreferredUsernameKey,
  90. groupsKey: c.ClaimMapping.GroupsKey,
  91. emailKey: c.ClaimMapping.EmailKey,
  92. emailVerifiedKey: c.ClaimMapping.EmailVerifiedKey,
  93. }
  94. oauthConn.httpClient, err = newHTTPClient(c.RootCAs, c.InsecureSkipVerify)
  95. if err != nil {
  96. return nil, err
  97. }
  98. return oauthConn, err
  99. }
  100. func newHTTPClient(rootCAs []string, insecureSkipVerify bool) (*http.Client, error) {
  101. pool, err := x509.SystemCertPool()
  102. if err != nil {
  103. return nil, err
  104. }
  105. tlsConfig := tls.Config{RootCAs: pool, InsecureSkipVerify: insecureSkipVerify}
  106. for _, rootCA := range rootCAs {
  107. rootCABytes, err := ioutil.ReadFile(rootCA)
  108. if err != nil {
  109. return nil, fmt.Errorf("failed to read root-ca: %v", err)
  110. }
  111. if !tlsConfig.RootCAs.AppendCertsFromPEM(rootCABytes) {
  112. return nil, fmt.Errorf("no certs found in root CA file %q", rootCA)
  113. }
  114. }
  115. return &http.Client{
  116. Transport: &http.Transport{
  117. TLSClientConfig: &tlsConfig,
  118. Proxy: http.ProxyFromEnvironment,
  119. DialContext: (&net.Dialer{
  120. Timeout: 30 * time.Second,
  121. KeepAlive: 30 * time.Second,
  122. DualStack: true,
  123. }).DialContext,
  124. MaxIdleConns: 100,
  125. IdleConnTimeout: 90 * time.Second,
  126. TLSHandshakeTimeout: 10 * time.Second,
  127. ExpectContinueTimeout: 1 * time.Second,
  128. },
  129. }, nil
  130. }
  131. func (c *oauthConnector) LoginURL(scopes connector.Scopes, callbackURL, state string) (string, error) {
  132. if c.redirectURI != callbackURL {
  133. c.logger.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
  134. return "", fmt.Errorf("expected callback URL %q did not match the URL in the config %q", callbackURL, c.redirectURI)
  135. }
  136. oauth2Config := &oauth2.Config{
  137. ClientID: c.clientID,
  138. ClientSecret: c.clientSecret,
  139. Endpoint: oauth2.Endpoint{TokenURL: c.tokenURL, AuthURL: c.authorizationURL},
  140. RedirectURL: c.redirectURI,
  141. Scopes: c.scopes,
  142. }
  143. return oauth2Config.AuthCodeURL(state), nil
  144. }
  145. func (c *oauthConnector) HandleCallback(s connector.Scopes, r *http.Request) (identity connector.Identity, err error) {
  146. q := r.URL.Query()
  147. if errType := q.Get("error"); errType != "" {
  148. c.logger.Errorf("get error:%s", q.Get("error_description"))
  149. return identity, errors.New(q.Get("error_description"))
  150. }
  151. oauth2Config := &oauth2.Config{
  152. ClientID: c.clientID,
  153. ClientSecret: c.clientSecret,
  154. Endpoint: oauth2.Endpoint{TokenURL: c.tokenURL, AuthURL: c.authorizationURL},
  155. RedirectURL: c.redirectURI,
  156. Scopes: c.scopes,
  157. }
  158. ctx := context.WithValue(r.Context(), oauth2.HTTPClient, c.httpClient)
  159. token, err := oauth2Config.Exchange(ctx, q.Get("code"))
  160. if err != nil {
  161. c.logger.Errorf("OAuth connector: failed to get token: %v", err)
  162. return identity, fmt.Errorf("OAuth connector: failed to get token: %v", err)
  163. }
  164. client := oauth2.NewClient(ctx, oauth2.StaticTokenSource(token))
  165. userInfoResp, err := client.Get(c.userInfoURL)
  166. if err != nil {
  167. c.logger.Errorf("OAuth Connector: failed to execute request to userinfo: %v", err)
  168. return identity, fmt.Errorf("OAuth Connector: failed to execute request to userinfo: %v", err)
  169. }
  170. defer userInfoResp.Body.Close()
  171. if userInfoResp.StatusCode != http.StatusOK {
  172. c.logger.Errorf("OAuth Connector: failed to execute request to userinfo: status %d", userInfoResp.StatusCode)
  173. return identity, fmt.Errorf("OAuth Connector: failed to execute request to userinfo: status %d", userInfoResp.StatusCode)
  174. }
  175. var userInfoResult map[string]interface{}
  176. err = json.NewDecoder(userInfoResp.Body).Decode(&userInfoResult)
  177. if err != nil {
  178. c.logger.Errorf("OAuth Connector: failed to parse userinfo: %v", err)
  179. return identity, fmt.Errorf("OAuth Connector: failed to parse userinfo: %v", err)
  180. }
  181. userID, found := userInfoResult[c.userIDKey].(string)
  182. if !found {
  183. c.logger.Errorf("OAuth Connector: not found %v claim", c.userIDKey)
  184. return identity, fmt.Errorf("OAuth Connector: not found %v claim", c.userIDKey)
  185. }
  186. identity.UserID = userID
  187. identity.Username, _ = userInfoResult[c.userNameKey].(string)
  188. identity.PreferredUsername, _ = userInfoResult[c.preferredUsernameKey].(string)
  189. identity.Email, _ = userInfoResult[c.emailKey].(string)
  190. identity.EmailVerified, _ = userInfoResult[c.emailVerifiedKey].(bool)
  191. if s.Groups {
  192. groups := map[string]struct{}{}
  193. c.addGroupsFromMap(groups, userInfoResult)
  194. c.addGroupsFromToken(groups, token.AccessToken)
  195. for groupName := range groups {
  196. identity.Groups = append(identity.Groups, groupName)
  197. }
  198. }
  199. if s.OfflineAccess {
  200. data := connectorData{AccessToken: token.AccessToken}
  201. connData, err := json.Marshal(data)
  202. if err != nil {
  203. c.logger.Errorf("OAuth Connector: failed to parse connector data for offline access: %v", err)
  204. return identity, fmt.Errorf("OAuth Connector: failed to parse connector data for offline access: %v", err)
  205. }
  206. identity.ConnectorData = connData
  207. }
  208. return identity, nil
  209. }
  210. func (c *oauthConnector) addGroupsFromMap(groups map[string]struct{}, result map[string]interface{}) error {
  211. groupsClaim, ok := result[c.groupsKey].([]interface{})
  212. if !ok {
  213. return errors.New("cannot convert to slice")
  214. }
  215. for _, group := range groupsClaim {
  216. if groupString, ok := group.(string); ok {
  217. groups[groupString] = struct{}{}
  218. }
  219. }
  220. return nil
  221. }
  222. func (c *oauthConnector) addGroupsFromToken(groups map[string]struct{}, token string) error {
  223. parts := strings.Split(token, ".")
  224. if len(parts) < 2 {
  225. return errors.New("invalid token")
  226. }
  227. decoded, err := decode(parts[1])
  228. if err != nil {
  229. return err
  230. }
  231. var claimsMap map[string]interface{}
  232. err = json.Unmarshal(decoded, &claimsMap)
  233. if err != nil {
  234. return err
  235. }
  236. return c.addGroupsFromMap(groups, claimsMap)
  237. }
  238. func decode(seg string) ([]byte, error) {
  239. if l := len(seg) % 4; l > 0 {
  240. seg += strings.Repeat("=", 4-l)
  241. }
  242. return base64.URLEncoding.DecodeString(seg)
  243. }
  1. 将添加的自定义 connector type 和名称加入 dex/server/server.go 的 ConnectorsConfig 中,如下所示。

点击查看

  1. // ConnectorsConfig variable provides an easy way to return a config struct
  2. // depending on the connector type.
  3. var ConnectorsConfig = map[string]func() ConnectorConfig{
  4. "keystone": func() ConnectorConfig { return new(keystone.Config) },
  5. "mockCallback": func() ConnectorConfig { return new(mock.CallbackConfig) },
  6. "mockPassword": func() ConnectorConfig { return new(mock.PasswordConfig) },
  7. "ldap": func() ConnectorConfig { return new(ldap.Config) },
  8. "gitea": func() ConnectorConfig { return new(gitea.Config) },
  9. "github": func() ConnectorConfig { return new(github.Config) },
  10. "gitlab": func() ConnectorConfig { return new(gitlab.Config) },
  11. "google": func() ConnectorConfig { return new(google.Config) },
  12. "oidc": func() ConnectorConfig { return new(oidc.Config) },
  13. // 这个位置需要加自定义 connector type
  14. "oauth": func() ConnectorConfig { return new(oauth.Config) },
  15. "saml": func() ConnectorConfig { return new(saml.Config) },
  16. "authproxy": func() ConnectorConfig { return new(authproxy.Config) },
  17. "linkedin": func() ConnectorConfig { return new(linkedin.Config) },
  18. "microsoft": func() ConnectorConfig { return new(microsoft.Config) },
  19. "bitbucket-cloud": func() ConnectorConfig { return new(bitbucketcloud.Config) },
  20. "openshift": func() ConnectorConfig { return new(openshift.Config) },
  21. "atlassian-crowd": func() ConnectorConfig { return new(atlassiancrowd.Config) },
  22. // Keep around for backwards compatibility.
  23. "samlExperimental": func() ConnectorConfig { return new(saml.Config) },
  24. }

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

  1. 更改根目录下 Makefile自定义账号系统 - 图8 (opens new window) 中的 DOCKER_REPO 变量为自己的公开镜像仓库
  2. 运行 make docker-image 构建镜像,并上传镜像至自己的公开镜像仓库
  3. 用自定义的镜像覆盖 Dex 的原始镜像
  1. kubectl get deployment -n <Zadig Namespace> | grep dex # 获得 Dex 的 deployment
  2. 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

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

参考设置默认账号系统