[英]How to safely authenticate a user using LDAP?
For context: I am developing a web application where users need to authenticate to view internal documents.对于上下文:我正在开发一个 web 应用程序,用户需要在其中进行身份验证才能查看内部文档。 I neither need any detailed info on users nor special permission management, two states are sufficient: Either a session belongs to an authenticated user (→ documents can be accessed) or it does not (→ documents cannot be accessed).
我不需要任何有关用户的详细信息,也不需要特殊权限管理,两种状态就足够了:session 属于经过身份验证的用户(→ 文档可以访问)或不属于(→ 文档无法访问)。 A user authenticates by providing a username and a password, which I want to check against an LDAP server.
用户通过提供用户名和密码进行身份验证,我想检查 LDAP 服务器。
I am using Python 3.10 and the ldap3
Python library.我正在使用 Python 3.10 和
ldap3
Python 库。
I am currently using the following code to authenticate a user:我目前正在使用以下代码对用户进行身份验证:
#!/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
If you want to run this code, you could try using the FreeIPA's project demo LDAP server .如果要运行此代码,可以尝试使用FreeIPA 的项目演示 LDAP 服务器。
CERT_REQUIRED
with CERT_NONE
because the server only provides a self-signed cert (this obviously is a security flaw, but required to use this particular demo – the server I want to use uses a Let's Encrypt certificate).CERT_REQUIRED
替换为CERT_NONE
因为服务器仅提供自签名证书(这显然是一个安全漏洞,但需要使用此特定演示 - 我要使用的服务器使用 Let's Encrypt 证书)。"ldaps://ldap.example.com"
with ldaps://ipa.demo1.freeipa.org
"ldaps://ldap.example.com"
替换为ldaps://ipa.demo1.freeipa.org
user_dn
with f"uid={username},cn=users,cn=accounts,dc=demo1,dc=freeipa,dc=org"
user_dn
替换为f"uid={username},cn=users,cn=accounts,dc=demo1,dc=freeipa,dc=org"
After doing so, you could try running the following commands:这样做之后,您可以尝试运行以下命令:
>>> is_valid("admin", "Secret123")
True
>>> is_valid("admin", "Secret1234")
False
>>> is_valid("admin", "")
False
>>> is_valid("admin", None)
False
>>> is_valid("nonexistent", "Secret123")
False
Does the code above safely determine if a user has provided valid credentials?上面的代码是否可以安全地确定用户是否提供了有效的凭据?
Notably, I am concerned about the following particular aspects:值得注意的是,我担心以下特定方面:
with
statement should only be executed if binding was successful and therefore returns True
without further ado. with
语句的主体只应在绑定成功时执行,因此返回True
无需多言。 Is this safe?user_dn = f"cn={username},ou=ops,dc=ldap,dc=example,dc=com"
uses the untrusted username
(that came directly from the web form) to build a string. user_dn = f"cn={username},ou=ops,dc=ldap,dc=example,dc=com"
使用不受信任的username
(直接来自 web 表单)来构建字符串。 That basically screams LDAP injection. Also, of course, if there is anything else unsafe about my code, I'd be happy to know what it is.另外,当然,如果我的代码还有其他不安全的地方,我很乐意知道它是什么。
I've already searched for answers to the particular aspects.我已经搜索了特定方面的答案。 Sadly, I have found nothing definite (ie no one definitely saying something I do here is bad or good), but I wanted to provide them as a starting point for a potential answer:
可悲的是,我没有找到任何确定的东西(即没有人肯定地说我在这里做的事情是坏的还是好的),但我想提供它们作为潜在答案的起点:
ldap3
documentation of the Connection
object does mention one should use escape_rdn
when binding with an untrusted username.Connection
object 的ldap3
文档确实提到在与不受信任的用户名绑定时应该使用escape_rdn
。 This is at odds with my suppositions, who's right?Is attempting to bind to the LDAP server enough to verify credentials?
尝试绑定到 LDAP 服务器是否足以验证凭据?
From the LDAP protocol side, yes, and many systems already rely on this behavior (eg pam_ldap for Linux OS-level authentication against an LDAP server).从 LDAP 协议方面来看,是的,并且许多系统已经依赖此行为(例如,针对 LDAP 服务器的 Linux 操作系统级身份验证的 pam_ldap)。 I've never heard of any server where the bind result would be deferred until another operation.
我从未听说过任何将绑定结果推迟到另一个操作的服务器。
From the ldap3 module side I'd be more worried, as in my experience initializing a Connection did not attempt to connect – much less bind – to the server until I explicitly called .bind()
(or unless I specified auto_bind=True), but if your example works then I assume that using a with
block does this correctly.从 ldap3 模块方面我会更担心,因为在我初始化 Connection 的经验中,在我明确调用
.bind()
之前(或者除非我指定 auto_bind=True),它并没有尝试连接(更不用说绑定)到服务器,但是如果您的示例有效,那么我假设使用with
块可以正确执行此操作。
In old code (which holds a persistent connection, no 'with') I've used this, but it may be outdated:在旧代码中(它拥有一个持久连接,没有'with')我使用过这个,但它可能已经过时了:
conn = ldap3.Connection(server, raise_exceptions=True)
conn.bind()
(For some apps I use Apache as a reverse proxy and its mod_auth_ldap handles LDAP authentication for me, especially when "is authenticated" is sufficient.) (对于某些应用程序,我使用 Apache 作为反向代理,它的 mod_auth_ldap 为我处理 LDAP 身份验证,尤其是当“已通过身份验证”就足够了。)
Am I opening myself up to injection attacks?
我是否对注射攻击敞开心扉? If so, how to properly mitigate them?
如果是这样,如何正确缓解它们?
Well, kind of, but not in a way that would be easily exploitable.嗯,有点,但不是以一种容易被利用的方式。 The bind DN is not a free-form query – it's only a weird-looking "user name" field and it must exactly match an existing entry;
绑定 DN 不是一个自由格式的查询——它只是一个看起来很奇怪的“用户名”字段,它必须与现有条目完全匹配; you can't put wildcards in it.
你不能在里面放通配符。
(It's in the LDAP server's best interests to be strict about what the "bind" operation accepts, because it's literally the user-facing operation for logging into an LDAP server before anything else is done – it's not just a "password check" function.) (在 LDAP 服务器的最大利益中,严格“绑定”操作接受的内容,因为它实际上是在完成其他任何操作之前登录 LDAP 服务器的面向用户的操作 - 它不仅仅是“密码检查”ZA394FAB54074D78 )
For example, if you have some users at OU=Ops and some at OU=Superops,OU=Ops, then someone could specify Foo,OU=Superops
as their username resulting in UID=Foo,OU=Superops,OU=Ops,
as the DN – but they'd still have to provide the correct password for that account anyway;例如,如果您有一些用户在 OU=Ops,一些用户在 OU=Superops,OU=Ops,那么有人可以指定
Foo,OU=Superops
作为他们的用户名,从而导致UID=Foo,OU=Superops,OU=Ops,
如DN——但他们仍然必须为该帐户提供正确的密码; they cannot trick the server into using one account's privileges while checking another account's password.他们不能在检查另一个帐户的密码时欺骗服务器使用一个帐户的权限。
However, it's easy to avoid injection regardless.但是,无论如何,很容易避免注入。 DN component values can be escaped using:
可以使用以下方法转义 DN 组件值:
ldap3.utils.dn.escape_rdn(string)
ldap3.utils.dn.escape_rdn(string)
ldap.dn.escape_dn_chars(string)
ldap.dn.escape_dn_chars(string)
That being said, I dislike "DN template" approach for a completely different reason – its rather limited usefulness;话虽如此,我不喜欢“DN 模板”方法的原因完全不同——它的用处相当有限; it only works when all of your accounts are under the same OU (flat hierarchy) and only when they're named after the
uid
attribute.它仅在您的所有帐户都在同一个 OU(平面层次结构)下并且仅当它们以
uid
属性命名时才有效。
That may be the case for a purpose-built LDAP directory, but on a typical Microsoft Active Directory server (or, I believe, on some FreeIPA servers as well) the user account entries are named after their full name (the cn
attribute) and can be scattered across many OUs.专门构建的 LDAP 目录可能就是这种情况,但在典型的 Microsoft Active Directory 服务器上(或者,我相信,在某些 FreeIPA 服务器上),用户帐户条目以其全名(
cn
属性)命名,并且可以分散在许多 OU 中。 A two-step approach is more common:两步法更常见:
uid
attribute, or similar, and verify that you found exactly one entry;uid
属性中包含用户名或类似属性的任何“用户”条目,并验证您是否找到了一个条目; When searching, you do have to worry about LDAP filter injection attacks a bit more, as a username like foo)(uid=*
might give undesirable results. (But requiring the results to match exactly 1 entry – not "at least 1" – helps with mitigating this as well.)搜索时,您确实不得不担心 LDAP 过滤器注入攻击,因为像
foo)(uid=*
可能会产生不良结果。(但要求结果与 1 个条目完全匹配 - 而不是“至少 1 个” -也有助于减轻这种情况。)
Filter values can be escaped using:可以使用以下方法转义过滤器值:
ldap3.utils.conv.escape_filter_chars(string)
ldap3.utils.conv.escape_filter_chars(string)
ldap.filter.escape_filter_chars(string)
ldap.filter.escape_filter_chars(string)
(python-ldap also has a convenient wrapper ldap.filter.filter_format
around this, but it's basically just the_filter % tuple(map(escape_filter_chars, args))
.) (python-ldap 也有一个方便的包装器
ldap.filter.filter_format
,但它基本上只是the_filter % tuple(map(escape_filter_chars, args))
。)
The escaping rules for filter values are different from those for RDN values, so you need to use the correct one for the specific context.过滤器值的 escaping 规则与 RDN 值的规则不同,因此您需要针对特定上下文使用正确的规则。 But at least unlike SQL, they are exactly the same everywhere, so the functions that come with your LDAP client module will work with any server.
但至少与 SQL 不同,它们在任何地方都完全相同,因此您的 LDAP 客户端模块附带的功能适用于任何服务器。
Is TLS properly configured?
TLS 是否正确配置?
ldap3/core/tls.py looks good to me – it uses ssl.create_default_context() when supported, loads the system default CA certificates, so no extra configuration should be needed. ldap3/core/tls.py 在我看来不错——它使用 ssl.create_default_context() 在支持时加载系统默认 CA 证书,因此不需要额外配置。 Although it does implement custom hostname checking instead of relying on the ssl module's check_hostname so that's a bit weird.
尽管它确实实现了自定义主机名检查,而不是依赖于 ssl 模块的 check_hostname ,所以这有点奇怪。 (Perhaps the LDAP-over-TLS spec defines wildcard matching rules that are slightly incompatible with the usual HTTP-over-TLS ones.)
(也许 LDAP-over-TLS 规范定义了与通常的 HTTP-over-TLS 稍有不兼容的通配符匹配规则。)
声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.