Zadig 如何用 Dex 实现账号系统管理

Zadig 账号系统选型和架构设计实践

cover.png

确保企业应用的安全性,用户认证与授权是不可或缺的核心环节。在构建安全的企业级应用时,一个稳健的认证与授权机制显得尤为关键。

Zadig 账号系统以其强大的管理能力,超越了基本的内部账号管理,进一步升级以满足企业级需求。我们增强了账号授权的接入功能,除支持自身内部账号管理能力外,额外支持 LDAP、OAuth 等广泛认可的账号授权标准,同时实现了与 Active Directory、GitHub、GitLab 等主流平台的无缝集成,全面满足企业级应用的安全性和通用性需求。

以下是我们选型策略和架构设计实践,与社区读者共享,以期为构建更安全的应用程序提供参考和启发。

# 账号方案选型

研究市面上的主流方案后,针对 Dex、原生 Client、Keycloak,我们做了如下对比:

方案 Dex 使用原生 Client Keycloak
语言 Go Go Java
扩展性 高,可通过自行实现 connector 扩展 需自己实现 高
开发成本 低 高 低
用户同步 目前不支持 sync 模式,可自己扩展 需自己实现 支持 LDAP 用户同步,其它不支持
配置热更新 支持 可自己实现 支持
目前支持丰富度 已有丰富的 connector 需自己实现 已有丰富的 connector
后期维护成本 低,后期有强有力的社区支持 高,扩展性需自己保障 低
云原生标准适配度 K8s 推荐,以后成为标准可能性较高 低 低

Zadig 充分考虑扩展性、维护成本、云原生友好度等因素最终选择用 Dex 作为基础组件。

# Dex 组件介绍

Dex 是来自 CoreOS 的基于 OpenID Connect 的开源身份认证服务解决方案。内置的 Connectors 包括 LDAP、GitHub、GitLab、Google、OIDC 等。对于非标准的登录方式,用户也可以通过自定义 Connector 来实现接入 Zadig。

Dex (opens new window) 使用 OpenID Connect 来驱动应用程序的身份验证,当用户通过 Dex 登录时,该用户的身份通常存储在另一个用户管理系统中:LDAP 目录,GitHub 组织等。Dex 充当客户端应用程序和上游身份提供者之间的中介。客户端只需要了解 OpenID Connect 即可查询 Dex,而 Dex 实现了一系列用于查询其他用户管理系统的协议。

"连接器"是 Dex 用于根据一个身份提供者对用户进行身份验证的策略。Dex 实现了针对特定平台(例如 GitHub,LinkedIn 和 Microsoft)以及已建立的协议(例如 LDAP 和 SAML)的连接器。

# 账号系统

# 技术选择

在完成了第三方系统登录的组件选型后,剩下的问题就是如何将 Dex 提供的第三方用户信息加入 zadig 自己的用户体系中, 完成 Zadig 用户体系的打造,根据 Zadig 系统的实际要求,我们确定了以下的技术方案:

  1. 多个外部系统中的同名用户,不视为相同用户
  2. 所有账号系统,均使用 Zadig 的 Token 进行认证管理
  3. 使用 UID 信息作为用户的唯一主键,并且和权限、消息等进行关联
  4. Zadig 自身的用户体系认证采用无状态的方式来实现,相比有状态模式,服务端控制力和压力更小,数据迁移成本也会更低。

# 架构设计

用户登录环节主要涉及到的组件:

  • Zadig aslan 服务 user 模块:主要负责 Zadig 平台用户账号管理(包括 Zadig 自身平台账号和第三方同步过来的账号),用户登录管理以及用户授权信息。
  • Dex:主要负责作为链接器链接第三方账号系统,以及存储第三方账号的配置。
  • Upstream ldp:第三方账号系统

用户认证环节主要涉及到的组件:

  • Gloo Edge:Zadig 的网关,会拦截进入 Zadig 后台的流量,并且将流量转发给 user 服务进行认证
  • Zadig aslan 服务:Zadig 后台核心业务服务

# 第三方登录流程

第三方账号的登录逻辑如下:

  1. 访问 Zadig 系统的第三方登录页面(登录页内嵌在 Dex 中),输入用户名和密码后发送到第三方账号系统进行校验
  2. 第三方账号系统校验成功且同意授权 Zadig 后,携带生成的 authCode 访问 Zadig 的回调地址
  3. aslan 服务收到请求后用 authCode 换取 accessToken 并解析用户信息
  4. 刷新第三方账号的登录信息,并生成其 Token 返回登录首页,登录成功

# 数据库模型

用户服务的数据库模型节选:

CREATE TABLE `user_login`  (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `uid` varchar(64) NOT NULL DEFAULT '0' COMMENT '用户id',
  `login_id` varchar(64) NOT NULL DEFAULT '0' COMMENT '用户登录id,如账号名',
  `login_type` int(4) unsigned NOT NULL DEFAULT '0' COMMENT '登录类型,0.账号名',
  `password` varchar(64) DEFAULT '' COMMENT '密码',
  `last_login_time` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '最后登录时间',
  `created_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '创建时间',
  `updated_at` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '更新时间',
  UNIQUE KEY `login` (`uid`,`login_id`,`login_type`),
  PRIMARY KEY (`id`),
  KEY `idx_uid` (`uid`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 59 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '账号登录表' ROW_FORMAT = Compact;

CREATE TABLE `user`  ( 
  `uid` varchar(64) NOT NULL COMMENT '用户ID',
  `account` varchar(32) NOT NULL DEFAULT '' COMMENT '用户账号',
  `name` varchar(32) NOT NULL DEFAULT '' COMMENT '用户名',
  `identity_type` varchar(32) NOT NULL DEFAULT 'unknown' COMMENT '用户来源',
  `phone` varchar(16) NOT NULL DEFAULT '' COMMENT '手机号码',
  `email` varchar(100) NOT NULL DEFAULT '' COMMENT '邮箱',
  `created_at` int(11) unsigned NOT NULL COMMENT '创建时间',
  `updated_at` int(11) unsigned NOT NULL COMMENT '修改时间',
  UNIQUE KEY `account` (`account`,`identity_type`),
  PRIMARY KEY (`uid`)
) ENGINE = InnoDB AUTO_INCREMENT = 59 CHARACTER SET = utf8 COLLATE = utf8_general_ci COMMENT = '用户信息表' ROW_FORMAT = Compact;

Dex 服务数据库模型节选:

// Zadig 系统账号集成配置存在该表中
create table connector (
   id text not null primary key COMMENT 'connectorID',
   type text not null COMMENT 'connector类型,如 LDAP、AD 等',
   name text not null COMMENT 'connector名称', 
   resource_version text not null COMMENT '资源版本',
   config bytea COMMENT 'connector 配置内容'
);

# 核心代码节选

第三方登录的实现源码位于 koderover/zadig (opens new window) 库,核心代码说明如下:

func provider() *oidc.Provider {
   ctx := oidc.ClientContext(context.Background(), http.DefaultClient)
   provider, err := oidc.NewProvider(ctx, config.IssuerURL())
   if err != nil {
      log.Panicf(fmt.Sprintf("init provider error:%s", err))
   }
   return provider
}

// 用户登录会率先访问此方法
func Login(c *gin.Context) {
   ctx := internalhandler.NewContext(c)
   defer func() { internalhandler.JSONResponse(c, ctx) }()
   
   // Dex 封装的 oauth2 config 信息
   oauth2Config := &oauth2.Config{
      ClientID:     config.ClientID(),
      ClientSecret: config.ClientSecret(),
      Endpoint:     provider().Endpoint(),
      Scopes:       config.Scopes(),
      RedirectURL:  config.RedirectURI(),
   }
   
   // 根据配置生成 Dex 登录页访问地址
   authCodeURL := oauth2Config.AuthCodeURL(config.AppState, oauth2.AccessTypeOffline)
   systemConfig, err := aslan.New(configbase.AslanServiceAddress()).GetDefaultLogin()
   if err != nil {
      ctx.Err = err
      return
   }
   defaultLogin := ""
   replaceURL := configbase.SystemAddress() + "/dex/auth"
   if systemConfig.DefaultLogin != setting.DefaultLoginLocal {
      defaultLogin = systemConfig.DefaultLogin
      replaceURL = replaceURL + "/" + defaultLogin
   }
   // 外部访问可以通过此方式转为内部访问
   authCodeURL = strings.Replace(authCodeURL, config.IssuerURL()+"/auth", replaceURL, -1)

   // 跳转访问 Dex 提供的登录页
   c.Redirect(http.StatusSeeOther, authCodeURL)
}

// 根据 authCode 去资源服务器换取 accessToken, 并解密校验后并返回用户信息
func verifyAndDecode(ctx context.Context, code string) (*login.Claims, error) {
   oidcCtx := oidc.ClientContext(ctx, http.DefaultClient)
   oauth2Config := &oauth2.Config{
      ClientID:     config.ClientID(),
      ClientSecret: config.ClientSecret(),
      Endpoint:     provider().Endpoint(),
      Scopes:       nil,
      RedirectURL:  config.RedirectURI(),
   }
   var token *oauth2.Token
   // 根据 authCode 换取 accessToken
   token, err := oauth2Config.Exchange(oidcCtx, code)
   if err != nil {
      return nil, e.ErrCallBackUser.AddDesc(fmt.Sprintf("failed to get token: %v", err))
   }
   rawIDToken, ok := token.Extra("id_token").(string)
   if !ok {
      return nil, e.ErrCallBackUser.AddDesc("no id_token in token response")
   }
   // 校验 accessToken
   idToken, err := provider().Verifier(&oidc.Config{ClientID: config.ClientID()}).Verify(ctx, rawIDToken)
   if err != nil {
      return nil, e.ErrCallBackUser.AddDesc(fmt.Sprintf("failed to verify ID token: %v", err))
   }
   var claimsRaw json.RawMessage
   // 获取用户信息
   if err := idToken.Claims(&claimsRaw); err != nil {
      return nil, e.ErrCallBackUser.AddDesc(fmt.Sprintf("error decoding ID token claims: %v", err))
   }
   buff := new(bytes.Buffer)
   if err := json.Indent(buff, claimsRaw, "", "  "); err != nil {
      return nil, e.ErrCallBackUser.AddDesc(fmt.Sprintf("error indenting ID token claims: %v", err))
   }
   var claims login.Claims
   err = json.Unmarshal(claimsRaw, &claims)
   if err != nil {
      return nil, err
   }
   if len(claims.Name) == 0 {
      claims.Name = claims.PreferredUsername
   }
   return &claims, nil
}

// 第三方账号系统密码校验成功后的回调方法
func Callback(c *gin.Context) {
   ctx := internalhandler.NewContext(c)
   defer func() { internalhandler.JSONResponse(c, ctx) }()

   
   if errMsg := c.Query("error"); errMsg != "" {
      ctx.Err = e.ErrCallBackUser.AddDesc(errMsg)
      return
   }
   // 获取 authCode
   code := c.Query("code")
   if code == "" {
      ctx.Err = e.ErrCallBackUser.AddDesc(fmt.Sprintf("no code in request: %q", c.Request.Form))
      return
   }
   if state := c.Query("state"); state != config.AppState {
      ctx.Err = e.ErrCallBackUser.AddDesc(fmt.Sprintf("expected state %q got %q", config.AppState, state))
      return
   }
   // 根据 authCode 去资源服务器换取 accessToken, 并解密校验后并返回用户信息
   claims, err := verifyAndDecode(c.Request.Context(), code)
   if err != nil {
      ctx.Err = err
      return
   }
   // 同步用户信息到 zadig user 数据库
   user, err := user.SyncUser(&user.SyncUserInfo{
      Account:      claims.PreferredUsername,
      Name:         claims.Name,
      Email:        claims.Email,
      IdentityType: claims.FederatedClaims.ConnectorId,
   }, ctx.Logger)
   if err != nil {
      ctx.Err = err
      return
   }
   claims.UID = user.UID
   claims.StandardClaims.ExpiresAt = time.Now().Add(time.Duration(config.TokenExpiresAt()) * time.Minute).Unix()
   // 根据用户信息生成 token  
   userToken, err := login.CreateToken(claims)
   if err != nil {
      ctx.Err = err
      return
   }
   v := url.Values{}
   v.Add("token", userToken)
   redirectUrl := "/?" + v.Encode()
   // 携带 token 返回首页
   c.Redirect(http.StatusSeeOther, redirectUrl)
}

# 三方账号系统接入

目前 Zadig 系统支持集成 Microsoft Active Directory、OpenLDAP、GitHub 以及 OAuth 等外部账号系统,更多自定义系统的接入可参考文档 自定义账号系统集成 | Zadig 文档 (opens new window)。

Background Image

作为一名软件工程师,我们一直在帮各行各业提效,软件工程本身却极其低效。 AI 加速写代码,但未加速交付——环境、审查、发布、诊断,依然需要“人肉”扛住。代码越写越多,质量谁来把关?发布越来越频繁,风险谁来评估? 为什么没有一款平台,让研发团队不那么累,还能更好、更快地交付? Zadig 由此而来。

—— Zadig 创始人 Landy