Open-Falcon 中的 LDAP 认证
前言
Open-Falcon 是当下国内最流行的开源监控框架之一。LDAP 是一种轻量级的目录协议,广泛应用于统一身份认证中。自然的,我们的监控系统也需要对接 LDAP 进行认证。因此我们来研究一下 Open-Falcon 中如何通过 LDAP 来进行身份认证。
认证结构
由于在 Open-Falcon 2.0 以后已经实现了前后端的分离。Dashboard 本身并不承担用户的认证和鉴权等工作,他只是把用户发送给 API 模块,由 API 进行认证并赋予权限。例如这个 login
接口
我们可以在 FALCON+ API 上看到所有 API 文档说明。
由于认证实际是由 API 来完成的。因此要实现 LDAP 认证,办法可能有以下三种
- Dashboard 传递用户名和密码给 API,增加字段标注为 ldap 认证用户。LDAP 认证逻辑由 API 完成。若用户不存在,API 视
signup_disable
决定是否创建用户。需要较大幅度的修改 API 模块。 - Dashboard 上进行 ldap 认证校验。认证成功后,先通过
Get User info by name
接口判断用户是否存在。若不存在通过Create User
接口创建用户。若存在则将用户名和 token 传递给 API,API 给予直接放行。需要小幅修改 API 模块和 Dashboard 模块。 - 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