fix(ldap): 使用 LDAP Bind 认证替代 userPassword 查询,兼容 Active Directory

- 认证方式从 userPassword 属性过滤改为标准 LDAP Bind,兼容所有 LDAP 服务器
- 新增可配置的登录属性(cn/uid/mail/sAMAccountName),AD 用户选 sAMAccountName 即可
- 移除 posixAccount objectClass,兼容 AD 目录结构
- 同步创建用户时移除 POSIX 专属属性,添加 mail 属性
- 用户查找改用 findByEmail 按 mail/cn/uid/userPrincipalName 依次匹配
- initConfig 从静态变量缓存改为 RequestContext 请求级缓存,修复 Swoole 下配置变更不生效的问题
- 默认登录属性为 cn,与旧版本行为一致,确保向后兼容

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
kuaifan 2026-04-15 09:18:36 +00:00
parent f27cef2d66
commit 09edb14d56
4 changed files with 109 additions and 34 deletions

View File

@ -612,6 +612,7 @@ class SystemController extends AbstractController
'ldap_password', 'ldap_password',
'ldap_user_dn', 'ldap_user_dn',
'ldap_base_dn', 'ldap_base_dn',
'ldap_login_attr',
'ldap_sync_local' 'ldap_sync_local'
])) { ])) {
unset($all[$key]); unset($all[$key]);
@ -625,6 +626,7 @@ class SystemController extends AbstractController
// //
$setting['ldap_open'] = $setting['ldap_open'] ?: 'close'; $setting['ldap_open'] = $setting['ldap_open'] ?: 'close';
$setting['ldap_port'] = intval($setting['ldap_port']) ?: 389; $setting['ldap_port'] = intval($setting['ldap_port']) ?: 389;
$setting['ldap_login_attr'] = $setting['ldap_login_attr'] ?: 'cn';
$setting['ldap_sync_local'] = $setting['ldap_sync_local'] ?: 'close'; $setting['ldap_sync_local'] = $setting['ldap_sync_local'] ?: 'close';
// //
return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}')); return Base::retSuccess($type == 'save' ? '保存成功' : 'success', $setting ?: json_decode('{}'));

View File

@ -4,6 +4,7 @@ namespace App\Ldap;
use App\Models\User; use App\Models\User;
use App\Module\Base; use App\Module\Base;
use App\Services\RequestContext;
use LdapRecord\Configuration\ConfigurationException; use LdapRecord\Configuration\ConfigurationException;
use LdapRecord\Container; use LdapRecord\Container;
use LdapRecord\LdapRecordException; use LdapRecord\LdapRecordException;
@ -11,7 +12,6 @@ use LdapRecord\Models\Model;
class LdapUser extends Model class LdapUser extends Model
{ {
protected static $init = null;
/** /**
* The object classes of the LDAP model. * The object classes of the LDAP model.
* *
@ -22,9 +22,10 @@ class LdapUser extends Model
'organizationalPerson', 'organizationalPerson',
'person', 'person',
'top', 'top',
'posixAccount',
]; ];
private static $emailAttrs = ['mail', 'cn', 'uid', 'userPrincipalName'];
/** /**
* @return mixed|null * @return mixed|null
*/ */
@ -68,19 +69,29 @@ class LdapUser extends Model
return Base::settingFind('thirdAccessSetting', 'ldap_sync_local') === 'open'; return Base::settingFind('thirdAccessSetting', 'ldap_sync_local') === 'open';
} }
/**
* 获取登录属性名
* @return string
*/
public static function getLoginAttr(): string
{
$attr = Base::settingFind('thirdAccessSetting', 'ldap_login_attr');
return in_array($attr, ['cn', 'uid', 'mail', 'sAMAccountName']) ? $attr : 'cn';
}
/** /**
* 初始化配置 * 初始化配置
* @return bool * @return bool
*/ */
public static function initConfig() public static function initConfig()
{ {
if (is_bool(self::$init)) { if (RequestContext::has('ldap_init')) {
return self::$init; return RequestContext::get('ldap_init');
} }
// //
$setting = Base::setting('thirdAccessSetting'); $setting = Base::setting('thirdAccessSetting');
if ($setting['ldap_open'] !== 'open') { if ($setting['ldap_open'] !== 'open') {
return self::$init = false; return RequestContext::save('ldap_init', false);
} }
// //
$connection = Container::getDefaultConnection(); $connection = Container::getDefaultConnection();
@ -92,15 +103,15 @@ class LdapUser extends Model
"username" => $setting['ldap_user_dn'], "username" => $setting['ldap_user_dn'],
"password" => $setting['ldap_password'], "password" => $setting['ldap_password'],
]); ]);
return self::$init = true; return RequestContext::save('ldap_init', true);
} catch (ConfigurationException $e) { } catch (ConfigurationException $e) {
info($e->getMessage()); info($e->getMessage());
return self::$init = false; return RequestContext::save('ldap_init', false);
} }
} }
/** /**
* 获取 * 通过管理员绑定搜索用户,然后用用户 DN Bind 认证
* @param $username * @param $username
* @param $password * @param $password
* @return Model|null * @return Model|null
@ -111,16 +122,68 @@ class LdapUser extends Model
return null; return null;
} }
try { try {
return self::static() $loginAttr = self::getLoginAttr();
->where([ $row = self::static()
'cn' => $username, ->whereRaw($loginAttr, '=', $username)
'userPassword' => $password ->first();
])->first(); if (!$row) {
return null;
}
$connection = Container::getDefaultConnection();
if (!$connection->auth()->attempt($row->getDn(), $password)) {
return null;
}
// Swoole 下连接共享,必须恢复管理员绑定
$connection->auth()->attempt(
$connection->getConfiguration()->get('username'),
$connection->getConfiguration()->get('password')
);
return $row;
} catch (\Exception $e) {
info("[LDAP] auth fail: " . $e->getMessage());
return null;
}
}
/**
* 通过邮箱查找 LDAP 用户
* @param $email
* @return Model|null
*/
public static function findByEmail($email): ?Model
{
if (!self::initConfig()) {
return null;
}
try {
foreach (self::$emailAttrs as $attr) {
$row = self::static()->whereRaw($attr, '=', $email)->first();
if ($row) {
return $row;
}
}
return null;
} catch (\Exception) { } catch (\Exception) {
return null; return null;
} }
} }
/**
* 获取用户的邮箱(从 LDAP 记录中提取)
* @param Model $row
* @return string|null
*/
public static function getUserEmail(Model $row): ?string
{
foreach (self::$emailAttrs as $attr) {
$val = $row->getFirstAttribute($attr);
if ($val && Base::isEmail($val)) {
return $val;
}
}
return null;
}
/** /**
* 登录 * 登录
* @param $username * @param $username
@ -138,7 +201,11 @@ class LdapUser extends Model
return null; return null;
} }
if (empty($user)) { if (empty($user)) {
$user = User::reg($username, $password); $email = self::getUserEmail($row) ?: $username;
$user = User::whereEmail($email)->first();
if (empty($user)) {
$user = User::reg($email, $password);
}
} }
if ($user) { if ($user) {
$userimg = $row->getPhoto(); $userimg = $row->getPhoto();
@ -173,7 +240,7 @@ class LdapUser extends Model
} }
// //
if (self::isSyncLocal()) { if (self::isSyncLocal()) {
$row = self::userFirst($user->email, $password); $row = self::findByEmail($user->email);
if ($row) { if ($row) {
return; return;
} }
@ -184,17 +251,18 @@ class LdapUser extends Model
} else { } else {
$userimg = ''; $userimg = '';
} }
self::static()->create([ $attrs = [
'cn' => $user->email, 'cn' => $user->email,
'gidNumber' => 0,
'homeDirectory' => '/home/ldap/dootask/' . env("APP_NAME"),
'sn' => $user->email, 'sn' => $user->email,
'uid' => $user->email, 'uid' => $user->email,
'uidNumber' => $user->userid,
'userPassword' => $password, 'userPassword' => $password,
'displayName' => $user->nickname, 'displayName' => $user->nickname,
'jpegPhoto' => $userimg, 'mail' => $user->email,
]); ];
if ($userimg) {
$attrs['jpegPhoto'] = $userimg;
}
self::static()->create($attrs);
$user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['ldap']), ['ldap'])); $user->identity = Base::arrayImplode(array_merge(array_diff($user->identity, ['ldap']), ['ldap']));
$user->save(); $user->save();
} catch (LdapRecordException $e) { } catch (LdapRecordException $e) {
@ -205,11 +273,11 @@ class LdapUser extends Model
/** /**
* 更新 * 更新
* @param $username * @param $email
* @param $array * @param $array
* @return void * @return void
*/ */
public static function userUpdate($username, $array) public static function userUpdate($email, $array)
{ {
if (empty($array)) { if (empty($array)) {
return; return;
@ -218,10 +286,7 @@ class LdapUser extends Model
return; return;
} }
try { try {
$row = self::static() $row = self::findByEmail($email);
->where([
'cn' => $username,
])->first();
$row?->update($array); $row?->update($array);
} catch (\Exception $e) { } catch (\Exception $e) {
info("[LDAP] update fail: " . $e->getMessage()); info("[LDAP] update fail: " . $e->getMessage());
@ -230,19 +295,16 @@ class LdapUser extends Model
/** /**
* 删除 * 删除
* @param $username * @param $email
* @return void * @return void
*/ */
public static function userDelete($username) public static function userDelete($email)
{ {
if (!self::initConfig()) { if (!self::initConfig()) {
return; return;
} }
try { try {
$row = self::static() $row = self::findByEmail($email);
->where([
'cn' => $username,
])->first();
$row?->delete(); $row?->delete();
} catch (\Exception $e) { } catch (\Exception $e) {
info("[LDAP] delete fail: " . $e->getMessage()); info("[LDAP] delete fail: " . $e->getMessage());

View File

@ -2357,4 +2357,6 @@ AI任务分析
(*)和(*)等人的聊天记录 (*)和(*)等人的聊天记录
生日 生日
请选择生日 请选择生日
登录属性
用于匹配登录用户名的 LDAP 属性Active Directory 请选择 sAMAccountName

View File

@ -33,6 +33,15 @@
<FormItem :label="$L('密码')" prop="ldap_password"> <FormItem :label="$L('密码')" prop="ldap_password">
<Input v-model="formData.ldap_password" type="password"/> <Input v-model="formData.ldap_password" type="password"/>
</FormItem> </FormItem>
<FormItem :label="$L('登录属性')" prop="ldap_login_attr">
<RadioGroup v-model="formData.ldap_login_attr">
<Radio label="uid">uid</Radio>
<Radio label="cn">cn</Radio>
<Radio label="mail">mail</Radio>
<Radio label="sAMAccountName">sAMAccountName</Radio>
</RadioGroup>
<div class="form-tip">{{$L('用于匹配登录用户名的 LDAP 属性Active Directory 请选择 sAMAccountName')}}</div>
</FormItem>
<FormItem :label="$L('同步本地帐号')" prop="ldap_sync_local"> <FormItem :label="$L('同步本地帐号')" prop="ldap_sync_local">
<RadioGroup v-model="formData.ldap_sync_local"> <RadioGroup v-model="formData.ldap_sync_local">
<Radio label="open">{{ $L('开启') }}</Radio> <Radio label="open">{{ $L('开启') }}</Radio>