我们发现自己处于类似的情况,我们为我们内部托管的 Web 应用程序之一设置的企业环境 LDAP 身份验证设置似乎运行良好,直到密码中包含特殊字符的用户——比如?& * ' - 想要使用该应用程序,然后很明显有些东西实际上并没有按预期工作。
我们发现,当受影响用户的密码中存在特殊字符时,我们的身份验证请求失败ldap_bind()
,并且通常会看到诸如Invalid credentials
或NDS error: failed authentication (-669)
(来自 LDAP 的扩展错误日志)之类的错误输出到日志中。事实上,正是该NDS error: failed authentication (-669)
错误导致发现绑定失败可能是由于密码中存在特殊字符 - 通过此 Novell 支持文章https://www.novell.com/support/kb/doc.php? id=3335671最值得注意的部分摘录如下:
管理员密码包含 LDAP 无法识别的字符,即:“_”和“$”
已经广泛测试了一系列修复程序,包括通过html_entity_decode()
、utf8_encode()
等“清除”密码,并确保在 Web 应用程序和各种服务器之间没有发生密码错误编码,但无论做了什么调整或保护输入文本以确保其原始 UTF-8 编码保持不变,ldap_bind()
当密码中存在一个或多个特殊字符时总是失败(我们没有测试用户名中包含特殊字符的案例,但其他人在用户名包含特殊字符而不是密码或除了密码之外)。
我们还ldap_bind()
直接使用在命令行上运行的最小 PHP 脚本进行了测试,该脚本使用硬编码的用户名和密码,用于测试 LDAP 帐户的密码中包含特殊字符(包括问号、星号和感叹号),以尝试尽可能多地消除潜在的失败原因,但绑定操作仍然失败。因此很明显,这不是 Web 应用程序和服务器之间的密码错误编码的问题,所有这些都被设计和构建为端到端兼容 UTF-8。相反,它似乎存在一个潜在的问题ldap_bind()
及其对特殊字符的处理。相同的测试脚本成功地绑定了另一个密码中没有任何特殊字符的帐户,进一步证实了我们的怀疑。
正如大多数 PHP LDAP 身份验证教程所推荐的那样,早期版本的 LDAP 身份验证工作如下:
$success = false; // could we authenticate the user?
if(!defined("LDAP_OPT_DIAGNOSTIC_MESSAGE")) {
define("LDAP_OPT_DIAGNOSTIC_MESSAGE", 0x0032); // needed for more detailed logging
}
if(($ldap = ldap_connect($ldap_server)) !== false && is_resource($ldap)) {
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
if(@ldap_bind($ldap, sprintf("cn=%s,ou=Workers,o=Internal", $username), $password)) {
$success = true; // login succeeded...
} else {
error_log(sprintf("* Unable to authenticate user (%s) against LDAP!", $username));
error_log(sprintf(" * LDAP Error: %s", ldap_error($ldap)));
if(ldap_get_option($ldap, LDAP_OPT_DIAGNOSTIC_MESSAGE, $extended_error)) {
error_log(sprintf(" * LDAP Extended Error: %s\n", $extended_error));
unset($extended_error);
}
}
}
@ldap_close($ldap); unset($ldap);
很明显,我们更简单的解决方案只使用ldap_connect()
并且ldap_bind()
不允许使用复杂密码的用户进行身份验证,因此我们必须找到替代方案。
我们选择了一种解决方案,该解决方案首先使用系统帐户绑定到我们的 LDAP 服务器(创建时具有搜索用户所需的权限,但有意将其配置为受限的只读帐户)。然后,我们使用提供的用户名搜索用户帐户ldap_search()
,然后ldap_compare()
将用户提供的密码与存储在 LDAP 中的密码进行比较。该解决方案已被证明是可靠且有效的,我们现在能够对密码中包含特殊字符的用户和没有密码的用户进行身份验证!
新的 LDAP 进程的工作方式如下:
if(!defined("LDAP_OPT_DIAGNOSTIC_MESSAGE")) {
define("LDAP_OPT_DIAGNOSTIC_MESSAGE", 0x0032); // needed for more detailed logging
}
$success = false; // could we authenticate the user?
if(($ldap = @ldap_connect($ldap_server)) !== false && is_resource($ldap)) {
ldap_set_option($ldap, LDAP_OPT_PROTOCOL_VERSION, 3);
ldap_set_option($ldap, LDAP_OPT_REFERRALS, 0);
// instead of trying to bind the user, we bind to the server...
if(@ldap_bind($ldap, $ldap_username, $ldap_password)) {
// then we search for the given user...
if(($search = ldap_search($ldap, "ou=Workers,o=Internal", sprintf("(&(uid=%s))", $username))) !== false && is_resource($search)) {
// they should be the first and only user found for the search...
if((ldap_count_entries($ldap, $search) == 1) && ($entry = ldap_first_entry($ldap, $search)) !== false && is_resource($entry)) {
// we ensure this is the case by obtaining the user identifier (UID) from the search which must match the provided $username...
if(($uid = ldap_get_values($ldap, $entry, "uid")) !== false && is_array($uid)) {
// ensure that just one entry was returned by ldap_get_values() and ensure the obtained value matches the provided $username (excluding any case-differences)
if((isset($uid["count"]) && $uid["count"] == 1) && (isset($uid[0]) && is_string($uid[0]) && (strcmp(mb_strtolower($uid[0], "UTF-8"), mb_strtolower($username, "UTF-8")) === 0))) {
// once we have compared the provied $username with the discovered username, we get the DN for the user, which we need to provide to ldap_compare()...
if(($dn = ldap_get_dn($ldap, $entry)) !== false && is_string($dn)) {
// we then use ldap_compare() to compare the user's password with the provided $password
// ldap_compare() will respond with a boolean true/false depending on if the comparison succeeded or not, and -1 on error...
if(($comparison = ldap_compare($ldap, $dn, "userPassword", $password)) !== -1 && is_bool($comparison)) {
if($comparison === true) {
$success = true; // user successfully authenticated, if we don't get this far, it failed...
}
}
}
}
}
}
}
}
if(!$success) { // if not successful, either an error occurred or the credentials were actually incorrect
error_log(sprintf("* Unable to authenticate user (%s) against LDAP!", $username));
error_log(sprintf(" * LDAP Error: %s", ldap_error($ldap)));
if(ldap_get_option($ldap, LDAP_OPT_DIAGNOSTIC_MESSAGE, $extended_error)) {
error_log(sprintf(" * LDAP Extended Error: %s\n", $extended_error));
unset($extended_error);
}
}
}
@ldap_close($ldap);
unset($ldap);
最初的担忧之一是,这种带有所有额外步骤的新方法将花费更长的时间来执行,但事实证明(至少在我们的环境中)运行一个简单的解决方案和使用和运行一个ldap_bind()
更复杂的解决方案之间的时间差异是实际上微不足道,平均可能长 0.1 秒。ldap_search()
ldap_compare()
需要注意的是,我们的 LDAP 身份验证代码的早期和后期版本都应仅在用户输入数据(即用户名和密码输入)经过验证和测试以确保没有提供空的或无效的用户名或密码字符串后运行,并且不存在无效字符(例如空字节)。此外,理想情况下,用户凭据应通过应用程序的安全 HTTPS 请求发送,并且安全 LDAPS 协议可用于在身份验证过程中进一步保护用户凭据。我们发现直接使用 LDAPS,通过连接到服务器ldaps://...
而不是通过连接ldap://...
然后切换到 TLS 连接已被证明更可靠。如果您的设置有所不同,则需要相应地调整上面的代码以包含对ldap_start_tls()
.
最后,可能值得注意的是,LDAP 协议要求根据 RFC(RFC 4511,p16,第一段)使用 UTF-8 对密码进行编码——因此,如果在某些情况下,请求期间使用的任何系统都是从初始输入一直到身份验证,不将用户的凭据作为 UTF-8 处理,这也可能是一个值得研究的领域。
希望这个解决方案对那些用许多其他报告的解决方案努力解决这个问题,但仍然发现用户无法通过身份验证的情况的其他人有用。代码可能需要针对您自己的 LDAP 环境进行调整,尤其是其中使用的 DN,ldap_search()
因为这将取决于您的 LDAP 目录的配置方式以及您的应用程序支持哪些用户组。很高兴,它在我们的环境中运行良好,希望该解决方案在其他地方也能发挥作用。