عندما تضيف Gitea أو Nextcloud أو Grafana إلى شركتك، فأول سؤال يسأله التطبيق عند الدخول هو: من هذا المستخدم، وهل كلمة مروره صحيحة، وإلى أي مجموعة ينتمي؟ وإذا أجاب كل تطبيق عن هذا السؤال من قاعدة بياناته الخاصة فسوف تجد نفسك تنشئ الموظف الجديد في عشرة أماكن وتغلقه في عشرة أماكن. والحل الذي استخدمته المؤسسات منذ التسعينات هو دليل مستخدمين واحد Directory تسأله كل التطبيقات، ولغة السؤال هي LDAP. وقد ذكرنا في شرح الدخول الموحد SSO أن الدليل يقف خلف مزود الهوية، وفي هذا المقال نفتحه من الداخل.
وفي هذا المقال سوف نتناول:
- ما هو الدليل Directory وما هو LDAP، وما الفرق بينهما وبين قاعدة البيانات ومزود الهوية.
- كيف تقرأ شجرة الدليل والعنوان المميز DN والـ objectClass والصفات Attributes.
- تجربة عملية على OpenLDAP في Debian 13: الشجرة، والمجموعات مع memberOf، ومخرجات
ldapsearchكما هي. - كيف يتحقق التطبيق من كلمة المرور عبر البحث ثم الربط Search then Bind، وما رأيناه عند كلمة المرور الخاطئة.
- التعيين والنقل والمغادرة في LDAP، ومنها خطآن يقع فيهما أغلب من يبدأ.
- الحماية: منع القراءة دون دخول، وإلزام TLS، وتخزين كلمات المرور بـ Argon2، والنسخ الاحتياطي.
ما هو الدليل Directory، وما هو LDAP؟
الدليل Directory قاعدة بيانات متخصصة في الأشخاص والأجهزة والمجموعات، مصممة لتقرأ آلاف المرات ثم تكتب نادراً، ومرتبة كشجرة تشبه الهيكل التنظيمي للشركة. أما LDAP واختصاره Lightweight Directory Access Protocol فهو البروتوكول الذي تتحدث به التطبيقات مع الدليل، ومعياره الحالي RFC 4511 من 2006، ويعمل على المنفذ 389، أو 636 عندما يكون مشفراً من البداية LDAPS. والتشبيه الأقرب هو دليل الهاتف الداخلي للشركة: مرتب بالإدارات، وكل موظف له بطاقة فيها اسمه وبريده ورقمه، ويقرؤه الجميع أكثر بكثير مما يعدله أحد.
وقد يتساءل البعض: لماذا لا نضع المستخدمين في جدول في PostgreSQL ونكتفي؟ والإجابة أن المشكلة ليست في التخزين، وإنما في اللغة المشتركة، فمئات التطبيقات تعرف كيف تسأل LDAP دون أن تكتب سطراً واحداً من الكود، بينما لا يعرف أي تطبيق شكل جدولك الخاص. وفيما يلي الفرق بين الأطراف التي تختلط عادة:
| يجيب عن | أمثلة | |
|---|---|---|
| الدليل Directory عبر LDAP | من الموظفون؟ وما مجموعاتهم؟ وهل كلمة المرور صحيحة؟ | OpenLDAP و389 Directory Server وActive Directory وLLDAP |
| مزود الهوية IdP | هل الذي أمامي في المتصفح هو سارة فعلاً، ومعها MFA؟ | authentik وKeycloak، وغالباً يقرأ المستخدمين من الدليل |
| قاعدة بيانات التطبيق | ما ملفات سارة وإعداداتها داخل هذا التطبيق؟ | جداول Gitea أو Nextcloud |
والفرق المهم بين LDAP والدخول الموحد أن التطبيق مع LDAP يرى كلمة مرور المستخدم ثم يمررها إلى الدليل، أما مع SSO فلا يراها أبداً. لذلك يبقى LDAP مناسباً للتطبيقات الداخلية الموثوقة وللخدمات التي لا تعمل عبر المتصفح مثل البريد عبر IMAP وتسجيل الدخول إلى Linux، ويبقى مزود الهوية أمامه لتطبيقات الويب.
كيف تقرأ شجرة الدليل؟
كل مدخل Entry في الدليل له عنوان فريد اسمه العنوان المميز Distinguished Name واختصاراً DN، يقرأ من المدخل نفسه صعوداً إلى الجذر، والصورة التالية تبين الشجرة التي سوف ننشئها في التجربة:

- الجذر Base DN:
dc=example,dc=com، وهو غالباً اسم نطاق الشركة مقسوماً إلى مكونات Domain Component. - الوحدة التنظيمية Organizational Unit:
ou=peopleوou=groups، وهي مجلدات لترتيب المداخل فقط. - الاسم النسبي RDN: الجزء الأول من DN، مثل
uid=sara، ويجب أن يكون فريداً داخل فرعه. - الصنف objectClass: يحدد الصفات المسموحة والإلزامية للمدخل، فـ
inetOrgPersonللأشخاص وposixAccountلحسابات Linux وgroupOfNamesللمجموعات. وتعريفات هذه الأصناف تسمى المخطط Schema، وأغلبها معرف في RFC 4519. - الصفات Attributes: مثل
mailوcnوmember، ويمكن أن تحمل الصفة أكثر من قيمة.
تجربة عملية: OpenLDAP على Debian 13
سوف نستخدم OpenLDAP، وهو أقدم سيرفر LDAP مفتوح المصدر وما زال نشطاً، وآخر إصدار طويل الدعم منه 2.6.16 وإصدار الميزات 2.7.2 كما في صفحة الإصدارات، أما Debian 13 فيأتي بالحزمة slapd 2.6.10 مع إصلاحات Debian الأمنية، وهي التي جربناها. وسوف نشغل حاويتين على شبكة Docker واحدة: ldap وفيها السيرفر باسم ldap.example.com، وapp وفيها أدوات ldap-utils فقط، لتقوم بدور التطبيق الذي يسأل الدليل عبر الشبكة.
docker network create ldap-lab
docker run -d --name ldap --hostname ldap.example.com --network ldap-lab --network-alias ldap.example.com --ulimit nofile=1024 debian:trixie-20261020-slim sleep infinity
docker run -d --name app --network ldap-lab debian:trixie-20261020-slim sleep infinity
docker exec app bash -c 'apt-get update -qq && apt-get install -y -qq --no-install-recommends ldap-utils'لاحظ الخيار --ulimit nofile=1024، وسوف نعود إليه في قسم الأخطاء الشائعة لأنه كان الفرق بين 700 ميجابايت و6 ميجابايت من الذاكرة.
الخطوة 1: التثبيت دون أسئلة
حزمة slapd في Debian تسأل عن النطاق وكلمة مرور المدير أثناء التثبيت، ونجيب عنها مسبقاً عبر debconf-set-selections بهذا السكربت install.sh، وكلمة المرور تأتي من متغير بيئة ولا تكتب في الملف:
#!/bin/bash
# install.sh: install OpenLDAP on Debian 13 without questions; the admin password comes from $LDAP_ADMIN_PW
set -e
apt-get update -qq
cat <<SEL | debconf-set-selections
slapd slapd/password1 password $LDAP_ADMIN_PW
slapd slapd/password2 password $LDAP_ADMIN_PW
slapd slapd/domain string example.com
slapd shared/organization string Example
SEL
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends slapd ldap-utils ssl-cert > /dev/null
mkdir -p /var/run/slapd && chown openldap:openldap /var/run/slapd
slapd -h "ldap:/// ldapi:///" -u openldap -g openldap
dpkg-query -W slapd ldap-utilsdocker cp install.sh ldap:/root/
docker exec -e LDAP_ADMIN_PW="$(openssl rand -base64 18)" ldap bash /root/install.shldap-utils 2.6.10+dfsg-1
slapd 2.6.10+dfsg-1في الإعداد أعلاه لاحظ التالي:
- شغلنا
slapdمباشرة لأن الـ Container لا يحتوي systemd، وعلى سيرفر حقيقي تكفيكsystemctl enable --now slapd. - العنوان
ldapi:///مقبس Socket محلي، وعبره يدير root الإعدادات بالمصادقة-Y EXTERNALدون كلمة مرور، لأن النواة تخبر slapd أن المتصل هو root. - إعدادات OpenLDAP الحديثة نفسها مخزنة كمداخل في شجرة اسمها
cn=config، وتعدل بأوامر LDAP عادية دون إعادة تشغيل السيرفر، كما في صفحة slapd-config.
ولنقرأ صلاحيات الوصول الافتراضية في Debian قبل أن نضيف أي بيانات، وهذا الجزء الخاص بقاعدة بياناتنا من المخرج:
ldapsearch -Q -LLL -Y EXTERNAL -H ldapi:/// -b cn=config "(olcDatabase=*)" olcDatabase olcSuffix olcAccess olcRootDNdn: olcDatabase={1}mdb,cn=config
olcDatabase: {1}mdb
olcSuffix: dc=example,dc=com
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by * none
olcAccess: {1}to attrs=shadowLastChange by self write by * read
olcAccess: {2}to * by * read
olcRootDN: cn=admin,dc=example,dc=comتذكر السطر {2}to * by * read، فهو يعني أن أي شخص يصل إلى المنفذ 389 يقرأ كل شيء عدا كلمات المرور، حتى دون أن يسجل دخوله، وسوف نرى ذلك بأعيننا بعد قليل.
الخطوة 2: memberOf لمعرفة مجموعات المستخدم
المجموعة من نوع groupOfNames تحمل قائمة أعضائها في الصفة member، ولكن التطبيق يسأل عادة السؤال المعاكس: ما مجموعات سارة؟ والإضافة Overlay المسماة memberof تحسب ذلك تلقائياً، فتضيف إلى كل مستخدم الصفة memberOf كلما أضيف إلى مجموعة أو حذف منها. ونحملها بهذا الملف 01-memberof.ldif قبل إضافة أي مجموعة، لأنها لا تحسب المجموعات الموجودة مسبقاً:
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: memberof
dn: olcOverlay=memberof,olcDatabase={1}mdb,cn=config
changetype: add
objectClass: olcOverlayConfig
objectClass: olcMemberOf
olcOverlay: memberof
olcMemberOfRefInt: TRUEldapmodify -Q -Y EXTERNAL -H ldapi:/// -f 01-memberof.ldifولاحظ أن صفحة slapo-memberof تذكر أن الصنف olcMemberOf سوف يستبدل بـ olcMemberOfConfig في إصدار قادم، وقد عمل الاسم القديم في 2.6.10، فإذا كنت على إصدار أحدث فاستخدم الاسم الجديد.
الخطوة 3: الشجرة والمستخدمون والمجموعات
نضيف الفروع، وموظفين، وحساب خدمة لتطبيق Gitea، ومجموعتين، في ملف 02-tree.ldif بصيغة LDIF، وهي الصيغة النصية التي تكتب بها مداخل LDAP:
dn: ou=people,dc=example,dc=com
objectClass: organizationalUnit
ou: people
dn: ou=groups,dc=example,dc=com
objectClass: organizationalUnit
ou: groups
dn: ou=services,dc=example,dc=com
objectClass: organizationalUnit
ou: services
dn: uid=sara,ou=people,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: posixAccount
uid: sara
cn: Sara Ali
givenName: Sara
sn: Ali
mail: [email protected]
title: Accountant
uidNumber: 10001
gidNumber: 10001
homeDirectory: /home/sara
loginShell: /bin/bash
dn: uid=omar,ou=people,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: posixAccount
uid: omar
cn: Omar Hassan
givenName: Omar
sn: Hassan
mail: [email protected]
title: Developer
uidNumber: 10002
gidNumber: 10002
homeDirectory: /home/omar
loginShell: /bin/bash
dn: cn=svc-gitea,ou=services,dc=example,dc=com
objectClass: organizationalRole
objectClass: simpleSecurityObject
cn: svc-gitea
description: Gitea uses this account to search the directory
userPassword: {CRYPT}x
dn: cn=finance,ou=groups,dc=example,dc=com
objectClass: groupOfNames
cn: finance
member: uid=sara,ou=people,dc=example,dc=com
dn: cn=developers,ou=groups,dc=example,dc=com
objectClass: groupOfNames
cn: developers
member: uid=omar,ou=people,dc=example,dc=comldapadd -x -H ldapi:/// -D cn=admin,dc=example,dc=com -W -f 02-tree.ldif
ldappasswd -x -H ldapi:/// -D cn=admin,dc=example,dc=com -W -S uid=sara,ou=people,dc=example,dc=comالخيار -W يطلب كلمة مرور المدير، و-S يطلب كلمة المرور الجديدة للمستخدم، وكررنا الأمر الثاني لـ omar ولحساب الخدمة cn=svc-gitea، حتى لا تبقى أي كلمة مرور في سجل الأوامر History. وقيمة {CRYPT}x في حساب الخدمة مجرد قيمة مؤقتة لا تطابق أي كلمة مرور، لأن الصنف simpleSecurityObject يشترط وجود الصفة.
الخطوة 4: من يستطيع أن يقرأ الدليل؟
الآن من حاوية التطبيق، ودون أي اسم مستخدم أو كلمة مرور، نطلب كل الأشخاص:
docker exec app ldapsearch -x -LLL -H ldap://ldap.example.com -b dc=example,dc=com "(objectClass=inetOrgPerson)" cn mail titledn: uid=sara,ou=people,dc=example,dc=com
cn: Sara Ali
mail: [email protected]
title: Accountant
dn: uid=omar,ou=people,dc=example,dc=com
cn: Omar Hassan
mail: [email protected]
title: Developerوهذه قائمة الموظفين ومسمياتهم وبريدهم لأي جهاز على الشبكة، وهي بالضبط ما يحتاجه من يجهز حملة تصيد Phishing أو تخمين كلمات المرور. لذلك نغير الصلاحيات بحيث لا يقرأ إلا من سجل دخوله، بهذا الملف 03-acl.ldif:
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcAccess
olcAccess: {0}to attrs=userPassword by self write by anonymous auth by * none
olcAccess: {1}to attrs=shadowLastChange by self write by users read by * none
olcAccess: {2}to * by users read by * noneldapmodify -Q -Y EXTERNAL -H ldapi:/// -f 03-acl.ldif
docker exec app ldapsearch -x -LLL -H ldap://ldap.example.com -b dc=example,dc=com "(objectClass=inetOrgPerson)" cn mail titleNo such object (32)في الإعداد أعلاه لاحظ التالي:
- القاعدة الأولى تبقي
by anonymous authعلى كلمة المرور، وهي ما يسمح لأي شخص أن يحاول الربط Bind بكلمة مرور دون أن يقرأها، فبدونها لا يستطيع أحد أن يسجل دخوله أصلاً. - الرد
No such object (32)لمن لم يسجل دخوله، فالسيرفر لا يعترف حتى بوجود الشجرة. by users readتعني أن أي مستخدم سجل دخوله يقرأ الدليل كله عدا كلمات المرور، وهذا مقبول في شركة صغيرة، ولكن في المؤسسة الأكبر اجعل القراءة الكاملة لحسابات الخدمة فقط، والترتيب في قواعد التحكم في الوصول مهم لأن أول قاعدة تطابق هي التي تطبق.
كيف يتحقق التطبيق من كلمة المرور؟
التطبيق لا يعرف DN سارة، فهي تكتب sara فقط. لذلك تعمل أغلب التطبيقات بطريقة البحث ثم الربط Search then Bind الموضحة في الصورة التالية:

وهذه الخطوات الثلاث بأوامر حقيقية من حاوية التطبيق. أولاً يبحث حساب الخدمة عن المستخدم:
docker exec -it app ldapsearch -x -LLL -H ldap://ldap.example.com -D cn=svc-gitea,ou=services,dc=example,dc=com -W -b ou=people,dc=example,dc=com "(&(objectClass=inetOrgPerson)(uid=sara))" dn cn mail memberOfdn: uid=sara,ou=people,dc=example,dc=com
cn: Sara Ali
mail: [email protected]
memberOf: cn=finance,ou=groups,dc=example,dc=comثم يربط Bind بالعنوان الذي وجده وبكلمة المرور التي كتبتها سارة، والأداة ldapwhoami تفعل ذلك وتطبع من أنت عند السيرفر:
docker exec -it app ldapwhoami -x -H ldap://ldap.example.com -D uid=sara,ou=people,dc=example,dc=com -Wdn:uid=sara,ou=people,dc=example,dc=comوبكلمة مرور خاطئة:
ldap_bind: Invalid credentials (49)ولاحظ أن الرمز 49 نفسه يظهر عندما يكون المستخدم غير موجود وعندما تكون كلمة المرور خاطئة، حتى لا يعرف المهاجم أي الحالتين أصاب. وحتى بعد الدخول لا تستطيع سارة أن ترى كلمة مرور زميلها، فهذا ما رجع عندما طلبت userPassword وcn لعمر:
dn: uid=omar,ou=people,dc=example,dc=com
cn: Omar Hassanوأخيراً، التطبيق الذي يريد كل أعضاء مجموعة المطورين لا يحتاج إلى قراءة المجموعة ثم كل عضو، وإنما يبحث بـ memberOf مباشرة:
docker exec -it app ldapsearch -x -LLL -H ldap://ldap.example.com -D cn=svc-gitea,ou=services,dc=example,dc=com -W -b dc=example,dc=com "(memberOf=cn=developers,ou=groups,dc=example,dc=com)" uiddn: uid=omar,ou=people,dc=example,dc=com
uid: omarوهذا الفلتر نفسه هو ما تكتبه في حقل «فلتر المستخدمين User Filter» في إعدادات LDAP في أي تطبيق، إذا أردت ألا يدخل إلا المطورون. وصيغة الفلاتر بالأقواس و& و| معرفة في RFC 4515.
التعيين والنقل والمغادرة في LDAP
الدليل هو المكان الطبيعي لدورة حياة الحساب التي شرحناها في دورة حياة حساب الموظف JML، لأن كل تطبيق يقرأ منه. والتعيين Joiner رأيناه في الخطوة 3، أما النقل والمغادرة ففيهما مفاجأتان.
النقل Mover: ماذا يحدث إذا كانت سارة آخر عضو في المجموعة؟
انتقلت سارة من المالية إلى التطوير، فكتبنا ملفاً يحذفها من finance ويضيفها إلى developers، فكان الرد:
ldap_modify: Object class violation (65)
additional info: object class 'groupOfNames' requires attribute 'member'والسبب أن member صفة إلزامية في groupOfNames بحسب RFC 4519، فلا يسمح السيرفر بمجموعة فارغة، وسارة كانت العضو الوحيد في المالية. والحل الذي يناسب الواقع هنا أن تنضم الموظفة الجديدة نورة إلى المالية في نفس التعديل الذي تخرج فيه سارة، لأن LDAP ينفذ التعديلات على المدخل الواحد معاً. وفي فرق أخرى يضاف عضو ثابت لا يمثل شخصاً إلى كل مجموعة حتى لا تفرغ. وهذا الملف 04b-mover.ldif:
dn: uid=noura,ou=people,dc=example,dc=com
changetype: add
objectClass: inetOrgPerson
objectClass: posixAccount
uid: noura
cn: Noura Saleh
givenName: Noura
sn: Saleh
mail: [email protected]
title: Accountant
uidNumber: 10003
gidNumber: 10003
homeDirectory: /home/noura
loginShell: /bin/bash
dn: cn=finance,ou=groups,dc=example,dc=com
changetype: modify
add: member
member: uid=noura,ou=people,dc=example,dc=com
-
delete: member
member: uid=sara,ou=people,dc=example,dc=com
dn: cn=developers,ou=groups,dc=example,dc=com
changetype: modify
add: member
member: uid=sara,ou=people,dc=example,dc=comadding new entry "uid=noura,ou=people,dc=example,dc=com"
modifying entry "cn=finance,ou=groups,dc=example,dc=com"
modifying entry "cn=developers,ou=groups,dc=example,dc=com"ثم نسأل الدليل عن مجموعات الاثنتين، فنجد أن memberOf تغير وحده:
dn: uid=sara,ou=people,dc=example,dc=com
memberOf: cn=developers,ou=groups,dc=example,dc=com
dn: uid=noura,ou=people,dc=example,dc=com
memberOf: cn=finance,ou=groups,dc=example,dc=comالمغادرة Leaver: القفل الذي لا يقفل شيئاً
لا يوجد في LDAP القياسي حقل «معطل» للحساب، والطريقة المعروفة في OpenLDAP هي الإضافة ppolicy مع الصفة pwdAccountLockedTime، والقيمة 000001010000Z تعني بحسب صفحة slapo-ppolicy أن الحساب مقفل نهائياً ولا يفتحه إلا المدير. حملنا الإضافة بالملف 05-ppolicy.ldif ثم أقفلنا حساب سارة بالملف 06-leaver.ldif:
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: ppolicy
dn: olcOverlay=ppolicy,olcDatabase={1}mdb,cn=config
changetype: add
objectClass: olcOverlayConfig
objectClass: olcPPolicyConfig
olcOverlay: ppolicydn: uid=sara,ou=people,dc=example,dc=com
changetype: modify
add: pwdAccountLockedTime
pwdAccountLockedTime: 000001010000Zثم جربنا أن تسجل سارة دخولها بكلمة مرورها:
dn:uid=sara,ou=people,dc=example,dc=comودخلت! والصفة موجودة فعلاً على حسابها كما يظهر البحث:
dn: uid=sara,ou=people,dc=example,dc=com
cn: Sara Ali
pwdAccountLockedTime: 000001010000Zوالسبب أن ppolicy لا يطبق شيئاً على مستخدم لا تنطبق عليه سياسة كلمات مرور Password Policy، ولم نعرف أي سياسة بعد. لذلك نضيف سياسة افتراضية في الملف 07-policy.ldif، ونجعلها الافتراضية في الإضافة بالملف 08-default-policy.ldif:
dn: ou=policies,dc=example,dc=com
objectClass: organizationalUnit
ou: policies
dn: cn=default,ou=policies,dc=example,dc=com
objectClass: organizationalRole
objectClass: pwdPolicy
cn: default
pwdAttribute: userPassword
pwdLockout: TRUE
pwdMaxFailure: 5
pwdLockoutDuration: 900
pwdMinLength: 12dn: olcOverlay={1}ppolicy,olcDatabase={1}mdb,cn=config
changetype: modify
add: olcPPolicyDefault
olcPPolicyDefault: cn=default,ou=policies,dc=example,dc=comوالآن المحاولة نفسها:
ldap_bind: Invalid credentials (49)في هذه التجربة لاحظ التالي:
- لا تثق بأن الحساب مغلق لأن الأمر نجح، وإنما جرب الدخول به بعد الإغلاق، فقد رأينا أمراً ينجح ولا يغير شيئاً.
- السياسة نفسها تقفل الحساب تلقائياً بعد 5 محاولات خاطئة لمدة 900 ثانية، وتشترط 12 حرفاً على الأقل لكلمة المرور الجديدة.
- الحساب المقفل ما زال يظهر في البحث، فالتطبيق الذي ينشئ حساباته بالمزامنة من الدليل لن يعطله إلا إذا قرأ هذه الصفة، أو إذا حذفت المستخدم من مجموعة الدخول إلى التطبيق. لذلك اجعل المغادرة تحذف العضويات أيضاً.
- الإغلاق في الدليل لا يغلق مفاتيح SSH والرموز Tokens داخل التطبيقات، وهذا موضوع SCIM.
حماية الدليل: TLS وتخزين كلمات المرور والنسخ الاحتياطي
إلزام التشفير TLS
في الربط البسيط Simple Bind تمر كلمة المرور كما هي عبر الشبكة، فإذا كان الاتصال على المنفذ 389 دون تشفير فكل من يرى الشبكة يراها. لذلك أنشأنا جهة توقيع CA تجريبية وشهادة للاسم ldap.example.com، ثم أخبرنا slapd بمكانها بالملف 09-tls.ldif:
dn: cn=config
changetype: modify
replace: olcTLSCACertificateFile
olcTLSCACertificateFile: /etc/ldap/tls/ca.crt
-
replace: olcTLSCertificateFile
olcTLSCertificateFile: /etc/ldap/tls/ldap.crt
-
replace: olcTLSCertificateKeyFile
olcTLSCertificateKeyFile: /etc/ldap/tls/ldap.keyثم ألزمنا التشفير لكل ربط بكلمة مرور بالملف 10-require-tls.ldif:
dn: olcDatabase={1}mdb,cn=config
changetype: modify
replace: olcSecurity
olcSecurity: simple_bind=128والقيمة 128 هي قوة التشفير المطلوبة Security Strength Factor، والربط بكلمة مرور دون TLS يرفض الآن:
ldap_bind: Confidentiality required (13)
additional info: TLS confidentiality requiredوالخيار -ZZ يطلب StartTLS ويفشل إذا لم ينجح، وبدون أن يعرف الكلاينت جهة التوقيع يرفض الشهادة:
ldap_start_tls: Connect error (-11)
additional info: error:0A000086:SSL routines::certificate verify failed (self-signed certificate in certificate chain)ومع جهة التوقيع عبر المتغير LDAPTLS_CACERT:
docker exec -it -e LDAPTLS_CACERT=/etc/ldap/certs/example-lab-ca.crt app ldapwhoami -ZZ -x -H ldap://ldap.example.com -D uid=omar,ou=people,dc=example,dc=com -Wdn:uid=omar,ou=people,dc=example,dc=comolcSecurity: tls=1 ثم ssf=71 على قاعدة البيانات كلها، فرفض السيرفر حتى أوامر الإدارة المحلية عبر ldapi:/// بنفس الرسالة TLS confidentiality required، ولم تعد تستطيع أن تغير كلمة مرور أحد إلا عبر TLS. لذلك اخترنا simple_bind=128 الذي يلزم التشفير لكلمات المرور فقط، وجرب أي تغيير في olcSecurity على سيرفر اختبار قبل الإنتاج. وفي التطبيقات اختر دائماً StartTLS أو LDAPS، ولا تفعل خيار تجاهل الشهادة Skip Verify، لأنه يجعل التشفير بلا فائدة أمام من يعترض الاتصال.كيف تخزن كلمات المرور؟
عندما قرأ عمر مدخله الكامل بعد الدخول، ومعه الصفات التشغيلية Operational Attributes بالرمز +، ظهرت كلمة مروره مخزنة كهاش:
dn: uid=omar,ou=people,dc=example,dc=com
objectClass: inetOrgPerson
objectClass: posixAccount
uid: omar
cn: Omar Hassan
givenName: Omar
sn: Hassan
mail: [email protected]
title: Developer
uidNumber: 10002
gidNumber: 10002
homeDirectory: /home/omar
loginShell: /bin/bash
structuralObjectClass: inetOrgPerson
entryUUID: b035e834-64fc-1041-9027-91e5b778e4f2
creatorsName: cn=admin,dc=example,dc=com
createTimestamp: 20261025201621Z
memberOf: cn=developers,ou=groups,dc=example,dc=com
userPassword:: e1NTSEF9Q2QrQndzWDFEaTBTYUN2WkNUZWJ2cTZOemNlNVRReGU=
entryCSN: 20261025201623.487312Z#000000#000#000000
modifiersName: cn=admin,dc=example,dc=com
modifyTimestamp: 20261025201623Z
entryDN: uid=omar,ou=people,dc=example,dc=com
subschemaSubentry: cn=Subschema
hasSubordinates: FALSEوالقيمة بعد فك base64 هي {SSHA}Cd+BwsX1Di0SaCvZCTebvq6Nzce5TQxe، أي SHA-1 مع Salt، وهي سريعة جداً على من يحاول التخمين Cracking إذا سرق نسخة من الدليل. والحزمة في Debian تأتي بوحدة Argon2، فحملناها وجعلناها الطريقة الافتراضية بالملف 11-argon2.ldif:
dn: cn=module{0},cn=config
changetype: modify
add: olcModuleLoad
olcModuleLoad: argon2
dn: olcDatabase={-1}frontend,cn=config
changetype: modify
add: olcPasswordHash
olcPasswordHash: {ARGON2}ثم غيرنا كلمة مرور عمر عبر TLS، فأصبح الهاش:
{ARGON2}$argon2id$v=19$m=7168,t=5,p=1$l1lpXZZHNzO8cPDzmnyxMA$tOfETyzyQM3XQ3bYYwRz7RuqLCEl+d4FicFhp5+Tkuc
dn:uid=omar,ou=people,dc=example,dc=comلاحظ أن التغيير يطبق على كلمات المرور الجديدة فقط، فالقديمة تبقى SSHA حتى يغيرها أصحابها، ولذلك اجعل تغيير كلمات المرور بعد هذا الإعداد جزءاً من الخطة. والمعاملات m=7168,t=5,p=1 هي ذاكرة 7 ميجابايت وخمس دورات لكل محاولة، وهي القيم الافتراضية للوحدة.
النسخ الاحتياطي
الأمر slapcat يكتب قاعدة البيانات كلها في ملف LDIF واحد تستطيع أن تعيده بـ slapadd، والرقم -n 1 هو قاعدة البيانات، و-n 0 هو الإعدادات cn=config، وتحتاج الاثنين:
slapcat -n 0 > /root/config.ldif
slapcat -n 1 > /root/backup.ldif
grep -c "^dn:" /root/backup.ldif12اثنا عشر مدخلاً: الجذر، وأربعة فروع، وثلاثة موظفين، وحساب خدمة، ومجموعتان، والسياسة. والملف يحتوي هاشات كلمات المرور، فعامله كأنه سر.
أخطاء شائعة وما تعنيه
| ما تراه | السبب الأرجح |
|---|---|
Invalid credentials (49) | كلمة مرور خاطئة، أو DN غير صحيح، أو الحساب مقفل بـ ppolicy |
No such object (32) | الـ Base DN خطأ، أو الصلاحيات تمنعك من رؤية الشجرة |
Confidentiality required (13) | السيرفر يلزم TLS، فأضف -ZZ أو استخدم ldaps:// |
certificate verify failed | الكلاينت لا يعرف جهة التوقيع، فأضفها إلى LDAPTLS_CACERT أو إلى شهادات النظام |
Object class violation (65) | حذفت صفة إلزامية، مثل آخر member في مجموعة |
| slapd يأخذ مئات الميجابايت دون حمل | حد الملفات المفتوحة في الـ Container كبير جداً، فقد كان ulimit -n عندنا 1048576 وslapd يستهلك 703 ميجابايت، وبعد إعادة تشغيله بحد 1024 أصبح 6 ميجابايت |
OpenLDAP أم غيره؟
OpenLDAP مرن جداً وسريع ومجاني، ولكنه كما رأيت يحتاج إلى معرفة بالإعدادات في cn=config والإضافات والصلاحيات، ولا توجد له واجهة رسومية رسمية. لذلك إذا كنت تريد دليلاً صغيراً لفريق بواجهة ويب بسيطة فانظر إلى LLDAP وKanidm، وإذا كانت لديك أجهزة Linux كثيرة تحتاج إلى سياسات sudo وKerberos فانظر إلى FreeIPA، وإذا كانت المؤسسة تعمل على Windows فـ Active Directory أو Samba هو الدليل الطبيعي:
دليلKanidm وLLDAP: دليل مستخدمين خفيف بديل Active Directoryجربنا LLDAP وKanidm في Docker بمستخدمين عرب ومخرجات LDAP حقيقية، لترى متى يكفيك دليل خفيف مجاني بدلاً من Active Directory.
دليلFreeIPA: إدارة هويات وأجهزة Linux في المؤسسةما يجمعه FreeIPA من دليل وKerberos وقواعد دخول وsudo مركزية لسيرفرات Linux، وما حدث عندما حاولنا تشغيله في Docker.
دليلما هو الدخول الموحد SSO؟ المصطلحات التي تحتاجها أولاًخريطة لمصطلحات الدخول الموحد SSO مع تجربة حقيقية تبين كيف تفتح جلسة واحدة في مزود الهوية تطبيقين، وماذا يبقى بعد الخروج.
دليلتثبيت authentik للدخول الموحد SSO إلى تطبيقاتككيف تثبت authentik حتى يدخل كل موظف إلى تطبيقات الفريق بحساب واحد، مع فرض المصادقة الثنائية على الجميع.
دليلدورة حياة حساب الموظف JML من أول يوم إلى آخر يومكيف تجعل الموارد البشرية مصدر كل حساب، فيجد الموظف ما يحتاجه في يومه الأول وتغلق كل أبوابه في يوم مغادرته.الخلاصة
- الدليل Directory قاعدة بيانات للأشخاص والمجموعات مرتبة كشجرة، وLDAP هو اللغة المشتركة التي تسأله بها مئات التطبيقات، على المنفذ 389 مع StartTLS أو 636 مع LDAPS.
- كل مدخل له DN يقرأ من المدخل إلى الجذر، والـ objectClass يحدد صفاته، والمجموعات من نوع groupOfNames مع memberOf لمعرفة مجموعات المستخدم.
- التطبيقات تتحقق بالبحث ثم الربط: حساب خدمة يبحث عن DN المستخدم، ثم يربط التطبيق بكلمة مرور المستخدم، والرمز 49 يعني الفشل دون تفصيل.
- الإعداد الافتراضي في Debian يسمح لأي شخص بقراءة الدليل دون دخول، فغير الصلاحيات قبل أي شيء.
- القفل بـ pwdAccountLockedTime لا يعمل دون سياسة كلمات مرور افتراضية، والمجموعة لا تقبل أن تفرغ، فجرب كل إغلاق بمحاولة دخول حقيقية.
- ألزم TLS لكلمات المرور بـ
simple_bind=128، واستخدم Argon2 بدلاً من SSHA، وانسخcn=configوقاعدة البيانات بـ slapcat، وحددulimit -nعند تشغيله في Container.
سجل التحديثات
- أكتوبر 2026: كتابة المقال، وتنفيذ التجربة على OpenLDAP 2.6.10 (حزمة Debian 13) مع ldap-utils من نفس الإصدار.