MDL-51723 ldap: Normalise the user objectclass
[moodle.git] / lib / ldaplib.php
1 <?php
3 /**
4  * ldaplib.php - LDAP functions & data library
5  *
6  * Library file of miscellaneous general-purpose LDAP functions and
7  * data structures, useful for both ldap authentication (or ldap based
8  * authentication like CAS) and enrolment plugins.
9  *
10  * @author     Iñaki Arenaza
11  * @package    core
12  * @subpackage lib
13  * @copyright  1999 onwards Martin Dougiamas  http://dougiamas.com
14  * @copyright  2010 onwards Iñaki Arenaza
15  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
16  */
18 defined('MOODLE_INTERNAL') || die();
20 // rootDSE is defined as the root of the directory data tree on a directory server.
21 if (!defined('ROOTDSE')) {
22     define ('ROOTDSE', '');
23 }
25 // Default page size when using LDAP paged results
26 if (!defined('LDAP_DEFAULT_PAGESIZE')) {
27     define('LDAP_DEFAULT_PAGESIZE', 250);
28 }
30 /**
31  * Returns predefined user types
32  *
33  * @return array of predefined user types
34  */
35 function ldap_supported_usertypes() {
36     $types = array();
37     $types['edir'] = 'Novell Edirectory';
38     $types['rfc2307'] = 'posixAccount (rfc2307)';
39     $types['rfc2307bis'] = 'posixAccount (rfc2307bis)';
40     $types['samba'] = 'sambaSamAccount (v.3.0.7)';
41     $types['ad'] = 'MS ActiveDirectory';
42     $types['default'] = get_string('default');
43     return $types;
44 }
46 /**
47  * Initializes needed variables for ldap-module
48  *
49  * Uses names defined in ldap_supported_usertypes.
50  * $default is first defined as:
51  * $default['pseudoname'] = array(
52  *                      'typename1' => 'value',
53  *                      'typename2' => 'value'
54  *                      ....
55  *                      );
56  *
57  * @return array of default values
58  */
59 function ldap_getdefaults() {
60     // All the values have to be written in lowercase, even if the
61     // standard LDAP attributes are mixed-case
62     $default['objectclass'] = array(
63                         'edir' => 'user',
64                         'rfc2307' => 'posixaccount',
65                         'rfc2307bis' => 'posixaccount',
66                         'samba' => 'sambasamaccount',
67                         'ad' => '(samaccounttype=805306368)',
68                         'default' => '*'
69                         );
70     $default['user_attribute'] = array(
71                         'edir' => 'cn',
72                         'rfc2307' => 'uid',
73                         'rfc2307bis' => 'uid',
74                         'samba' => 'uid',
75                         'ad' => 'cn',
76                         'default' => 'cn'
77                         );
78     $default['memberattribute'] = array(
79                         'edir' => 'member',
80                         'rfc2307' => 'member',
81                         'rfc2307bis' => 'member',
82                         'samba' => 'member',
83                         'ad' => 'member',
84                         'default' => 'member'
85                         );
86     $default['memberattribute_isdn'] = array(
87                         'edir' => '1',
88                         'rfc2307' => '0',
89                         'rfc2307bis' => '1',
90                         'samba' => '0', // is this right?
91                         'ad' => '1',
92                         'default' => '0'
93                         );
94     $default['expireattr'] = array (
95                         'edir' => 'passwordexpirationtime',
96                         'rfc2307' => 'shadowexpire',
97                         'rfc2307bis' => 'shadowexpire',
98                         'samba' => '', // No support yet
99                         'ad' => 'pwdlastset',
100                         'default' => ''
101                         );
102     return $default;
105 /**
106  * Checks if user belongs to specific group(s) or is in a subtree.
107  *
108  * Returns true if user belongs to a group in grupdns string OR if the
109  * DN of the user is in a subtree of the DN provided as "group"
110  *
111  * @param mixed $ldapconnection A valid LDAP connection.
112  * @param string $userid LDAP user id (dn/cn/uid/...) to test membership for.
113  * @param array $group_dns arrary of group dn
114  * @param string $member_attrib the name of the membership attribute.
115  * @return boolean
116  *
117  */
118 function ldap_isgroupmember($ldapconnection, $userid, $group_dns, $member_attrib) {
119     if (empty($ldapconnection) || empty($userid) || empty($group_dns) || empty($member_attrib)) {
120         return false;
121     }
123     $result = false;
124     foreach ($group_dns as $group) {
125         $group = trim($group);
126         if (empty($group)) {
127             continue;
128         }
130         // Check cheaply if the user's DN sits in a subtree of the
131         // "group" DN provided. Granted, this isn't a proper LDAP
132         // group, but it's a popular usage.
133         if (stripos(strrev(strtolower($userid)), strrev(strtolower($group))) === 0) {
134             $result = true;
135             break;
136         }
138         $search = ldap_read($ldapconnection, $group,
139                             '('.$member_attrib.'='.ldap_filter_addslashes($userid).')',
140                             array($member_attrib));
142         if (!empty($search) && ldap_count_entries($ldapconnection, $search)) {
143             $info = ldap_get_entries_moodle($ldapconnection, $search);
144             if (count($info) > 0 ) {
145                 // User is member of group
146                 $result = true;
147                 break;
148             }
149         }
150     }
152     return $result;
155 /**
156  * Tries connect to specified ldap servers. Returns a valid LDAP
157  * connection or false.
158  *
159  * @param string $host_url
160  * @param integer $ldap_version either 2 (LDAPv2) or 3 (LDAPv3).
161  * @param string $user_type the configured user type for this connection.
162  * @param string $bind_dn the binding user dn. If an emtpy string, anonymous binding is used.
163  * @param string $bind_pw the password for the binding user. Ignored for anonymous bindings.
164  * @param boolean $opt_deref whether to set LDAP_OPT_DEREF on this connection or not.
165  * @param string &$debuginfo the debugging information in case the connection fails.
166  * @param boolean $start_tls whether to use LDAP with TLS (not to be confused with LDAP+SSL)
167  * @return mixed connection result or false.
168  */
169 function ldap_connect_moodle($host_url, $ldap_version, $user_type, $bind_dn, $bind_pw, $opt_deref, &$debuginfo, $start_tls=false) {
170     if (empty($host_url) || empty($ldap_version) || empty($user_type)) {
171         $debuginfo = 'No LDAP Host URL, Version or User Type specified in your LDAP settings';
172         return false;
173     }
175     $debuginfo = '';
176     $urls = explode(';', $host_url);
177     foreach ($urls as $server) {
178         $server = trim($server);
179         if (empty($server)) {
180             continue;
181         }
183         $connresult = ldap_connect($server); // ldap_connect returns ALWAYS true
185         if (!empty($ldap_version)) {
186             ldap_set_option($connresult, LDAP_OPT_PROTOCOL_VERSION, $ldap_version);
187         }
189         // Fix MDL-10921
190         if ($user_type === 'ad') {
191             ldap_set_option($connresult, LDAP_OPT_REFERRALS, 0);
192         }
194         if (!empty($opt_deref)) {
195             ldap_set_option($connresult, LDAP_OPT_DEREF, $opt_deref);
196         }
198         if ($start_tls && (!ldap_start_tls($connresult))) {
199             $debuginfo .= "Server: '$server', Connection: '$connresult', STARTTLS failed.\n";
200             continue;
201         }
203         if (!empty($bind_dn)) {
204             $bindresult = @ldap_bind($connresult, $bind_dn, $bind_pw);
205         } else {
206             // Bind anonymously
207             $bindresult = @ldap_bind($connresult);
208         }
210         if ($bindresult) {
211             return $connresult;
212         }
214         $debuginfo .= "Server: '$server', Connection: '$connresult', Bind result: '$bindresult'\n";
215     }
217     // If any of servers were alive we have already returned connection.
218     return false;
221 /**
222  * Search specified contexts for username and return the user dn like:
223  * cn=username,ou=suborg,o=org
224  *
225  * @param mixed $ldapconnection a valid LDAP connection.
226  * @param mixed $username username (external LDAP encoding, no db slashes).
227  * @param array $contexts contexts to look for the user.
228  * @param string $objectclass objectlass of the user (in LDAP filter syntax).
229  * @param string $search_attrib the attribute use to look for the user.
230  * @param boolean $search_sub whether to search subcontexts or not.
231  * @return mixed the user dn (external LDAP encoding, no db slashes) or false
232  *
233  */
234 function ldap_find_userdn($ldapconnection, $username, $contexts, $objectclass, $search_attrib, $search_sub) {
235     if (empty($ldapconnection) || empty($username) || empty($contexts) || empty($objectclass) || empty($search_attrib)) {
236         return false;
237     }
239     // Default return value
240     $ldap_user_dn = false;
242     // Get all contexts and look for first matching user
243     foreach ($contexts as $context) {
244         $context = trim($context);
245         if (empty($context)) {
246             continue;
247         }
249         if ($search_sub) {
250             $ldap_result = @ldap_search($ldapconnection, $context,
251                                         '(&'.$objectclass.'('.$search_attrib.'='.ldap_filter_addslashes($username).'))',
252                                         array($search_attrib));
253         } else {
254             $ldap_result = @ldap_list($ldapconnection, $context,
255                                       '(&'.$objectclass.'('.$search_attrib.'='.ldap_filter_addslashes($username).'))',
256                                       array($search_attrib));
257         }
259         if (!$ldap_result) {
260             continue; // Not found in this context.
261         }
263         $entry = ldap_first_entry($ldapconnection, $ldap_result);
264         if ($entry) {
265             $ldap_user_dn = ldap_get_dn($ldapconnection, $entry);
266             break;
267         }
268     }
270     return $ldap_user_dn;
273 /**
274  * Normalise the supplied objectclass filter.
275  *
276  * This normalisation is a rudimentary attempt to format the objectclass filter correctly.
277  *
278  * @param string $objectclass The objectclass to normalise
279  * @param string $default The default objectclass value to use if no objectclass was supplied
280  * @return string The normalised objectclass.
281  */
282 function ldap_normalise_objectclass($objectclass, $default = '*') {
283     if (empty($objectclass)) {
284         // Can't send empty filter.
285         $return = sprintf('(objectClass=%s)', $default);
286     } else if (stripos($objectclass, 'objectClass=') === 0) {
287         // Value is 'objectClass=some-string-here', so just add () around the value (filter _must_ have them).
288         $return = sprintf('(%s)', $objectclass);
289     } else if (stripos($objectclass, '(') !== 0) {
290         // Value is 'some-string-not-starting-with-left-parentheses', which is assumed to be the objectClass matching value.
291         // Build a valid filter using the value it.
292         $return = sprintf('(objectClass=%s)', $objectclass);
293     } else {
294         // There is an additional possible value '(some-string-here)', that can be used to specify any valid filter
295         // string, to select subsets of users based on any criteria.
296         //
297         // For example, we could select the users whose objectClass is 'user' and have the 'enabledMoodleUser'
298         // attribute, with something like:
299         //
300         // (&(objectClass=user)(enabledMoodleUser=1))
301         //
302         // In this particular case we don't need to do anything, so leave $this->config->objectclass as is.
303         $return = $objectclass;
304     }
306     return $return;
309 /**
310  * Returns values like ldap_get_entries but is binary compatible and
311  * returns all attributes as array.
312  *
313  * @param mixed $ldapconnection A valid LDAP connection
314  * @param mixed $searchresult A search result from ldap_search, ldap_list, etc.
315  * @return array ldap-entries with lower-cased attributes as indexes
316  */
317 function ldap_get_entries_moodle($ldapconnection, $searchresult) {
318     if (empty($ldapconnection) || empty($searchresult)) {
319         return array();
320     }
322     $i = 0;
323     $result = array();
324     $entry = ldap_first_entry($ldapconnection, $searchresult);
325     if (!$entry) {
326         return array();
327     }
328     do {
329         $attributes = array_change_key_case(ldap_get_attributes($ldapconnection, $entry), CASE_LOWER);
330         for ($j = 0; $j < $attributes['count']; $j++) {
331             $values = ldap_get_values_len($ldapconnection, $entry, $attributes[$j]);
332             if (is_array($values)) {
333                 $result[$i][$attributes[$j]] = $values;
334             } else {
335                 $result[$i][$attributes[$j]] = array($values);
336             }
337         }
338         $i++;
339     } while ($entry = ldap_next_entry($ldapconnection, $entry));
341     return ($result);
344 /**
345  * Quote control characters in texts used in LDAP filters - see RFC 4515/2254
346  *
347  * @param string filter string to quote
348  * @return string the filter string quoted
349  */
350 function ldap_filter_addslashes($text) {
351     $text = str_replace('\\', '\\5c', $text);
352     $text = str_replace(array('*',    '(',    ')',    "\0"),
353                         array('\\2a', '\\28', '\\29', '\\00'), $text);
354     return $text;
357 if(!defined('LDAP_DN_SPECIAL_CHARS')) {
358     define('LDAP_DN_SPECIAL_CHARS', 0);
360 if(!defined('LDAP_DN_SPECIAL_CHARS_QUOTED_NUM')) {
361     define('LDAP_DN_SPECIAL_CHARS_QUOTED_NUM', 1);
363 if(!defined('LDAP_DN_SPECIAL_CHARS_QUOTED_ALPHA')) {
364     define('LDAP_DN_SPECIAL_CHARS_QUOTED_ALPHA', 2);
366 if(!defined('LDAP_DN_SPECIAL_CHARS_QUOTED_ALPHA_REGEX')) {
367     define('LDAP_DN_SPECIAL_CHARS_QUOTED_ALPHA_REGEX', 3);
370 /**
371  * The order of the special characters in these arrays _IS IMPORTANT_.
372  * Make sure '\\5C' (and '\\') are the first elements of the arrays.
373  * Otherwise we'll double replace '\' with '\5C' which is Bad(tm)
374  */
375 function ldap_get_dn_special_chars() {
376     static $specialchars = null;
378     if ($specialchars !== null) {
379         return $specialchars;
380     }
382     $specialchars = array (
383         LDAP_DN_SPECIAL_CHARS              => array('\\',  ' ',   '"',   '#',   '+',   ',',   ';',   '<',   '=',   '>',   "\0"),
384         LDAP_DN_SPECIAL_CHARS_QUOTED_NUM   => array('\\5c','\\20','\\22','\\23','\\2b','\\2c','\\3b','\\3c','\\3d','\\3e','\\00'),
385         LDAP_DN_SPECIAL_CHARS_QUOTED_ALPHA => array('\\\\','\\ ', '\\"', '\\#', '\\+', '\\,', '\\;', '\\<', '\\=', '\\>', '\\00'),
386         );
387     $alpharegex = implode('|', array_map (function ($expr) { return preg_quote($expr); },
388                                           $specialchars[LDAP_DN_SPECIAL_CHARS_QUOTED_ALPHA]));
389     $specialchars[LDAP_DN_SPECIAL_CHARS_QUOTED_ALPHA_REGEX] = $alpharegex;
391     return $specialchars;
394 /**
395  * Quote control characters in AttributeValue parts of a RelativeDistinguishedName
396  * used in LDAP distinguished names - See RFC 4514/2253
397  *
398  * @param string the AttributeValue to quote
399  * @return string the AttributeValue quoted
400  */
401 function ldap_addslashes($text) {
402     $special_dn_chars = ldap_get_dn_special_chars();
404     // Use the preferred/universal quotation method: ESC HEX HEX
405     // (i.e., the 'numerically' quoted characters)
406     $text = str_replace ($special_dn_chars[LDAP_DN_SPECIAL_CHARS],
407                          $special_dn_chars[LDAP_DN_SPECIAL_CHARS_QUOTED_NUM],
408                          $text);
409     return $text;
412 /**
413  * Unquote control characters in AttributeValue parts of a RelativeDistinguishedName
414  * used in LDAP distinguished names - See RFC 4514/2253
415  *
416  * @param string the AttributeValue quoted
417  * @return string the AttributeValue unquoted
418  */
419 function ldap_stripslashes($text) {
420     $specialchars = ldap_get_dn_special_chars();
422     // We can't unquote in two steps, as we end up unquoting too much in certain cases. So
423     // we need to build a regexp containing both the 'numerically' and 'alphabetically'
424     // quoted characters. We don't use LDAP_DN_SPECIAL_CHARS_QUOTED_NUM because the
425     // standard allows us to quote any character with this encoding, not just the special
426     // ones.
427     // @TODO: This still misses some special (and rarely used) cases, but we need
428     // a full state machine to handle them.
429     $quoted = '/(\\\\[0-9A-Fa-f]{2}|' . $specialchars[LDAP_DN_SPECIAL_CHARS_QUOTED_ALPHA_REGEX] . ')/';
430     $text = preg_replace_callback($quoted,
431                                   function ($match) use ($specialchars) {
432                                       if (ctype_xdigit(ltrim($match[1], '\\'))) {
433                                           return chr(hexdec($match[1]));
434                                       } else {
435                                           return str_replace($specialchars[LDAP_DN_SPECIAL_CHARS_QUOTED_ALPHA],
436                                                              $specialchars[LDAP_DN_SPECIAL_CHARS],
437                                                              $match[1]);
438                                       }
439                                   },
440                                   $text);
442     return $text;
446 /**
447  * Check if we use LDAP version 3, otherwise the server cannot use them.
448  *
449  * @param ldapversion integer The LDAP protocol version we use.
450  *
451  * @return boolean true is paged results can be used, false otherwise.
452  */
453 function ldap_paged_results_supported($ldapversion) {
454     if ((int)$ldapversion === 3) {
455         return true;
456     }
458     return false;