如何使用LDAP安全地对用户进行身份验证



对于上下文:我正在开发一个web应用程序,用户需要在其中进行身份验证才能查看内部文档。我既不需要任何关于用户的详细信息,也不需要特殊权限管理,两种状态就足够了:会话属于经过身份验证的用户(→文档可以访问)(→无法访问文档)。用户通过提供用户名和密码进行身份验证,我想对照LDAP服务器进行检查。

我使用的是Python 3.10和ldap3Python库。

代码

我目前正在使用以下代码对用户进行身份验证:

#!/usr/bin/env python3
import ssl
from ldap3 import Tls, Server, Connection
from ldap3.core.exceptions import LDAPBindError, LDAPPasswordIsMandatoryError

def is_valid(username: str, password: str) -> bool:
tls_configuration = Tls(validate=ssl.CERT_REQUIRED)
server = Server("ldaps://ldap.example.com", tls=tls_configuration)
user_dn = f"cn={username},ou=ops,dc=ldap,dc=example,dc=com"
try:
with Connection(server, user=user_dn, password=password):
return True
except (LDAPBindError, LDAPPasswordIsMandatoryError):
return False

Demo实例

如果您想运行此代码,可以尝试使用FreeIPA的项目演示LDAP服务器。

  • CERT_REQUIRED替换为CERT_NONE,因为服务器只提供自签名证书(这显然是一个安全缺陷,但需要使用此特定的演示程序——我要使用的服务器使用Let's Encrypt证书)
  • ldaps://ipa.demo1.freeipa.org替换"ldaps://ldap.example.com"
  • user_dn替换为f"uid={username},cn=users,cn=accounts,dc=demo1,dc=freeipa,dc=org"

完成此操作后,您可以尝试运行以下命令:

>>> is_valid("admin", "Secret123")
True
>>> is_valid("admin", "Secret1234")
False
>>> is_valid("admin", "")
False
>>> is_valid("admin", None)
False
>>> is_valid("nonexistent", "Secret123")
False

我的问题

上面的代码是否安全地确定用户是否提供了有效凭据

值得注意的是,我关注以下特定方面:

  1. 尝试绑定到LDAP服务器是否足以验证凭据
    • 只有在绑定成功的情况下才应执行with语句的主体,因此返回True而无需进一步操作。这安全吗?或者,绑定成功了,但提供的密码仍然被认为是错误的,不足以根据网络应用程序对用户进行身份验证
  2. 我是否对注射攻击敞开心扉如果是,如何适当缓解?
    • user_dn = f"cn={username},ou=ops,dc=ldap,dc=example,dc=com"使用不可信username(直接来自web表单)来构建字符串。这基本上就是LDAP注入
  3. TLS配置正确吗
    • 连接应该使用现代TLS加密并验证服务器提供的证书,就像普通浏览器一样

当然,如果我的代码还有其他不安全的地方,我很乐意知道它是什么。

我已经找到的资源

我已经在寻找特定方面的答案了。遗憾的是,我没有发现任何确切的东西(也就是说,没有人确切地说我在这里做的事情是好是坏),但我想把它们作为一个潜在答案的起点:

  1. 可能是的。
    • "如何在python3中使用ldap3绑定(验证)用户"使用了类似的代码片段进行绑定,没有人明确表示这很糟糕
    • Auth0在他们的博客文章"Using LDAP and Active Directory with C#101"中使用了这种方法,他们可能知道自己在做什么
  2. 可能不会,所以不需要任何缓解措施。
    • 关于LDAP注入有一些问题(比如"如何防止ldap3中的LDAP注入for python3"),但它们总是只提到过滤和搜索,而不是绑定
    • 关于LDAP注入的OWASP备忘单提到,在过滤时,启用绑定身份验证是减轻LDAP注入的一种方法,但没有提及绑定DN所需的净化
    • 我想你甚至可以争辩说,这种情况不容易受到注入攻击,因为我们确实在处理不受信任的输入,,但仅在预期不受信任输入的情况下。任何人都可以在登录表单中键入任何内容,但也可以将任何内容放入绑定到LDAP服务器的请求中(甚至不必使用web应用程序)。只要我不把不受信任的输入放在需要受信任输入的地方(例如,在与LDAP管理员帐户绑定后,在过滤器查询中使用用户名),我就应该是安全的
    • 然而,Connection对象的ldap3文档确实提到,当与不受信任的用户名绑定时,应该使用escape_rdn。这与我的假设不一致,谁是对的
  3. 可能是的。
    • 当我试图在只提供自签名证书的服务器上使用此代码时,至少出现了一个错误,所以我认为我应该是安全的

尝试绑定到LDAP服务器是否足以验证凭据?

从LDAP协议方面来看,是的,许多系统已经依赖于这种行为(例如,针对LDAP服务器的Linux操作系统级别身份验证的pam_LDAP)。我从来没有听说过任何服务器会将绑定结果推迟到另一个操作。

从ldap3模块的角度来看,我会更担心,因为根据我的经验,在我显式调用.bind()(或者除非我指定auto_bind=True)之前,初始化Connection并没有尝试连接到服务器,更不用说绑定了,但如果您的示例有效,那么我假设使用with块可以正确地做到这一点。

在旧代码中(它保持持久连接,没有"with"),我使用过这个,但它可能已经过时了:

conn = ldap3.Connection(server, raise_exceptions=True)
conn.bind()

(对于一些应用程序,我使用Apache作为反向代理,它的mod_auth_ldap为我处理ldap身份验证,尤其是当"已验证"时。)

我是否会接受注射攻击?如果是,如何适当地减轻它们?

嗯,有点,,但不是以一种容易利用的方式。绑定DN不是一个自由形式的查询——它只是一个看起来很奇怪的";用户名";字段,并且它必须与现有条目完全匹配;你不能在里面放通配符。

(严格控制"绑定"操作所接受的内容符合LDAP服务器的最大利益,因为这实际上是在执行任何其他操作之前登录LDAP服务器的面向用户的操作,而不仅仅是"密码检查"功能。)

例如,如果一些用户位于OU=Ops,而另一些用户位于OU=Superops,OU=Ops,则有人可以将Foo,OU=Superops指定为他们的用户名,从而将UID=Foo,OU=Superops,OU=Ops,指定为DN,但他们仍然必须为该帐户提供正确的密码;他们不能欺骗服务器在检查另一个帐户的密码时使用一个帐户权限。

然而,无论如何都很容易避免注射。DN组件值可以使用进行转义

  • ldap3:ldap3.utils.dn.escape_rdn(string)
  • python-ldap:ldap.dn.escape_dn_chars(string)

话虽如此,我不喜欢"DN模板";由于一个完全不同的原因——它的用处相当有限;只有当您的所有帐户都在同一OU(平面层次结构)下时,并且只有当它们以uid属性命名时,它才有效。

对于专门构建的LDAP目录来说可能是这样,但在典型的Microsoft Active directory服务器上(或者,我相信,在一些FreeIPA服务器上),用户帐户条目以其全名(cn属性)命名,并且可以分散在许多OU中。两步方法更为常见:

  1. 使用应用程序的服务凭据绑定,然后在目录中搜索任何";用户";在其uid属性或类似属性中具有用户名的条目,并验证您是否正好找到一个条目
  2. 取消绑定(可选?),然后使用用户的找到的DN和提供的密码再次绑定

在搜索时,您必须更加担心LDAP过滤器注入攻击,因为像foo)(uid=*这样的用户名可能会产生不希望的结果。(但要求结果与1个条目完全匹配——而不是"至少1"——也有助于缓解这种情况。)

过滤器值可以使用进行转义

  • ldap3:ldap3.utils.conv.escape_filter_chars(string)
  • python-ldap:ldap.filter.escape_filter_chars(string)

(python-ldap也有一个方便的包装ldap.filter.filter_format,但它基本上只是the_filter % tuple(map(escape_filter_chars, args))。)

筛选器值的转义规则与RDN值的转义法则不同,因此需要为特定上下文使用正确的转义法则。但至少与SQL不同,它们在任何地方都是完全相同的,因此LDAP客户端模块附带的功能可以与任何服务器一起使用。

TLS配置正确吗?

ldap3/core/tls.py对我来说很好——它在支持时使用ssl.create_default_context(),加载系统默认的CA证书,因此不需要额外的配置。尽管它确实实现了自定义主机名检查,而不是依赖ssl模块的check_hostname,所以这有点奇怪。(也许LDAP over TLS规范定义了通配符匹配规则,这些规则与通常的HTTP over TLS规则稍微不兼容。)


一种替代手动转义DN模板的方法:

dn = build_dn({"CN": f"{last}, {first} ({username})"},
{"OU": "Faculty of Foo and Bar (XYZF)"},
{"OU": "Staff"},
ad.BASE_DN)
def build_dn(*args):
components = []
for rdn in args:
if isinstance(rdn, dict):
rdn = [(a, ldap.dn.escape_dn_chars(v))
for a, v in rdn.items()]
rdn.sort()
rdn = "+".join(["%s=%s" % av for av in rdn])
components.append(rdn)
elif isinstance(rdn, str):
components.append(rdn)
else:
raise ValueError("Unacceptable RDN type for %r" % (rdn,))
return ",".join(components)

相关内容

最新更新