确保企业应用的安全性,用户认证与授权是不可或缺的核心环节。在构建安全的企业级应用时,一个稳健的认证与授权机制显得尤为关键。
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 使用 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 后台的流量,并且将流量转发给 user 服务进行认证
- 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 文档。
扫码即刻咨询
解锁企业专属最佳实践方案!
发表评论