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