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

时间:2022-10-26 11:19:33

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

用户认证和授权是应用安全的一个重要组成部分,尤其对于企业应用而言,安全的进行认证和授权是必选项。本文我们将介绍 Zadig 关于账号系统的一些思考,以及如何使用 Dex 实现账号系统管理。

在 v1.7.0 之前 Zadig 账号系统仅支持内部账号系统管理,随着企业级需求的增强,我们需要支持更为通用的账号授权接入能力,支持标准协议比如 LDAP、Oauth,通用平台类似 AD、GitHub、GitLab,并且满足账号系统的通用能力。

研究市面上的主流方案后,针对 Dex、原生 Client、Keycloak,我们做了如下对比: Zadig 如何用 Dex 实现账号系统管理

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

Dex 组件介绍

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

Dex 使用 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 如何用 Dex 实现账号系统管理

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

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

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

  • Gloo Edge:Zadig 的网关,会拦截进入 Zadig 后台的流量,并且将流量转发给 OPA 进行认证
  • OPA:一款开源通用策略引擎,在 Zadig 中负责对请求进行认证和授权
  • Zadig aslan 服务:Zadig 后台核心业务服务

第三方登录流程

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

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

  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 库,核心代码说明如下:

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 文档

Zadig,让工程师更专注创造。欢迎加入 开源吐槽群????

Zadig on Github
Zadig on Gitee