mailcow 集成 Adldap2 的 LDAP Contact 模型:创建、成员关系与 Schema 扩展实践
【免费下载链接】mailcow-dockerizedmailcow: dockerized - 🐮 + 🐋 = 💕项目地址: https://gitcode.com/GitHub_Trending/ma/mailcow-dockerized
本篇技术指南聚焦 mailcow-dockerized 仓库中随附的 Adldap2 LDAP 库的 Contact(联系人)模型,讲解如何在 Active Directory / OpenLDAP 目录中创建、读写联系人对象,梳理其与 User 模型的区别与继承关系,并结合 mailcow 的 LDAP 同步场景(data/conf/phpfpm/crons/ldap-sync.php)说明实际使用方式。读完本文,你将掌握通过$provider->make()->contact()创建联系人、利用HasMemberOf处理组成员关系,以及通过自定义 Schema 扩展 Contact 模型的完整套路。
Contact 模型的定位:Adldap2 模型体系中的"轻量用户"
在 Adldap2 的模型体系中,Adldap\Models\Contact是所有 LDAP 模型中最特殊的一类:它没有属于自己的专属方法或专属属性,而是全部继承自基类。官方文档(data/web/inc/lib/vendor/adldap2/adldap2/docs/models/contact.md)对此的表述是:
The Contact model extends from the base
Adldap\Models\Modelclass and contains no specific methods / attributes that are limited to it.
从仓库源码看,这一点得到了精确印证。Contact类的完整实现只有短短十几行(data/web/inc/lib/vendor/adldap2/adldap2/src/Models/Contact.php):
namespace Adldap\Models; /** * Class Contact. * * Represents an LDAP contact. */ class Contact extends Entry { use Concerns\HasMemberOf; use Concerns\HasUserProperties; }三个关键信息:
- 继承自
Entry而非直接继承Model:Entry位于Adldap\Models命名空间下,是目录中"条目"的通用抽象;而Model是整个模型体系的基类。从源码结构看,Contact通过Entry间接获得了基类Model的全部能力(属性读写、save()、create()、update()、delete()、move()、rename()、DN 构建等)。 - 使用了
HasMemberOftrait:联系人可以像用户、组一样参与组成员关系,能够查询、添加、移除所在组(详见下文)。 - 使用了
HasUserPropertiestrait:联系人虽然不拥有账号属性(密码、登录名等),但可以直接复用"用户资料类"属性(姓名、邮箱、电话、部门、地址等),这也是它被称为"轻量用户"的原因。
创建 Contact 对象
Contact 模型本身不提供构造器级别的差异,创建它的标准方式是通过 provider 的模型工厂make(),并传入初始化属性数组:
// Adldap\Models\Contact $contact = $provider->make()->contact([ 'cn' => 'Suzy Doe', ]);这里$provider是已连接 LDAP 服务器的 provider 实例(Adldap\Connections\ProviderInterface)。上述调用等价于"构造一个 cn 为 'Suzy Doe' 的 Contact 模型实例",但此时它尚未写入目录——需要后续调用$contact->save()才会真正持久化。
从源码层面看,工厂方法contact()(data/web/inc/lib/vendor/adldap2/adldap2/src/Models/Factory.php)实际做了三件事:
public function contact(array $attributes = []) { $model = $this->schema->contactModel(); return (new $model($attributes, $this->query)) ->setAttribute($this->schema->objectClass(), [ $this->schema->top(), $this->schema->person(), $this->schema->organizationalPerson(), $this->schema->contact(), ]); }- 通过
$this->schema->contactModel()获取当前 Schema 对应的 Contact 模型类(默认Adldap\Models\Contact,Schema.php); - 实例化模型并绑定查询构建器
$this->query; - 自动写入
objectClass属性,值为四个对象类的组合:top、person、organizationalPerson、contact。
也就是说,调用make()->contact()后,对象已经被预置了完整的 LDAP 对象类链,无需手动设置objectClass。这是 Contact 与 User 在目录 schema 层面最本质的区别:Contact 使用contact对象类,而 User 使用user对象类,二者都继承自top -> person -> organizationalPerson。
完整创建流程示例
结合基类能力(docs/models/model.md),一个完整的"创建并保存联系人"流程如下:
// 1. 通过工厂创建 Contact 并填入初始属性 $contact = $provider->make()->contact([ 'cn' => 'Suzy Doe', ]); // 2. 使用 DN 构建器指定联系人所在的组织单元 // 生成的 DN 形如:CN=Suzy Doe,OU=Contacts,DC=acme,DC=org $dn = $contact->getDnBuilder()->addOu('Contacts'); $contact->setDn($dn); // 3. 补充联系人资料(HasUserProperties 提供) $contact->setFirstName('Suzy'); $contact->setLastName('Doe'); $contact->setEmail('suzy.doe@acme.org'); $contact->setTelephoneNumber('+1 555 0100'); $contact->setDepartment('Public Relations'); $contact->setTitle('PR Manager'); // 4. 持久化到 LDAP 目录 if ($contact->save()) { // 保存成功后,模型属性会从服务器重新同步 echo $contact->exists; // true } else { // 保存失败处理 }要点说明:
save()返回布尔值;保存成功后模型属性会自动与 LDAP 服务器重新同步(见 model.md 的 Note),因此同一请求内可以立刻继续基于该模型做其他操作;- 若确定对象尚不存在,可改用
create();若确定对象已存在,可改用update(); - 与 User 模型不同,创建 Contact 不需要设置密码,因此不要求连接启用 SSL/TLS(User 文档中明确提示设置密码时 SSL/TLS 必须开启,见 user.md,Contact 无此约束)。
Contact 可用的属性与方法
由于 Contact 继承自Entry/Model并组合了HasUserPropertiestrait,它可以使用的 API 分为两大类。
1. 全部模型通用的基类方法(来自Model/Entry)
以下是文档列出的通用 getter(model.md),Contact 全部可用:
$contact->getName(); // 'name' 属性 $contact->getCommonName(); // 'cn' 属性 $contact->getDisplayName(); // 'displayname' 属性 $contact->getAccountName(); // 'samaccountname'(Contact 上通常为空) $contact->getCreatedAt(); // 'whencreated' 属性 $contact->getCreatedAtDate(); // MySQL 时间戳格式 $contact->getCreatedAtTimestamp();// Unix 时间戳格式 $contact->getUpdatedAt(); // 'whenchanged' 属性 $contact->getObjectClass(); // 'objectclass' 属性 $contact->getObjectCategory(); // 根对象类别字符串 $contact->getObjectSid(); // 二进制 SID $contact->getObjectGuid(); // 二进制 GUID $contact->getConvertedSid(); // 字符串 SID $contact->getConvertedGuid(); // 字符串 GUID $contact->getPrimaryGroupId(); // 主组 ID通用的属性读写、增删改查能力同样继承自基类:
// 读取 $contact->getAttributes(); // 全部属性数组 $contact->getAttribute('mail'); // 邮箱数组,无则 null $contact->getFirstAttribute('mail'); // 第一个邮箱 $contact->mail; // 属性方式访问 $contact->mail[0]; // 第一个邮箱 // 写入 $contact->setAttribute('cn', 'New Name'); // 方法方式 $contact->setFirstAttribute('mail', 'a@b.c'); // 覆盖第一个值 $contact->cn = 'New Name'; // 属性方式 $contact->fill(['cn' => 'New Name', 'mail' => 'a@b.c']); // 批量填充 // 判断 $contact->hasAttribute('mail'); // 是否含某属性 $contact->countAttributes(); // 属性总数 $contact->inOu('Contacts'); // 是否位于某 OU $contact->isWritable(); // 是否可写 $contact->getDirty(); // 已修改属性 $contact->getOriginal(); // 原始属性特别提醒:设置布尔型 LDAP 属性时,不能直接使用0/1/true/false(保存时会被转成整数导致 LDAP 服务器报错),必须使用字符串'TRUE'/'FALSE'。这是 Adldap2 在多值属性模型下的一个经典坑,例如:
$contact->setFirstAttribute('showInAddressBook', 'TRUE'); $contact->save();删除属性时,将属性设为null或调用deleteAttribute();创建新属性时,直接给不存在的属性赋值并在save()时会自动创建,也可以调用createAttribute()/updateAttribute()单独操作。
2. 用户资料类属性(来自HasUserPropertiestrait)
HasUserProperties为 Contact 提供了丰富的"通讯录字段"读写方法(HasUserProperties.php):
| 方法(getter / setter) | 对应 LDAP 属性 | 说明 |
|---|---|---|
getEmail()/setEmail() | mail | 主邮箱;set 会清空其余邮箱 |
getFirstName()/setFirstName() | givenName | 名 |
getLastName()/setLastName() | sn | 姓 |
getTitle()/setTitle() | title | 职位 |
getDepartment()/setDepartment() | department | 部门 |
getCountry()/setCountry() | c | 国家 |
getStreetAddress()/setStreetAddress() | streetAddress | 街道地址 |
getPostalCode()/setPostalCode() | postalCode | 邮政编码 |
getPostOfficeBox()/setPostOfficeBox() | postOfficeBox | 邮箱号(PO Box) |
getTelephoneNumber()/setTelephoneNumber() | telephoneNumber | 电话 |
getFacsimileNumber()/setFacsimileNumber() | facsimileTelephoneNumber | 传真 |
getMobileNumber()/setMobileNumber() | mobile | 主手机号 |
getOtherMobileNumber()/setOtherMobileNumber() | otherMobile | 备用手机号 |
getInitials()/setInitials() | initials | 姓名缩写 |
getIpPhone()/setIpPhone() | ipPhone | IP 电话 |
getManager()/setManager() | manager | 上级(存 DN) |
getMailNickname() | mailNickname | 邮件昵称 |
getProxyAddresses()/setProxyAddresses() | proxyAddresses | 代理地址数组 |
addProxyAddress() | proxyAddresses | 追加一个代理地址 |
getOtherMailbox()/setOtherMailbox() | otherMailbox | 其他邮箱 |
这些方法内部都通过$this->schema->xxx()将属性名映射为当前目录类型(Active Directory / OpenLDAP / FreeIPA 等)的真实 LDAP 属性名,例如getEmail()实现为$this->getFirstAttribute($this->schema->email()),而schema->email()在 Active Directory Schema 中返回mail。
组与成员关系:HasMemberOf 的实战用法
Contact 通过HasMemberOftrait 获得完整的组成员关系能力(源码见 HasMemberOf.php,配套文档见 docs/models/traits/has-member-of.md)。
查询所在组
// 获取联系人所在的所有组(返回 Adldap\Query\Collection,元素为 Group 模型) $groups = $contact->getGroups(); foreach ($groups as $group) { echo $group->getCommonName(); // 如 'PR-Team' } // 只取需要的字段以加速查询 $groups = $contact->getGroups(['cn']); // 递归获取嵌套组 $groups = $contact->getGroups([], true); // 只要组名(一维数组) $names = $contact->getGroupNames(); $names = $contact->getGroupNames(true); // 递归注意:getGroups()默认只返回直接所属组;要包含嵌套组需传第二个参数true。递归实现中通过$visited数组记录已访问 DN 来防止环形组依赖造成死循环(见 HasMemberOf.php)。
判断成员资格
// 传入 Group 模型 $group = $provider->search()->groups()->find('PR-Team'); if ($contact->inGroup($group)) { /* ... */ } // 传入多个组(数组 / Collection) $groups = $provider->search()->findManyBy('cn', ['PR-Team', 'Office']); if ($contact->inGroup($groups->toArray())) { /* ... */ } // 传入 DN 数组 $dns = ['cn=PR-Team,ou=Groups,dc=acme,dc=org']; if ($contact->inGroup($dns, true)) { /* ... */ } // 传入组名数组 if ($contact->inGroup(['PR-Team', 'Office'], true)) { /* ... */ }inGroup()的第三参数$recursive控制是否包含嵌套组。从源码看,其判定逻辑支持三种输入形式:Group模型实例(比较 DN)、可解析的 DN 字符串(比较 DN)、普通字符串(比较组cn),见 groupIsParent()。
添加 / 移除组
// 添加:传 Group 模型或组的 DN 字符串均可 $group = $provider->search()->groups()->find('PR-Team'); $contact->addGroup($group); $contact->addGroup('cn=PR-Team,ou=Groups,dc=acme,dc=org'); // 移除 $contact->removeGroup($group); $contact->removeGroup('cn=PR-Team,ou=Groups,dc=acme,dc=org'); // 移除所有组,返回成功移除的组 DN 数组 $removed = $contact->removeAllGroups();重要:
addGroup()/removeGroup()内部直接操作目录,不需要再调用save(),方法返回布尔值,可直接用于if判断。
Contact 与 User 的区别及查询过滤
HasUserProperties让 Contact 拥有和 User 相似的外表,但两者在目录中本质不同:
| 维度 | Contact | User |
|---|---|---|
| 对象类 | top + person + organizationalPerson + contact | top + person + organizationalPerson + user |
| 账号能力 | 无登录、无密码、无 UAC | 有登录名、密码、UAC 控制 |
| 创建约束 | 无需 SSL/TLS | 设置密码时要求 SSL/TLS |
| 主组 | 无 | 有主组(getPrimaryGroup()) |
| 常用场景 | 通讯录、外部联系人、资源对象 | 真实用户账号 |
Adldap2 的查询构建器也针对这一区别做了处理。在Query\Factory::users()([data/web/inc/lib/vendor/adldap2/adldap2/src/Query/Factory.php#L143-L158])中,查询用户时会额外追加一个过滤条件:排除objectClass=contact的对象,而且该条件仅在 Active Directory Schema 下才加入(OpenLDAP 不支持对 contact 对象类的"不等于"过滤写法):
// OpenLDAP doesn't like specifying the omission of user objectclasses // equal to `contact`. We'll make sure we're working with // ActiveDirectory before adding this filter. if (is_a($this->schema, ActiveDirectory::class)) { $wheres[] = [$this->schema->objectClass(), Operator::$doesNotEqual, $this->schema->objectClassContact()]; }相应地,查询构建器也提供了专门的contacts()方法(Query/Factory.php),返回限定在 contact 对象类范围内的查询构建器,可用于批量检索联系人。
结合 Schema 扩展 Contact:自定义模型与自定义目录类型
Adldap2 从 v8.0.0 起支持通过自定义 Schema 替换默认模型。如果你的业务需要给 Contact 增加自定义方法,可以按以下步骤(流程见 model.md):
第一步:创建继承自Adldap\Models\Contact的自定义模型
namespace App\Ldap\Models; use Adldap\Models\Contact as Model; class Contact extends Model { public function getFullName() { return trim($this->getFirstName() . ' ' . $this->getLastName()); } }约束:自定义模型必须继承自现有 Adldap2 模型,因为很多方法与属性只存在于这些类上。
第二步:创建自定义 Schema 并返回你的模型类名
namespace App\Ldap\Schemas; use App\Ldap\Models\Contact; class LdapSchema extends ActiveDirectory { public function contactModel() { return Contact::class; } }第三步:在连接配置中指定 Schema
$config = [ 'hosts' => ['ldap.corp.local'], 'username' => 'admin', 'password' => 'P@ssword', 'schema' => App\Ldap\Schemas\LdapSchema::class, ]; $ad = new Adldap($config); $provider = $ad->connect(); // 此后所有 contact 结果都会返回你的自定义模型 $contact = $provider->search()->contacts()->find('Suzy Doe');Schema 是 Adldap2 属性映射的核心:默认ActiveDirectorySchema 定义了所有 LDAP 属性名(contact、contactModel、email、memberOf等,见 Schema.php),仓库同时提供了OpenLDAP、FreeIPA、Directory389、EDirectory等实现(Schemas 目录)。如果你连接的是 OpenLDAP / FreeIPA 等非 Active Directory 服务器,必须在配置中显式切换 schema,否则查询结果可能无法映射到正确的模型实例(setup.md)。
在 mailcow 项目中的实际用途
mailcow-dockerized 将 Adldap2 以 vendor 依赖的形式内置于 Web 前端代码中(位于data/web/inc/lib/vendor/adldap2/adldap2),其 LDAP 相关功能集中在 cron 脚本 data/conf/phpfpm/crons/ldap-sync.php 与 admin 配置页(data/web/templates/admin/tab-ldap.twig)。在实际部署中,Contact 模型主要用于两类场景:
- 目录联系人同步:将 AD/LDAP 目录中的 Contact 条目作为通讯录来源同步到邮件系统,供用户在 Webmail / SOGo 中检索外部联系人;
- 成员资格驱动的权限派生:利用
HasMemberOf的能力,把联系人所在的 AD 组映射为邮件系统的别名/转发规则,实现"按组管理收件人"。
需要强调的是:mailcow 的 LDAP 同步主流程以User 模型为核心(账号登录、邮箱归属),Contact 模型主要用于补充通讯录数据;具体同步哪些对象类、映射哪些属性,取决于你在管理界面 LDAP 配置中的设置,请以实际配置为准。
小结
Adldap2 的 Contact 模型是一个"零专属逻辑"但能力完整的 LDAP 模型:
- 通过
$provider->make()->contact([...])创建,工厂方法自动写入top/person/organizationalPerson/contact对象类; - 组合
HasMemberOf与HasUserProperties,同时获得组成员管理与通讯录字段读写能力; - 全部属性读写、增删改查、DN 操作继承自基类
Model/Entry; - 在 Active Directory 下查询用户时会被显式排除,需用
contacts()方法单独检索; - 需要扩展时,可通过自定义 Schema + 自定义模型无缝替换默认
Contact实现。
掌握这些要点后,无论是为 mailcow 补充 LDAP 通讯录同步,还是在其他 PHP 项目中对接 AD/OpenLDAP 联系人数据,都能直接复用上述模式。
延伸阅读
- Contact 模型官方文档
- 模型基类:创建 / 更新 / 属性操作 / 移动 / 删除
- User 模型(含密码创建约束)
- HasMemberOf Trait 文档
- Contact 源码实现
- 模型工厂 contact() 方法
- Schema 属性映射定义
- 连接配置与 Schema 选择
- mailcow LDAP 同步 cron 脚本
【免费下载链接】mailcow-dockerizedmailcow: dockerized - 🐮 + 🐋 = 💕项目地址: https://gitcode.com/GitHub_Trending/ma/mailcow-dockerized
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考