用户认证和授权是应用安全的一个重要组成部分,尤其对于企业应用而言,安全的进行认证和授权是必选项。本文我们将介绍 Zadig 关于账号系统的一些思考,以及如何使用 Dex 实现账号系统管理。
在 v1.7.0 之前 Zadig 账号系统仅支持内部账号系统管理,随着企业级需求的增强,我们需要支持更为通用的账号授权接入能力,支持标准协议比如 LDAP、Oauth,通用平台类似 AD、GitHub、GitLab,并且满足账号系统的通用能力。
研究市面上的主流方案后,针对 Dex、原生 Client、Keycloak,我们做了如下对比:
Zadig 充分考虑扩展性、维护成本、云原生友好度等因素最终选择用 Dex 作为基础组件。
Dex 组件介绍
Dex 是来自 CoreOS 的基于 OpenID Connect 的开源身份认证服务解决方案。内置的 Connectors 包括 LDAP、GitHub、GitLab、Google、OIDC 等。对于非标准的登录方式,用户也可以通过自定义 Connector 来实现接入 Zadig。
Dex 使用 OpenID Connect 来驱动应用程序的身份验证,当用户通过 Dex 登录时,该用户的身份通常存储在另一个用户管理系统中:LDAP 目录,GitHub 组织等。Dex 充当客户端应用程序和上游身份提供者之间的中介。客户端只需要了解 OpenID Connect 即可查询 Dex,而 Dex 实现了一系列用于查询其他用户管理系统的协议。
"连接器"是 Dex 用于根据一个身份提供者对用户进行身份验证的策略。Dex 实现了针对特定平台(例如 GitHub,LinkedIn 和 Microsoft)以及已建立的协议(例如 LDAP 和 SAML)的连接器。
账号系统
技术选择
在完成了第三方系统登录的组件选型后,剩下的问题就是如何将 Dex 提供的第三方用户信息加入 zadig 自己的用户体系中, 完成 Zadig 用户体系的打造,根据 Zadig 系统的实际要求,我们确定了以下的技术方案:
- 多个外部系统中的同名用户,不视为相同用户
- 所有账号系统,均使用 Zadig 的 Token 进行认证管理
- 使用 UID 信息作为用户的唯一主键,并且和权限、消息等进行关联
- Zadig 自身的用户体系认证采用无状态的方式来实现,相比有状态模式,服务端控制力和压力更小,数据迁移成本也会更低。
架构设计
用户登录环节主要涉及到的组件:
- Zadig aslan 服务 user 模块:主要负责 Zadig 平台用户账号管理(包括 Zadig 自身平台账号和第三方同步过来的账号),和用户登录管理。
- Dex:主要负责作为链接器链接第三方账号系统,以及存储第三方账号的配置。
- Upstream ldp:第三方账号系统
用户认证环节主要涉及到的组件:
- Gloo Edge:Zadig 的网关,会拦截进入 Zadig 后台的流量,并且将流量转发给 OPA 进行认证
- OPA:一款开源通用策略引擎,在 Zadig 中负责对请求进行认证和授权
- Zadig aslan 服务:Zadig 后台核心业务服务
第三方登录流程
第三方账号的登录逻辑如下:
- 访问 Zadig 系统的第三方登录页面(登录页内嵌在 Dex 中),输入用户名和密码后发送到第三方账号系统进行校验
- 第三方账号系统校验成功且同意授权 Zadig 后,携带生成的 authCode 访问 Zadig 的回调地址
- aslan 服务收到请求后用 authCode 换取 accessToken 并解析用户信息
- 刷新第三方账号的登录信息,并生成其 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,让工程师更专注创造。欢迎加入 开源吐槽群????