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

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

cover.png

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

Zadig 账号系统以其强大的管理能力,超越了基本的内部账号管理,进一步升级以满足企业级需求。我们增强了账号授权的接入功能,除支持自身内部账号管理能力外,额外支持 LDAPOAuth 等广泛认可的账号授权标准,同时实现了与 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

作为一名软件工程师,我们一直给各行各业写软件提升效率,但是软件工程本身却是非常低效,为什么市面上没有一个工具可以让研发团队不这么累,还能更好、更快地满足大客户的交付需求?我们是否能够打造一个面向开发者的交付平台呢?我们开源打造 Zadig 正是去满足这个愿望。

—— Zadig 创始人 Landy