Open-Falcon 中的 LDAP 认证

前言

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

认证结构

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

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 进行权限控制。例如这样:

一个拥有 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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,293评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,604评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,958评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,729评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,719评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,630评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,000评论 3 397
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,665评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,909评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,646评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,726评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,400评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,986评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,959评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,197评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,996评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,481评论 2 342

推荐阅读更多精彩内容