Open-Falcon 中的 LDAP 认证

前言

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

认证结构

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

Open-Falcon 中的 LDAP 认证_第1张图片
image.png

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

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

  1. Dashboard 传递用户名和密码给 API,增加字段标注为 ldap 认证用户。LDAP 认证逻辑由 API 完成。若用户不存在,API 视 signup_disable 决定是否创建用户。需要较大幅度的修改 API 模块
  2. Dashboard 上进行 ldap 认证校验。认证成功后,先通过 Get User info by name 接口判断用户是否存在。若不存在通过 Create User 接口创建用户。若存在则将用户名和 token 传递给 API,API 给予直接放行。需要小幅修改 API 模块和 Dashboard 模块
  3. Dashboard 上进行 ldap 认证校验。认证成功后,先通过 Get User info by name 接口判断用户是否存在。若不存在通过 Create User 接口创建用户。若存在则通过 Change User's Password 接口将他的密码进行本地更新。然后使用用户+密码正常调用 Login 接口认证。只需要修改 Dashboard 模块

ldap 认证

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

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 认证通过后,同步到本地。再通过标准 Login 接口进行认证。这样可以不必修改 API 模块,改动会比较小。

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

以下是 dashboard 中 rrd/view/auth/auth.py 的代码片段

        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

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

我们需要用到以下几个 API

  • Login —— 用于获取 token
  • Get User info by name —— 用于确认用户是否存在
  • Change User's Password —— 用于更新用户的密码
  • Create User —— 用于创建用户

API 的调用,只需要通过login 接口获取 Apitoken。请求其他接口时,把 Apitoken 放在请求的 header 里就好了。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 认证 ——》 认证成功 ——》 判断用户是否存在(Get User info by name ) ——》 不存在 ——》 创建用户(Create User) ——》 本地认证(Login)

LDAP 认证 ——》 认证成功 ——》 判断用户是否存在(Get User info by name ) ——》 存在 ——》 更新本地密码(Change User's Password)——》 本地认证(Login)

代码片段如下

        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 现有的授权逻辑。基于角色的 Apitoken 进行权限控制。例如这样:

Open-Falcon 中的 LDAP 认证_第2张图片

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

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

falcon-plus/modules/api/app/controller/uic/session_controller.go 修改后的代码片段

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 上的逻辑就很简单了
/dashboard/rrd/view/auth/auth.py 修改后的代码片段

        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)

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

结束语

本文所有代码的完整版本均可在以下两个 PR 找到
https://github.com/open-falcon/dashboard/pull/76
https://github.com/open-falcon/falcon-plus/pull/305

以上

转载授权

CC BY-SA

你可能感兴趣的:(Open-Falcon 中的 LDAP 认证)