Bootstrap

Open-Falcon 中的 LDAP 认证

前言

Open-Falcon 是当下国内最流行的开源监控框架之一。LDAP 是一种轻量级的目录协议,广泛应用于统一身份认证中。自然的,我们的监控系统也需要对接 LDAP 进行认证。因此我们来研究一下 Open-Falcon 中如何通过 LDAP 来进行身份认证。

认证结构

由于在 Open-Falcon 2.0 以后已经实现了前后端的分离。Dashboard 本身并不承担用户的认证和鉴权等工作,他只是把用户发送给 API 模块,由 API 进行认证并赋予权限。例如这个  接口

我们可以在  上看到所有 API 文档说明。

由于认证实际是由 API 来完成的。因此要实现 LDAP 认证,办法可能有以下三种

ldap 认证

目前 dashboard 中的 ldap 认证,是基于配置文件模板来绑定用户的方式来做的。即  这个配置

LDAP_SERVER = os.environ.get("LDAP_SERVER","ldap.forumsys.com:389")
LDAP_BASE_DN = os.environ.get("LDAP_BASE_DN","dc=example,dc=com")
LDAP_BINDDN_FMT = os.environ.get("LDAP_BINDDN_FMT","uid=%s,dc=example,dc=com")
LDAP_SEARCH_FMT = os.environ.get("LDAP_SEARCH_FMT","uid=%s")

这需要用户知道自己在 ldap 中的完整 dn,并且无法支持多个 ou 子树。实际上,ldap 认证时,更常见的做法是配置一个 ldap 的管理员账号。先由管理员账号根据登录的用户名, search 出用户的 dn,再使用这个 dn 与用户密码进行 bind 操作,进行认证校验。类似这样

        cli.bind_s(bind_dn, bind_pass, ldap.AUTH_SIMPLE)
        result = cli.search_s(base_dn, ldap.SCOPE_SUBTREE, search_filter, config.LDAP_ATTRS)
        log.debug("ldap result: %s" % result)
        user_dn = result[0][0]
        cli.bind_s(user_dn, password, ldap.AUTH_SIMPLE)

一种实现

从 Dashboard 的代码里可以看到,事实上当下 Dashboard 中选择的是第三种实现方式。也就是 ldap 认证通过后,同步到本地。再通过标准  接口进行认证。这样可以不必修改 API 模块,改动会比较小。

但是目前的实现有点不太完整,我们来看代码。

以下是 dashboard 中  的代码片段

        if ldap == "1":
            try:
                ldap_info = view_utils.ldap_login_user(name, password)

                h = {"Content-type":"application/json"}
                d = {
                    "name": name,
                    "password": password,
                    "cnname": ldap_info['cnname'],
                    "email": ldap_info['email'],
                    "phone": ldap_info['phone'],
                }

                r = requests.post("%s/user/create" %(config.API_ADDR,), \
                        data=json.dumps(d), headers=h)
                log.debug("%s:%s" %(r.status_code, r.text))

                #TODO: update password in db if ldap password changed
            except Exception as e:
                ret["msg"] = str(e)
                return json.dumps(ret)

可以看到,当 ldap 认证通过时,dashboard 会通过 api 创建一个本地账号,并将 ldap 用户认证时的密码作为本地用户的密码。之后再登陆时,实际上就用的这个本地密码来做本地用户的认证了。

显然当时作者就发现了这个实现不完整。因为如果用户在 ldap 上修改了密码,这个修改并不会反馈到 Open-Falcon 中。他依然只能使用老密码进行认证

#TODO: update password in db if ldap password changed

所以第一种办法就是把这个实现给补完。让用户每次认证的时候都更新一下本地的密码。

我们需要用到以下几个 

  •  —— 用于获取 token

  •  —— 用于确认用户是否存在

  •  —— 用于更新用户的密码

  •  —— 用于创建用户

 的调用,只需要通过 接口获取 。请求其他接口时,把  放在请求的  里就好了。API 是 REST 风格的,非常简单易用。我们以获取 Apitoken 和 获取用户 id 为例,代码如下:

def get_Apitoken(name, password):
     d = {"name": name, "password": password}
     h = {"Content-type":"application/json"}
     r = requests.post("%s/user/login" %(config.API_ADDR,), \
             data=json.dumps(d), headers=h)
     if r.status_code != 200:
         raise Exception("%s %s" %(r.status_code, r.text)) 
     sig = json.loads(r.text)["sig"]
     return json.dumps({"name":name,"sig":sig})
 
 def get_user_id(name, Apitoken):
     h = {"Content-type":"application/json","Apitoken":Apitoken}    
     r = requests.get("%s/user/name/%s" %(config.API_ADDR,name), headers=h)
     if r.status_code != 200:
         user_id = -1
         return user_id
     user_id = json.loads(r.text)["id"]
     return user_id

现在可以补完认证的逻辑了。

LDAP 认证 ——》 认证成功 ——》 判断用户是否存在( ) ——》 不存在 ——》 创建用户() ——》 本地认证()

LDAP 认证 ——》 认证成功 ——》 判断用户是否存在( ) ——》 存在 ——》 更新本地密码()——》 本地认证()

代码片段如下

        if ldap == "1":
            try:
                ldap_info = view_utils.ldap_login_user(name, password)

                user_info = {
                    "name": name,
                    "password": password,
                    "cnname": ldap_info['cnname'],
                    "email": ldap_info['email'],
                    "phone": ldap_info['phone'],
                }

                Apitoken = view_utils.get_Apitoken(config.API_USER, config.API_PASS)

                user_id = view_utils.get_user_id(name, Apitoken)
                
                if user_id > 0:
                    view_utils.update_password(user_id, password, Apitoken)
                    # if user exist, update password
                else:
                    view_utils.create_user(user_info)
                    # create user , signup must be enabled
                    
            except Exception as e:
                ret["msg"] = str(e)
                return json.dumps(ret)

哪里不对

相信你也觉得,把 ldap 用户的密码本地存一份总感觉有点怪怪的……

况且,这样的逻辑意味着 ldap 用户实际上可以使用这个密码进行本地认证,即便不勾选 ldap 选项。虽然说这意味着 ldap 宕机的时候能继续保持登陆可用性,但是同时也意味着如果用户修改了 ldap 的密码,或者修改了ldap 中的状态(比如禁用),但是再他下一次登陆 dashboard 之前,Open-Falcon 本地的密码并不会随之更新。

我们假设某个用户被盗了,管理员紧急的锁掉了他的 LDAP 账号。但是 Open-Falcon 并不能感知到!盗号者依然可以用这个用户的密码在 dashboard 上完成认证。这其实存在安全隐患。

所以似乎修改 API 模块已经不可避免了。那是把 ldap 的认证逻辑直接做进 API 模块,还是 API 模块加一个接口来信任 ldap 认证的结果呢?

让我们考虑的稍微远一点点。

ldap 认证实际上可以视作是一种第三方认证。从扩展性上来讲,我们将来可能还要进一步集成其他方式的第三方认证,比如 CAS,Oauth2,OpenID 等。

这些逻辑如果都直接做进 API 的话,未免显得太罗嗦。况且有些不太符合前后端分离的设计初衷。

另一种实现

简单来讲,尽量减少对 API 的改动,同时要考虑扩展性。以后前端再加其他的认证,不需要再次改动 API。

所以就给 API 加个接口来信任第三方认证吧,尽可能简单一点,复用 API 现有的授权逻辑。基于角色的  进行权限控制。例如这样:

一个拥有  权限(Role = 1)的用户,通过该账号申请的  ,可以调用 接口,认证普通角色( Role = 0 )的用户。

 用户们自身的  怎么处理呢?直接允许与他们平级的  用户拥有  权限似乎不太合适。所以我们限制只有 ( Role = 2 ) 才能够  

 修改后的代码片段

func AdminLogin(c *gin.Context) {
    inputs := APIAdminLoginInput{}
    if err := c.Bind(&inputs); err != nil {
        h.JSONR(c, badstatus, "name is blank")
        return
    }
    name := inputs.Name

    user := uic.User{
        Name: name,
    }
    adminuser, err := h.GetUser(c)
    if err != nil {
        h.JSONR(c, badstatus, err.Error())
        return
    }

    db.Uic.Where(&user).Find(&user)
    switch {
    case user.ID == 0:
        h.JSONR(c, badstatus, "no such user")
        return
    case user.Role >= adminuser.Role:
        h.JSONR(c, badstatus, "API_USER not admin, no permissions can do this")
        return
    }
    var session uic.Session
    s := db.Uic.Table("session").Where("uid = ?", user.ID).Scan(&session)
    if s.Error != nil && s.Error.Error() != "record not found" {
        h.JSONR(c, badstatus, s.Error)
        return
    } else if session.ID == 0 {
        session.Sig = utils.GenerateUUID()
        session.Expired = int(time.Now().Unix()) + 3600*24*30
        session.Uid = user.ID
        db.Uic.Create(&session)
    }
    log.Debugf("session: %v", session)
    resp := struct {
        Sig   string `json:"sig,omitempty"`
        Name  string `json:"name,omitempty"`
        Admin bool   `json:"admin"`
    }{session.Sig, user.Name, user.IsAdmin()}
    h.JSONR(c, resp)
    return
}

现在 Dashboard 上的逻辑就很简单了

 修改后的代码片段

        if ldap == "1":
            try:
                ldap_info = view_utils.ldap_login_user(name, password)
                password = id_generator()
                user_info = {
                    "name": name,
                    "password": password,
                    "cnname": ldap_info['cnname'],
                    "email": ldap_info['email'],
                    "phone": ldap_info['phone'],
                }
                Apitoken = view_utils.get_Apitoken(config.API_USER, config.API_PASS)

                ut = view_utils.admin_login_user(name, Apitoken)
                if not ut:
                    view_utils.create_user(user_info)
                    ut = view_utils.admin_login_user(name, Apitoken)
                    #if user not exist, create user , signup must be enabled
                ret["data"] = {
                        "name": ut.name,
                        "sig": ut.sig,
                }
                return json.dumps(ret)

简而言之,本地已有账号, 之,本地尚无账号,先创建,再  之

结束语

本文所有代码的完整版本均可在以下两个 PR 找到

以上

原文于2017年12月首发于,搬家存档。

行文有微调。