MDL-52387 auth_ldap: add support for fine grained password
[moodle.git] / auth / ldap / auth.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  * Authentication Plugin: LDAP Authentication
19  * Authentication using LDAP (Lightweight Directory Access Protocol).
20  *
21  * @package auth_ldap
22  * @author Martin Dougiamas
23  * @author IƱaki Arenaza
24  * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
25  */
27 defined('MOODLE_INTERNAL') || die();
29 // See http://support.microsoft.com/kb/305144 to interprete these values.
30 if (!defined('AUTH_AD_ACCOUNTDISABLE')) {
31     define('AUTH_AD_ACCOUNTDISABLE', 0x0002);
32 }
33 if (!defined('AUTH_AD_NORMAL_ACCOUNT')) {
34     define('AUTH_AD_NORMAL_ACCOUNT', 0x0200);
35 }
36 if (!defined('AUTH_NTLMTIMEOUT')) {  // timewindow for the NTLM SSO process, in secs...
37     define('AUTH_NTLMTIMEOUT', 10);
38 }
40 // UF_DONT_EXPIRE_PASSWD value taken from MSDN directly
41 if (!defined('UF_DONT_EXPIRE_PASSWD')) {
42     define ('UF_DONT_EXPIRE_PASSWD', 0x00010000);
43 }
45 // The Posix uid and gid of the 'nobody' account and 'nogroup' group.
46 if (!defined('AUTH_UID_NOBODY')) {
47     define('AUTH_UID_NOBODY', -2);
48 }
49 if (!defined('AUTH_GID_NOGROUP')) {
50     define('AUTH_GID_NOGROUP', -2);
51 }
53 // Regular expressions for a valid NTLM username and domain name.
54 if (!defined('AUTH_NTLM_VALID_USERNAME')) {
55     define('AUTH_NTLM_VALID_USERNAME', '[^/\\\\\\\\\[\]:;|=,+*?<>@"]+');
56 }
57 if (!defined('AUTH_NTLM_VALID_DOMAINNAME')) {
58     define('AUTH_NTLM_VALID_DOMAINNAME', '[^\\\\\\\\\/:*?"<>|]+');
59 }
60 // Default format for remote users if using NTLM SSO
61 if (!defined('AUTH_NTLM_DEFAULT_FORMAT')) {
62     define('AUTH_NTLM_DEFAULT_FORMAT', '%domain%\\%username%');
63 }
64 if (!defined('AUTH_NTLM_FASTPATH_ATTEMPT')) {
65     define('AUTH_NTLM_FASTPATH_ATTEMPT', 0);
66 }
67 if (!defined('AUTH_NTLM_FASTPATH_YESFORM')) {
68     define('AUTH_NTLM_FASTPATH_YESFORM', 1);
69 }
70 if (!defined('AUTH_NTLM_FASTPATH_YESATTEMPT')) {
71     define('AUTH_NTLM_FASTPATH_YESATTEMPT', 2);
72 }
74 // Allows us to retrieve a diagnostic message in case of LDAP operation error
75 if (!defined('LDAP_OPT_DIAGNOSTIC_MESSAGE')) {
76     define('LDAP_OPT_DIAGNOSTIC_MESSAGE', 0x0032);
77 }
79 require_once($CFG->libdir.'/authlib.php');
80 require_once($CFG->libdir.'/ldaplib.php');
81 require_once($CFG->dirroot.'/user/lib.php');
83 /**
84  * LDAP authentication plugin.
85  */
86 class auth_plugin_ldap extends auth_plugin_base {
88     /**
89      * Init plugin config from database settings depending on the plugin auth type.
90      */
91     function init_plugin($authtype) {
92         $this->pluginconfig = 'auth/'.$authtype;
93         $this->config = get_config($this->pluginconfig);
94         if (empty($this->config->ldapencoding)) {
95             $this->config->ldapencoding = 'utf-8';
96         }
97         if (empty($this->config->user_type)) {
98             $this->config->user_type = 'default';
99         }
101         $ldap_usertypes = ldap_supported_usertypes();
102         $this->config->user_type_name = $ldap_usertypes[$this->config->user_type];
103         unset($ldap_usertypes);
105         $default = ldap_getdefaults();
107         // Use defaults if values not given
108         foreach ($default as $key => $value) {
109             // watch out - 0, false are correct values too
110             if (!isset($this->config->{$key}) or $this->config->{$key} == '') {
111                 $this->config->{$key} = $value[$this->config->user_type];
112             }
113         }
115         // Hack prefix to objectclass
116         if (empty($this->config->objectclass)) {
117             // Can't send empty filter
118             $this->config->objectclass = '(objectClass=*)';
119         } else if (stripos($this->config->objectclass, 'objectClass=') === 0) {
120             // Value is 'objectClass=some-string-here', so just add ()
121             // around the value (filter _must_ have them).
122             $this->config->objectclass = '('.$this->config->objectclass.')';
123         } else if (strpos($this->config->objectclass, '(') !== 0) {
124             // Value is 'some-string-not-starting-with-left-parentheses',
125             // which is assumed to be the objectClass matching value.
126             // So build a valid filter with it.
127             $this->config->objectclass = '(objectClass='.$this->config->objectclass.')';
128         } else {
129             // There is an additional possible value
130             // '(some-string-here)', that can be used to specify any
131             // valid filter string, to select subsets of users based
132             // on any criteria. For example, we could select the users
133             // whose objectClass is 'user' and have the
134             // 'enabledMoodleUser' attribute, with something like:
135             //
136             //   (&(objectClass=user)(enabledMoodleUser=1))
137             //
138             // In this particular case we don't need to do anything,
139             // so leave $this->config->objectclass as is.
140         }
141     }
143     /**
144      * Constructor with initialisation.
145      */
146     public function __construct() {
147         $this->authtype = 'ldap';
148         $this->roleauth = 'auth_ldap';
149         $this->errorlogtag = '[AUTH LDAP] ';
150         $this->init_plugin($this->authtype);
151     }
153     /**
154      * Old syntax of class constructor. Deprecated in PHP7.
155      *
156      * @deprecated since Moodle 3.1
157      */
158     public function auth_plugin_ldap() {
159         debugging('Use of class name as constructor is deprecated', DEBUG_DEVELOPER);
160         self::__construct();
161     }
163     /**
164      * Returns true if the username and password work and false if they are
165      * wrong or don't exist.
166      *
167      * @param string $username The username (without system magic quotes)
168      * @param string $password The password (without system magic quotes)
169      *
170      * @return bool Authentication success or failure.
171      */
172     function user_login($username, $password) {
173         if (! function_exists('ldap_bind')) {
174             print_error('auth_ldapnotinstalled', 'auth_ldap');
175             return false;
176         }
178         if (!$username or !$password) {    // Don't allow blank usernames or passwords
179             return false;
180         }
182         $extusername = core_text::convert($username, 'utf-8', $this->config->ldapencoding);
183         $extpassword = core_text::convert($password, 'utf-8', $this->config->ldapencoding);
185         // Before we connect to LDAP, check if this is an AD SSO login
186         // if we succeed in this block, we'll return success early.
187         //
188         $key = sesskey();
189         if (!empty($this->config->ntlmsso_enabled) && $key === $password) {
190             $cf = get_cache_flags($this->pluginconfig.'/ntlmsess');
191             // We only get the cache flag if we retrieve it before
192             // it expires (AUTH_NTLMTIMEOUT seconds).
193             if (!isset($cf[$key]) || $cf[$key] === '') {
194                 return false;
195             }
197             $sessusername = $cf[$key];
198             if ($username === $sessusername) {
199                 unset($sessusername);
200                 unset($cf);
202                 // Check that the user is inside one of the configured LDAP contexts
203                 $validuser = false;
204                 $ldapconnection = $this->ldap_connect();
205                 // if the user is not inside the configured contexts,
206                 // ldap_find_userdn returns false.
207                 if ($this->ldap_find_userdn($ldapconnection, $extusername)) {
208                     $validuser = true;
209                 }
210                 $this->ldap_close();
212                 // Shortcut here - SSO confirmed
213                 return $validuser;
214             }
215         } // End SSO processing
216         unset($key);
218         $ldapconnection = $this->ldap_connect();
219         $ldap_user_dn = $this->ldap_find_userdn($ldapconnection, $extusername);
221         // If ldap_user_dn is empty, user does not exist
222         if (!$ldap_user_dn) {
223             $this->ldap_close();
224             return false;
225         }
227         // Try to bind with current username and password
228         $ldap_login = @ldap_bind($ldapconnection, $ldap_user_dn, $extpassword);
230         // If login fails and we are using MS Active Directory, retrieve the diagnostic
231         // message to see if this is due to an expired password, or that the user is forced to
232         // change the password on first login. If it is, only proceed if we can change
233         // password from Moodle (otherwise we'll get stuck later in the login process).
234         if (!$ldap_login && ($this->config->user_type == 'ad')
235             && $this->can_change_password()
236             && (!empty($this->config->expiration) and ($this->config->expiration == 1))) {
238             // We need to get the diagnostic message right after the call to ldap_bind(),
239             // before any other LDAP operation.
240             ldap_get_option($ldapconnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $diagmsg);
242             if ($this->ldap_ad_pwdexpired_from_diagmsg($diagmsg)) {
243                 // If login failed because user must change the password now or the
244                 // password has expired, let the user in. We'll catch this later in the
245                 // login process when we explicitly check for expired passwords.
246                 $ldap_login = true;
247             }
248         }
249         $this->ldap_close();
250         return $ldap_login;
251     }
253     /**
254      * Reads user information from ldap and returns it in array()
255      *
256      * Function should return all information available. If you are saving
257      * this information to moodle user-table you should honor syncronization flags
258      *
259      * @param string $username username
260      *
261      * @return mixed array with no magic quotes or false on error
262      */
263     function get_userinfo($username) {
264         $extusername = core_text::convert($username, 'utf-8', $this->config->ldapencoding);
266         $ldapconnection = $this->ldap_connect();
267         if(!($user_dn = $this->ldap_find_userdn($ldapconnection, $extusername))) {
268             $this->ldap_close();
269             return false;
270         }
272         $search_attribs = array();
273         $attrmap = $this->ldap_attributes();
274         foreach ($attrmap as $key => $values) {
275             if (!is_array($values)) {
276                 $values = array($values);
277             }
278             foreach ($values as $value) {
279                 if (!in_array($value, $search_attribs)) {
280                     array_push($search_attribs, $value);
281                 }
282             }
283         }
285         if (!$user_info_result = ldap_read($ldapconnection, $user_dn, '(objectClass=*)', $search_attribs)) {
286             $this->ldap_close();
287             return false; // error!
288         }
290         $user_entry = ldap_get_entries_moodle($ldapconnection, $user_info_result);
291         if (empty($user_entry)) {
292             $this->ldap_close();
293             return false; // entry not found
294         }
296         $result = array();
297         foreach ($attrmap as $key => $values) {
298             if (!is_array($values)) {
299                 $values = array($values);
300             }
301             $ldapval = NULL;
302             foreach ($values as $value) {
303                 $entry = array_change_key_case($user_entry[0], CASE_LOWER);
304                 if (($value == 'dn') || ($value == 'distinguishedname')) {
305                     $result[$key] = $user_dn;
306                     continue;
307                 }
308                 if (!array_key_exists($value, $entry)) {
309                     continue; // wrong data mapping!
310                 }
311                 if (is_array($entry[$value])) {
312                     $newval = core_text::convert($entry[$value][0], $this->config->ldapencoding, 'utf-8');
313                 } else {
314                     $newval = core_text::convert($entry[$value], $this->config->ldapencoding, 'utf-8');
315                 }
316                 if (!empty($newval)) { // favour ldap entries that are set
317                     $ldapval = $newval;
318                 }
319             }
320             if (!is_null($ldapval)) {
321                 $result[$key] = $ldapval;
322             }
323         }
325         $this->ldap_close();
326         return $result;
327     }
329     /**
330      * Reads user information from ldap and returns it in an object
331      *
332      * @param string $username username (with system magic quotes)
333      * @return mixed object or false on error
334      */
335     function get_userinfo_asobj($username) {
336         $user_array = $this->get_userinfo($username);
337         if ($user_array == false) {
338             return false; //error or not found
339         }
340         $user_array = truncate_userinfo($user_array);
341         $user = new stdClass();
342         foreach ($user_array as $key=>$value) {
343             $user->{$key} = $value;
344         }
345         return $user;
346     }
348     /**
349      * Returns all usernames from LDAP
350      *
351      * get_userlist returns all usernames from LDAP
352      *
353      * @return array
354      */
355     function get_userlist() {
356         return $this->ldap_get_userlist("({$this->config->user_attribute}=*)");
357     }
359     /**
360      * Checks if user exists on LDAP
361      *
362      * @param string $username
363      */
364     function user_exists($username) {
365         $extusername = core_text::convert($username, 'utf-8', $this->config->ldapencoding);
367         // Returns true if given username exists on ldap
368         $users = $this->ldap_get_userlist('('.$this->config->user_attribute.'='.ldap_filter_addslashes($extusername).')');
369         return count($users);
370     }
372     /**
373      * Creates a new user on LDAP.
374      * By using information in userobject
375      * Use user_exists to prevent duplicate usernames
376      *
377      * @param mixed $userobject  Moodle userobject
378      * @param mixed $plainpass   Plaintext password
379      */
380     function user_create($userobject, $plainpass) {
381         $extusername = core_text::convert($userobject->username, 'utf-8', $this->config->ldapencoding);
382         $extpassword = core_text::convert($plainpass, 'utf-8', $this->config->ldapencoding);
384         switch ($this->config->passtype) {
385             case 'md5':
386                 $extpassword = '{MD5}' . base64_encode(pack('H*', md5($extpassword)));
387                 break;
388             case 'sha1':
389                 $extpassword = '{SHA}' . base64_encode(pack('H*', sha1($extpassword)));
390                 break;
391             case 'plaintext':
392             default:
393                 break; // plaintext
394         }
396         $ldapconnection = $this->ldap_connect();
397         $attrmap = $this->ldap_attributes();
399         $newuser = array();
401         foreach ($attrmap as $key => $values) {
402             if (!is_array($values)) {
403                 $values = array($values);
404             }
405             foreach ($values as $value) {
406                 if (!empty($userobject->$key) ) {
407                     $newuser[$value] = core_text::convert($userobject->$key, 'utf-8', $this->config->ldapencoding);
408                 }
409             }
410         }
412         //Following sets all mandatory and other forced attribute values
413         //User should be creted as login disabled untill email confirmation is processed
414         //Feel free to add your user type and send patches to paca@sci.fi to add them
415         //Moodle distribution
417         switch ($this->config->user_type)  {
418             case 'edir':
419                 $newuser['objectClass']   = array('inetOrgPerson', 'organizationalPerson', 'person', 'top');
420                 $newuser['uniqueId']      = $extusername;
421                 $newuser['logindisabled'] = 'TRUE';
422                 $newuser['userpassword']  = $extpassword;
423                 $uadd = ldap_add($ldapconnection, $this->config->user_attribute.'='.ldap_addslashes($extusername).','.$this->config->create_context, $newuser);
424                 break;
425             case 'rfc2307':
426             case 'rfc2307bis':
427                 // posixAccount object class forces us to specify a uidNumber
428                 // and a gidNumber. That is quite complicated to generate from
429                 // Moodle without colliding with existing numbers and without
430                 // race conditions. As this user is supposed to be only used
431                 // with Moodle (otherwise the user would exist beforehand) and
432                 // doesn't need to login into a operating system, we assign the
433                 // user the uid of user 'nobody' and gid of group 'nogroup'. In
434                 // addition to that, we need to specify a home directory. We
435                 // use the root directory ('/') as the home directory, as this
436                 // is the only one can always be sure exists. Finally, even if
437                 // it's not mandatory, we specify '/bin/false' as the login
438                 // shell, to prevent the user from login in at the operating
439                 // system level (Moodle ignores this).
441                 $newuser['objectClass']   = array('posixAccount', 'inetOrgPerson', 'organizationalPerson', 'person', 'top');
442                 $newuser['cn']            = $extusername;
443                 $newuser['uid']           = $extusername;
444                 $newuser['uidNumber']     = AUTH_UID_NOBODY;
445                 $newuser['gidNumber']     = AUTH_GID_NOGROUP;
446                 $newuser['homeDirectory'] = '/';
447                 $newuser['loginShell']    = '/bin/false';
449                 // IMPORTANT:
450                 // We have to create the account locked, but posixAccount has
451                 // no attribute to achive this reliably. So we are going to
452                 // modify the password in a reversable way that we can later
453                 // revert in user_activate().
454                 //
455                 // Beware that this can be defeated by the user if we are not
456                 // using MD5 or SHA-1 passwords. After all, the source code of
457                 // Moodle is available, and the user can see the kind of
458                 // modification we are doing and 'undo' it by hand (but only
459                 // if we are using plain text passwords).
460                 //
461                 // Also bear in mind that you need to use a binding user that
462                 // can create accounts and has read/write privileges on the
463                 // 'userPassword' attribute for this to work.
465                 $newuser['userPassword']  = '*'.$extpassword;
466                 $uadd = ldap_add($ldapconnection, $this->config->user_attribute.'='.ldap_addslashes($extusername).','.$this->config->create_context, $newuser);
467                 break;
468             case 'ad':
469                 // User account creation is a two step process with AD. First you
470                 // create the user object, then you set the password. If you try
471                 // to set the password while creating the user, the operation
472                 // fails.
474                 // Passwords in Active Directory must be encoded as Unicode
475                 // strings (UCS-2 Little Endian format) and surrounded with
476                 // double quotes. See http://support.microsoft.com/?kbid=269190
477                 if (!function_exists('mb_convert_encoding')) {
478                     print_error('auth_ldap_no_mbstring', 'auth_ldap');
479                 }
481                 // Check for invalid sAMAccountName characters.
482                 if (preg_match('#[/\\[\]:;|=,+*?<>@"]#', $extusername)) {
483                     print_error ('auth_ldap_ad_invalidchars', 'auth_ldap');
484                 }
486                 // First create the user account, and mark it as disabled.
487                 $newuser['objectClass'] = array('top', 'person', 'user', 'organizationalPerson');
488                 $newuser['sAMAccountName'] = $extusername;
489                 $newuser['userAccountControl'] = AUTH_AD_NORMAL_ACCOUNT |
490                                                  AUTH_AD_ACCOUNTDISABLE;
491                 $userdn = 'cn='.ldap_addslashes($extusername).','.$this->config->create_context;
492                 if (!ldap_add($ldapconnection, $userdn, $newuser)) {
493                     print_error('auth_ldap_ad_create_req', 'auth_ldap');
494                 }
496                 // Now set the password
497                 unset($newuser);
498                 $newuser['unicodePwd'] = mb_convert_encoding('"' . $extpassword . '"',
499                                                              'UCS-2LE', 'UTF-8');
500                 if(!ldap_modify($ldapconnection, $userdn, $newuser)) {
501                     // Something went wrong: delete the user account and error out
502                     ldap_delete ($ldapconnection, $userdn);
503                     print_error('auth_ldap_ad_create_req', 'auth_ldap');
504                 }
505                 $uadd = true;
506                 break;
507             default:
508                print_error('auth_ldap_unsupportedusertype', 'auth_ldap', '', $this->config->user_type_name);
509         }
510         $this->ldap_close();
511         return $uadd;
512     }
514     /**
515      * Returns true if plugin allows resetting of password from moodle.
516      *
517      * @return bool
518      */
519     function can_reset_password() {
520         return !empty($this->config->stdchangepassword);
521     }
523     /**
524      * Returns true if plugin can be manually set.
525      *
526      * @return bool
527      */
528     function can_be_manually_set() {
529         return true;
530     }
532     /**
533      * Returns true if plugin allows signup and user creation.
534      *
535      * @return bool
536      */
537     function can_signup() {
538         return (!empty($this->config->auth_user_create) and !empty($this->config->create_context));
539     }
541     /**
542      * Sign up a new user ready for confirmation.
543      * Password is passed in plaintext.
544      *
545      * @param object $user new user object
546      * @param boolean $notify print notice with link and terminate
547      * @return boolean success
548      */
549     function user_signup($user, $notify=true) {
550         global $CFG, $DB, $PAGE, $OUTPUT;
552         require_once($CFG->dirroot.'/user/profile/lib.php');
553         require_once($CFG->dirroot.'/user/lib.php');
555         if ($this->user_exists($user->username)) {
556             print_error('auth_ldap_user_exists', 'auth_ldap');
557         }
559         $plainslashedpassword = $user->password;
560         unset($user->password);
562         if (! $this->user_create($user, $plainslashedpassword)) {
563             print_error('auth_ldap_create_error', 'auth_ldap');
564         }
566         $user->id = user_create_user($user, false, false);
568         user_add_password_history($user->id, $plainslashedpassword);
570         // Save any custom profile field information
571         profile_save_data($user);
573         $this->update_user_record($user->username);
574         // This will also update the stored hash to the latest algorithm
575         // if the existing hash is using an out-of-date algorithm (or the
576         // legacy md5 algorithm).
577         update_internal_user_password($user, $plainslashedpassword);
579         $user = $DB->get_record('user', array('id'=>$user->id));
581         \core\event\user_created::create_from_userid($user->id)->trigger();
583         if (! send_confirmation_email($user)) {
584             print_error('noemail', 'auth_ldap');
585         }
587         if ($notify) {
588             $emailconfirm = get_string('emailconfirm');
589             $PAGE->set_url('/auth/ldap/auth.php');
590             $PAGE->navbar->add($emailconfirm);
591             $PAGE->set_title($emailconfirm);
592             $PAGE->set_heading($emailconfirm);
593             echo $OUTPUT->header();
594             notice(get_string('emailconfirmsent', '', $user->email), "{$CFG->wwwroot}/index.php");
595         } else {
596             return true;
597         }
598     }
600     /**
601      * Returns true if plugin allows confirming of new users.
602      *
603      * @return bool
604      */
605     function can_confirm() {
606         return $this->can_signup();
607     }
609     /**
610      * Confirm the new user as registered.
611      *
612      * @param string $username
613      * @param string $confirmsecret
614      */
615     function user_confirm($username, $confirmsecret) {
616         global $DB;
618         $user = get_complete_user_data('username', $username);
620         if (!empty($user)) {
621             if ($user->auth != $this->authtype) {
622                 return AUTH_CONFIRM_ERROR;
624             } else if ($user->secret == $confirmsecret && $user->confirmed) {
625                 return AUTH_CONFIRM_ALREADY;
627             } else if ($user->secret == $confirmsecret) {   // They have provided the secret key to get in
628                 if (!$this->user_activate($username)) {
629                     return AUTH_CONFIRM_FAIL;
630                 }
631                 $user->confirmed = 1;
632                 user_update_user($user, false);
633                 return AUTH_CONFIRM_OK;
634             }
635         } else {
636             return AUTH_CONFIRM_ERROR;
637         }
638     }
640     /**
641      * Return number of days to user password expires
642      *
643      * If userpassword does not expire it should return 0. If password is already expired
644      * it should return negative value.
645      *
646      * @param mixed $username username
647      * @return integer
648      */
649     function password_expire($username) {
650         $result = 0;
652         $extusername = core_text::convert($username, 'utf-8', $this->config->ldapencoding);
654         $ldapconnection = $this->ldap_connect();
655         $user_dn = $this->ldap_find_userdn($ldapconnection, $extusername);
656         $search_attribs = array($this->config->expireattr);
657         $sr = ldap_read($ldapconnection, $user_dn, '(objectClass=*)', $search_attribs);
658         if ($sr)  {
659             $info = ldap_get_entries_moodle($ldapconnection, $sr);
660             if (!empty ($info)) {
661                 $info = array_change_key_case($info[0], CASE_LOWER);
662                 if (isset($info[$this->config->expireattr][0])) {
663                     $expiretime = $this->ldap_expirationtime2unix($info[$this->config->expireattr][0], $ldapconnection, $user_dn);
664                     if ($expiretime != 0) {
665                         $now = time();
666                         if ($expiretime > $now) {
667                             $result = ceil(($expiretime - $now) / DAYSECS);
668                         } else {
669                             $result = floor(($expiretime - $now) / DAYSECS);
670                         }
671                     }
672                 }
673             }
674         } else {
675             error_log($this->errorlogtag.get_string('didtfindexpiretime', 'auth_ldap'));
676         }
678         return $result;
679     }
681     /**
682      * Syncronizes user fron external LDAP server to moodle user table
683      *
684      * Sync is now using username attribute.
685      *
686      * Syncing users removes or suspends users that dont exists anymore in external LDAP.
687      * Creates new users and updates coursecreator status of users.
688      *
689      * @param bool $do_updates will do pull in data updates from LDAP if relevant
690      */
691     function sync_users($do_updates=true) {
692         global $CFG, $DB;
694         print_string('connectingldap', 'auth_ldap');
695         $ldapconnection = $this->ldap_connect();
697         $dbman = $DB->get_manager();
699     /// Define table user to be created
700         $table = new xmldb_table('tmp_extuser');
701         $table->add_field('id', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
702         $table->add_field('username', XMLDB_TYPE_CHAR, '100', null, XMLDB_NOTNULL, null, null);
703         $table->add_field('mnethostid', XMLDB_TYPE_INTEGER, '10', XMLDB_UNSIGNED, XMLDB_NOTNULL, null, null);
704         $table->add_key('primary', XMLDB_KEY_PRIMARY, array('id'));
705         $table->add_index('username', XMLDB_INDEX_UNIQUE, array('mnethostid', 'username'));
707         print_string('creatingtemptable', 'auth_ldap', 'tmp_extuser');
708         $dbman->create_temp_table($table);
710         ////
711         //// get user's list from ldap to sql in a scalable fashion
712         ////
713         // prepare some data we'll need
714         $filter = '(&('.$this->config->user_attribute.'=*)'.$this->config->objectclass.')';
716         $contexts = explode(';', $this->config->contexts);
718         if (!empty($this->config->create_context)) {
719             array_push($contexts, $this->config->create_context);
720         }
722         $ldap_pagedresults = ldap_paged_results_supported($this->config->ldap_version);
723         $ldap_cookie = '';
724         foreach ($contexts as $context) {
725             $context = trim($context);
726             if (empty($context)) {
727                 continue;
728             }
730             do {
731                 if ($ldap_pagedresults) {
732                     ldap_control_paged_result($ldapconnection, $this->config->pagesize, true, $ldap_cookie);
733                 }
734                 if ($this->config->search_sub) {
735                     // Use ldap_search to find first user from subtree.
736                     $ldap_result = ldap_search($ldapconnection, $context, $filter, array($this->config->user_attribute));
737                 } else {
738                     // Search only in this context.
739                     $ldap_result = ldap_list($ldapconnection, $context, $filter, array($this->config->user_attribute));
740                 }
741                 if(!$ldap_result) {
742                     continue;
743                 }
744                 if ($ldap_pagedresults) {
745                     ldap_control_paged_result_response($ldapconnection, $ldap_result, $ldap_cookie);
746                 }
747                 if ($entry = @ldap_first_entry($ldapconnection, $ldap_result)) {
748                     do {
749                         $value = ldap_get_values_len($ldapconnection, $entry, $this->config->user_attribute);
750                         $value = core_text::convert($value[0], $this->config->ldapencoding, 'utf-8');
751                         $value = trim($value);
752                         $this->ldap_bulk_insert($value);
753                     } while ($entry = ldap_next_entry($ldapconnection, $entry));
754                 }
755                 unset($ldap_result); // Free mem.
756             } while ($ldap_pagedresults && $ldap_cookie !== null && $ldap_cookie != '');
757         }
759         // If LDAP paged results were used, the current connection must be completely
760         // closed and a new one created, to work without paged results from here on.
761         if ($ldap_pagedresults) {
762             $this->ldap_close(true);
763             $ldapconnection = $this->ldap_connect();
764         }
766         /// preserve our user database
767         /// if the temp table is empty, it probably means that something went wrong, exit
768         /// so as to avoid mass deletion of users; which is hard to undo
769         $count = $DB->count_records_sql('SELECT COUNT(username) AS count, 1 FROM {tmp_extuser}');
770         if ($count < 1) {
771             print_string('didntgetusersfromldap', 'auth_ldap');
772             exit;
773         } else {
774             print_string('gotcountrecordsfromldap', 'auth_ldap', $count);
775         }
778 /// User removal
779         // Find users in DB that aren't in ldap -- to be removed!
780         // this is still not as scalable (but how often do we mass delete?)
782         if ($this->config->removeuser == AUTH_REMOVEUSER_FULLDELETE) {
783             $sql = "SELECT u.*
784                       FROM {user} u
785                  LEFT JOIN {tmp_extuser} e ON (u.username = e.username AND u.mnethostid = e.mnethostid)
786                      WHERE u.auth = :auth
787                            AND u.deleted = 0
788                            AND e.username IS NULL";
789             $remove_users = $DB->get_records_sql($sql, array('auth'=>$this->authtype));
791             if (!empty($remove_users)) {
792                 print_string('userentriestoremove', 'auth_ldap', count($remove_users));
793                 foreach ($remove_users as $user) {
794                     if (delete_user($user)) {
795                         echo "\t"; print_string('auth_dbdeleteuser', 'auth_db', array('name'=>$user->username, 'id'=>$user->id)); echo "\n";
796                     } else {
797                         echo "\t"; print_string('auth_dbdeleteusererror', 'auth_db', $user->username); echo "\n";
798                     }
799                 }
800             } else {
801                 print_string('nouserentriestoremove', 'auth_ldap');
802             }
803             unset($remove_users); // Free mem!
805         } else if ($this->config->removeuser == AUTH_REMOVEUSER_SUSPEND) {
806             $sql = "SELECT u.*
807                       FROM {user} u
808                  LEFT JOIN {tmp_extuser} e ON (u.username = e.username AND u.mnethostid = e.mnethostid)
809                      WHERE u.auth = :auth
810                            AND u.deleted = 0
811                            AND u.suspended = 0
812                            AND e.username IS NULL";
813             $remove_users = $DB->get_records_sql($sql, array('auth'=>$this->authtype));
815             if (!empty($remove_users)) {
816                 print_string('userentriestoremove', 'auth_ldap', count($remove_users));
818                 foreach ($remove_users as $user) {
819                     $updateuser = new stdClass();
820                     $updateuser->id = $user->id;
821                     $updateuser->suspended = 1;
822                     user_update_user($updateuser, false);
823                     echo "\t"; print_string('auth_dbsuspenduser', 'auth_db', array('name'=>$user->username, 'id'=>$user->id)); echo "\n";
824                     \core\session\manager::kill_user_sessions($user->id);
825                 }
826             } else {
827                 print_string('nouserentriestoremove', 'auth_ldap');
828             }
829             unset($remove_users); // Free mem!
830         }
832 /// Revive suspended users
833         if (!empty($this->config->removeuser) and $this->config->removeuser == AUTH_REMOVEUSER_SUSPEND) {
834             $sql = "SELECT u.id, u.username
835                       FROM {user} u
836                       JOIN {tmp_extuser} e ON (u.username = e.username AND u.mnethostid = e.mnethostid)
837                      WHERE (u.auth = 'nologin' OR (u.auth = ? AND u.suspended = 1)) AND u.deleted = 0";
838             // Note: 'nologin' is there for backwards compatibility.
839             $revive_users = $DB->get_records_sql($sql, array($this->authtype));
841             if (!empty($revive_users)) {
842                 print_string('userentriestorevive', 'auth_ldap', count($revive_users));
844                 foreach ($revive_users as $user) {
845                     $updateuser = new stdClass();
846                     $updateuser->id = $user->id;
847                     $updateuser->auth = $this->authtype;
848                     $updateuser->suspended = 0;
849                     user_update_user($updateuser, false);
850                     echo "\t"; print_string('auth_dbreviveduser', 'auth_db', array('name'=>$user->username, 'id'=>$user->id)); echo "\n";
851                 }
852             } else {
853                 print_string('nouserentriestorevive', 'auth_ldap');
854             }
856             unset($revive_users);
857         }
860 /// User Updates - time-consuming (optional)
861         if ($do_updates) {
862             // Narrow down what fields we need to update
863             $all_keys = array_keys(get_object_vars($this->config));
864             $updatekeys = array();
865             foreach ($all_keys as $key) {
866                 if (preg_match('/^field_updatelocal_(.+)$/', $key, $match)) {
867                     // If we have a field to update it from
868                     // and it must be updated 'onlogin' we
869                     // update it on cron
870                     if (!empty($this->config->{'field_map_'.$match[1]})
871                          and $this->config->{$match[0]} === 'onlogin') {
872                         array_push($updatekeys, $match[1]); // the actual key name
873                     }
874                 }
875             }
876             unset($all_keys); unset($key);
878         } else {
879             print_string('noupdatestobedone', 'auth_ldap');
880         }
881         if ($do_updates and !empty($updatekeys)) { // run updates only if relevant
882             $users = $DB->get_records_sql('SELECT u.username, u.id
883                                              FROM {user} u
884                                             WHERE u.deleted = 0 AND u.auth = ? AND u.mnethostid = ?',
885                                           array($this->authtype, $CFG->mnet_localhost_id));
886             if (!empty($users)) {
887                 print_string('userentriestoupdate', 'auth_ldap', count($users));
889                 $sitecontext = context_system::instance();
890                 if (!empty($this->config->creators) and !empty($this->config->memberattribute)
891                   and $roles = get_archetype_roles('coursecreator')) {
892                     $creatorrole = array_shift($roles);      // We can only use one, let's use the first one
893                 } else {
894                     $creatorrole = false;
895                 }
897                 $transaction = $DB->start_delegated_transaction();
898                 $xcount = 0;
899                 $maxxcount = 100;
901                 foreach ($users as $user) {
902                     echo "\t"; print_string('auth_dbupdatinguser', 'auth_db', array('name'=>$user->username, 'id'=>$user->id));
903                     if (!$this->update_user_record($user->username, $updatekeys, true)) {
904                         echo ' - '.get_string('skipped');
905                     }
906                     echo "\n";
907                     $xcount++;
909                     // Update course creators if needed
910                     if ($creatorrole !== false) {
911                         if ($this->iscreator($user->username)) {
912                             role_assign($creatorrole->id, $user->id, $sitecontext->id, $this->roleauth);
913                         } else {
914                             role_unassign($creatorrole->id, $user->id, $sitecontext->id, $this->roleauth);
915                         }
916                     }
917                 }
918                 $transaction->allow_commit();
919                 unset($users); // free mem
920             }
921         } else { // end do updates
922             print_string('noupdatestobedone', 'auth_ldap');
923         }
925 /// User Additions
926         // Find users missing in DB that are in LDAP
927         // and gives me a nifty object I don't want.
928         // note: we do not care about deleted accounts anymore, this feature was replaced by suspending to nologin auth plugin
929         $sql = 'SELECT e.id, e.username
930                   FROM {tmp_extuser} e
931                   LEFT JOIN {user} u ON (e.username = u.username AND e.mnethostid = u.mnethostid)
932                  WHERE u.id IS NULL';
933         $add_users = $DB->get_records_sql($sql);
935         if (!empty($add_users)) {
936             print_string('userentriestoadd', 'auth_ldap', count($add_users));
938             $sitecontext = context_system::instance();
939             if (!empty($this->config->creators) and !empty($this->config->memberattribute)
940               and $roles = get_archetype_roles('coursecreator')) {
941                 $creatorrole = array_shift($roles);      // We can only use one, let's use the first one
942             } else {
943                 $creatorrole = false;
944             }
946             $transaction = $DB->start_delegated_transaction();
947             foreach ($add_users as $user) {
948                 $user = $this->get_userinfo_asobj($user->username);
950                 // Prep a few params
951                 $user->modified   = time();
952                 $user->confirmed  = 1;
953                 $user->auth       = $this->authtype;
954                 $user->mnethostid = $CFG->mnet_localhost_id;
955                 // get_userinfo_asobj() might have replaced $user->username with the value
956                 // from the LDAP server (which can be mixed-case). Make sure it's lowercase
957                 $user->username = trim(core_text::strtolower($user->username));
958                 if (empty($user->lang)) {
959                     $user->lang = $CFG->lang;
960                 }
961                 if (empty($user->calendartype)) {
962                     $user->calendartype = $CFG->calendartype;
963                 }
965                 $id = user_create_user($user, false);
966                 echo "\t"; print_string('auth_dbinsertuser', 'auth_db', array('name'=>$user->username, 'id'=>$id)); echo "\n";
967                 $euser = $DB->get_record('user', array('id' => $id));
969                 if (!empty($this->config->forcechangepassword)) {
970                     set_user_preference('auth_forcepasswordchange', 1, $id);
971                 }
973                 // Add course creators if needed
974                 if ($creatorrole !== false and $this->iscreator($user->username)) {
975                     role_assign($creatorrole->id, $id, $sitecontext->id, $this->roleauth);
976                 }
978             }
979             $transaction->allow_commit();
980             unset($add_users); // free mem
981         } else {
982             print_string('nouserstobeadded', 'auth_ldap');
983         }
985         $dbman->drop_table($table);
986         $this->ldap_close();
988         return true;
989     }
991     /**
992      * Update a local user record from an external source.
993      * This is a lighter version of the one in moodlelib -- won't do
994      * expensive ops such as enrolment.
995      *
996      * If you don't pass $updatekeys, there is a performance hit and
997      * values removed from LDAP won't be removed from moodle.
998      *
999      * @param string $username username
1000      * @param boolean $updatekeys true to update the local record with the external LDAP values.
1001      * @param bool $triggerevent set false if user_updated event should not be triggered.
1002      *             This will not affect user_password_updated event triggering.
1003      * @return stdClass|bool updated user record or false if there is no new info to update.
1004      */
1005     function update_user_record($username, $updatekeys = false, $triggerevent = false) {
1006         global $CFG, $DB;
1008         // Just in case check text case
1009         $username = trim(core_text::strtolower($username));
1011         // Get the current user record
1012         $user = $DB->get_record('user', array('username'=>$username, 'mnethostid'=>$CFG->mnet_localhost_id));
1013         if (empty($user)) { // trouble
1014             error_log($this->errorlogtag.get_string('auth_dbusernotexist', 'auth_db', '', $username));
1015             print_error('auth_dbusernotexist', 'auth_db', '', $username);
1016             die;
1017         }
1019         // Protect the userid from being overwritten
1020         $userid = $user->id;
1022         if ($newinfo = $this->get_userinfo($username)) {
1023             $newinfo = truncate_userinfo($newinfo);
1025             if (empty($updatekeys)) { // all keys? this does not support removing values
1026                 $updatekeys = array_keys($newinfo);
1027             }
1029             if (!empty($updatekeys)) {
1030                 $newuser = new stdClass();
1031                 $newuser->id = $userid;
1033                 foreach ($updatekeys as $key) {
1034                     if (isset($newinfo[$key])) {
1035                         $value = $newinfo[$key];
1036                     } else {
1037                         $value = '';
1038                     }
1040                     if (!empty($this->config->{'field_updatelocal_' . $key})) {
1041                         // Only update if it's changed.
1042                         if ($user->{$key} != $value) {
1043                             $newuser->$key = $value;
1044                         }
1045                     }
1046                 }
1047                 user_update_user($newuser, false, $triggerevent);
1048             }
1049         } else {
1050             return false;
1051         }
1052         return $DB->get_record('user', array('id'=>$userid, 'deleted'=>0));
1053     }
1055     /**
1056      * Bulk insert in SQL's temp table
1057      */
1058     function ldap_bulk_insert($username) {
1059         global $DB, $CFG;
1061         $username = core_text::strtolower($username); // usernames are __always__ lowercase.
1062         $DB->insert_record_raw('tmp_extuser', array('username'=>$username,
1063                                                     'mnethostid'=>$CFG->mnet_localhost_id), false, true);
1064         echo '.';
1065     }
1067     /**
1068      * Activates (enables) user in external LDAP so user can login
1069      *
1070      * @param mixed $username
1071      * @return boolean result
1072      */
1073     function user_activate($username) {
1074         $extusername = core_text::convert($username, 'utf-8', $this->config->ldapencoding);
1076         $ldapconnection = $this->ldap_connect();
1078         $userdn = $this->ldap_find_userdn($ldapconnection, $extusername);
1079         switch ($this->config->user_type)  {
1080             case 'edir':
1081                 $newinfo['loginDisabled'] = 'FALSE';
1082                 break;
1083             case 'rfc2307':
1084             case 'rfc2307bis':
1085                 // Remember that we add a '*' character in front of the
1086                 // external password string to 'disable' the account. We just
1087                 // need to remove it.
1088                 $sr = ldap_read($ldapconnection, $userdn, '(objectClass=*)',
1089                                 array('userPassword'));
1090                 $info = ldap_get_entries($ldapconnection, $sr);
1091                 $info[0] = array_change_key_case($info[0], CASE_LOWER);
1092                 $newinfo['userPassword'] = ltrim($info[0]['userpassword'][0], '*');
1093                 break;
1094             case 'ad':
1095                 // We need to unset the ACCOUNTDISABLE bit in the
1096                 // userAccountControl attribute ( see
1097                 // http://support.microsoft.com/kb/305144 )
1098                 $sr = ldap_read($ldapconnection, $userdn, '(objectClass=*)',
1099                                 array('userAccountControl'));
1100                 $info = ldap_get_entries($ldapconnection, $sr);
1101                 $info[0] = array_change_key_case($info[0], CASE_LOWER);
1102                 $newinfo['userAccountControl'] = $info[0]['useraccountcontrol'][0]
1103                                                  & (~AUTH_AD_ACCOUNTDISABLE);
1104                 break;
1105             default:
1106                 print_error('user_activatenotsupportusertype', 'auth_ldap', '', $this->config->user_type_name);
1107         }
1108         $result = ldap_modify($ldapconnection, $userdn, $newinfo);
1109         $this->ldap_close();
1110         return $result;
1111     }
1113     /**
1114      * Returns true if user should be coursecreator.
1115      *
1116      * @param mixed $username    username (without system magic quotes)
1117      * @return mixed result      null if course creators is not configured, boolean otherwise.
1118      */
1119     function iscreator($username) {
1120         if (empty($this->config->creators) or empty($this->config->memberattribute)) {
1121             return null;
1122         }
1124         $extusername = core_text::convert($username, 'utf-8', $this->config->ldapencoding);
1126         $ldapconnection = $this->ldap_connect();
1128         if ($this->config->memberattribute_isdn) {
1129             if(!($userid = $this->ldap_find_userdn($ldapconnection, $extusername))) {
1130                 return false;
1131             }
1132         } else {
1133             $userid = $extusername;
1134         }
1136         $group_dns = explode(';', $this->config->creators);
1137         $creator = ldap_isgroupmember($ldapconnection, $userid, $group_dns, $this->config->memberattribute);
1139         $this->ldap_close();
1141         return $creator;
1142     }
1144     /**
1145      * Called when the user record is updated.
1146      *
1147      * Modifies user in external LDAP server. It takes olduser (before
1148      * changes) and newuser (after changes) compares information and
1149      * saves modified information to external LDAP server.
1150      *
1151      * @param mixed $olduser     Userobject before modifications    (without system magic quotes)
1152      * @param mixed $newuser     Userobject new modified userobject (without system magic quotes)
1153      * @return boolean result
1154      *
1155      */
1156     function user_update($olduser, $newuser) {
1157         global $USER;
1159         if (isset($olduser->username) and isset($newuser->username) and $olduser->username != $newuser->username) {
1160             error_log($this->errorlogtag.get_string('renamingnotallowed', 'auth_ldap'));
1161             return false;
1162         }
1164         if (isset($olduser->auth) and $olduser->auth != $this->authtype) {
1165             return true; // just change auth and skip update
1166         }
1168         $attrmap = $this->ldap_attributes();
1169         // Before doing anything else, make sure we really need to update anything
1170         // in the external LDAP server.
1171         $update_external = false;
1172         foreach ($attrmap as $key => $ldapkeys) {
1173             if (!empty($this->config->{'field_updateremote_'.$key})) {
1174                 $update_external = true;
1175                 break;
1176             }
1177         }
1178         if (!$update_external) {
1179             return true;
1180         }
1182         $extoldusername = core_text::convert($olduser->username, 'utf-8', $this->config->ldapencoding);
1184         $ldapconnection = $this->ldap_connect();
1186         $search_attribs = array();
1187         foreach ($attrmap as $key => $values) {
1188             if (!is_array($values)) {
1189                 $values = array($values);
1190             }
1191             foreach ($values as $value) {
1192                 if (!in_array($value, $search_attribs)) {
1193                     array_push($search_attribs, $value);
1194                 }
1195             }
1196         }
1198         if(!($user_dn = $this->ldap_find_userdn($ldapconnection, $extoldusername))) {
1199             return false;
1200         }
1202         $success = true;
1203         $user_info_result = ldap_read($ldapconnection, $user_dn, '(objectClass=*)', $search_attribs);
1204         if ($user_info_result) {
1205             $user_entry = ldap_get_entries_moodle($ldapconnection, $user_info_result);
1206             if (empty($user_entry)) {
1207                 $attribs = join (', ', $search_attribs);
1208                 error_log($this->errorlogtag.get_string('updateusernotfound', 'auth_ldap',
1209                                                           array('userdn'=>$user_dn,
1210                                                                 'attribs'=>$attribs)));
1211                 return false; // old user not found!
1212             } else if (count($user_entry) > 1) {
1213                 error_log($this->errorlogtag.get_string('morethanoneuser', 'auth_ldap'));
1214                 return false;
1215             }
1217             $user_entry = array_change_key_case($user_entry[0], CASE_LOWER);
1219             foreach ($attrmap as $key => $ldapkeys) {
1220                 $profilefield = '';
1221                 // Only process if the moodle field ($key) has changed and we
1222                 // are set to update LDAP with it
1223                 $customprofilefield = 'profile_field_' . $key;
1224                 if (isset($olduser->$key) and isset($newuser->$key)
1225                     and ($olduser->$key !== $newuser->$key)) {
1226                     $profilefield = $key;
1227                 } else if (isset($olduser->$customprofilefield) && isset($newuser->$customprofilefield)
1228                     && $olduser->$customprofilefield !== $newuser->$customprofilefield) {
1229                     $profilefield = $customprofilefield;
1230                 }
1232                 if (!empty($profilefield) && !empty($this->config->{'field_updateremote_' . $key})) {
1233                     // For ldap values that could be in more than one
1234                     // ldap key, we will do our best to match
1235                     // where they came from
1236                     $ambiguous = true;
1237                     $changed   = false;
1238                     if (!is_array($ldapkeys)) {
1239                         $ldapkeys = array($ldapkeys);
1240                     }
1241                     if (count($ldapkeys) < 2) {
1242                         $ambiguous = false;
1243                     }
1245                     $nuvalue = core_text::convert($newuser->$profilefield, 'utf-8', $this->config->ldapencoding);
1246                     empty($nuvalue) ? $nuvalue = array() : $nuvalue;
1247                     $ouvalue = core_text::convert($olduser->$profilefield, 'utf-8', $this->config->ldapencoding);
1249                     foreach ($ldapkeys as $ldapkey) {
1250                         $ldapkey   = $ldapkey;
1251                         $ldapvalue = $user_entry[$ldapkey][0];
1252                         if (!$ambiguous) {
1253                             // Skip update if the values already match
1254                             if ($nuvalue !== $ldapvalue) {
1255                                 // This might fail due to schema validation
1256                                 if (@ldap_modify($ldapconnection, $user_dn, array($ldapkey => $nuvalue))) {
1257                                     $changed = true;
1258                                     continue;
1259                                 } else {
1260                                     $success = false;
1261                                     error_log($this->errorlogtag.get_string ('updateremfail', 'auth_ldap',
1262                                                                              array('errno'=>ldap_errno($ldapconnection),
1263                                                                                    'errstring'=>ldap_err2str(ldap_errno($ldapconnection)),
1264                                                                                    'key'=>$key,
1265                                                                                    'ouvalue'=>$ouvalue,
1266                                                                                    'nuvalue'=>$nuvalue)));
1267                                     continue;
1268                                 }
1269                             }
1270                         } else {
1271                             // Ambiguous. Value empty before in Moodle (and LDAP) - use
1272                             // 1st ldap candidate field, no need to guess
1273                             if ($ouvalue === '') { // value empty before - use 1st ldap candidate
1274                                 // This might fail due to schema validation
1275                                 if (@ldap_modify($ldapconnection, $user_dn, array($ldapkey => $nuvalue))) {
1276                                     $changed = true;
1277                                     continue;
1278                                 } else {
1279                                     $success = false;
1280                                     error_log($this->errorlogtag.get_string ('updateremfail', 'auth_ldap',
1281                                                                              array('errno'=>ldap_errno($ldapconnection),
1282                                                                                    'errstring'=>ldap_err2str(ldap_errno($ldapconnection)),
1283                                                                                    'key'=>$key,
1284                                                                                    'ouvalue'=>$ouvalue,
1285                                                                                    'nuvalue'=>$nuvalue)));
1286                                     continue;
1287                                 }
1288                             }
1290                             // We found which ldap key to update!
1291                             if ($ouvalue !== '' and $ouvalue === $ldapvalue ) {
1292                                 // This might fail due to schema validation
1293                                 if (@ldap_modify($ldapconnection, $user_dn, array($ldapkey => $nuvalue))) {
1294                                     $changed = true;
1295                                     continue;
1296                                 } else {
1297                                     $success = false;
1298                                     error_log($this->errorlogtag.get_string ('updateremfail', 'auth_ldap',
1299                                                                              array('errno'=>ldap_errno($ldapconnection),
1300                                                                                    'errstring'=>ldap_err2str(ldap_errno($ldapconnection)),
1301                                                                                    'key'=>$key,
1302                                                                                    'ouvalue'=>$ouvalue,
1303                                                                                    'nuvalue'=>$nuvalue)));
1304                                     continue;
1305                                 }
1306                             }
1307                         }
1308                     }
1310                     if ($ambiguous and !$changed) {
1311                         $success = false;
1312                         error_log($this->errorlogtag.get_string ('updateremfailamb', 'auth_ldap',
1313                                                                  array('key'=>$key,
1314                                                                        'ouvalue'=>$ouvalue,
1315                                                                        'nuvalue'=>$nuvalue)));
1316                     }
1317                 }
1318             }
1319         } else {
1320             error_log($this->errorlogtag.get_string ('usernotfound', 'auth_ldap'));
1321             $success = false;
1322         }
1324         $this->ldap_close();
1325         return $success;
1327     }
1329     /**
1330      * Changes userpassword in LDAP
1331      *
1332      * Called when the user password is updated. It assumes it is
1333      * called by an admin or that you've otherwise checked the user's
1334      * credentials
1335      *
1336      * @param  object  $user        User table object
1337      * @param  string  $newpassword Plaintext password (not crypted/md5'ed)
1338      * @return boolean result
1339      *
1340      */
1341     function user_update_password($user, $newpassword) {
1342         global $USER;
1344         $result = false;
1345         $username = $user->username;
1347         $extusername = core_text::convert($username, 'utf-8', $this->config->ldapencoding);
1348         $extpassword = core_text::convert($newpassword, 'utf-8', $this->config->ldapencoding);
1350         switch ($this->config->passtype) {
1351             case 'md5':
1352                 $extpassword = '{MD5}' . base64_encode(pack('H*', md5($extpassword)));
1353                 break;
1354             case 'sha1':
1355                 $extpassword = '{SHA}' . base64_encode(pack('H*', sha1($extpassword)));
1356                 break;
1357             case 'plaintext':
1358             default:
1359                 break; // plaintext
1360         }
1362         $ldapconnection = $this->ldap_connect();
1364         $user_dn = $this->ldap_find_userdn($ldapconnection, $extusername);
1366         if (!$user_dn) {
1367             error_log($this->errorlogtag.get_string ('nodnforusername', 'auth_ldap', $user->username));
1368             return false;
1369         }
1371         switch ($this->config->user_type) {
1372             case 'edir':
1373                 // Change password
1374                 $result = ldap_modify($ldapconnection, $user_dn, array('userPassword' => $extpassword));
1375                 if (!$result) {
1376                     error_log($this->errorlogtag.get_string ('updatepasserror', 'auth_ldap',
1377                                                                array('errno'=>ldap_errno($ldapconnection),
1378                                                                      'errstring'=>ldap_err2str(ldap_errno($ldapconnection)))));
1379                 }
1380                 // Update password expiration time, grace logins count
1381                 $search_attribs = array($this->config->expireattr, 'passwordExpirationInterval', 'loginGraceLimit');
1382                 $sr = ldap_read($ldapconnection, $user_dn, '(objectClass=*)', $search_attribs);
1383                 if ($sr) {
1384                     $entry = ldap_get_entries_moodle($ldapconnection, $sr);
1385                     $info = array_change_key_case($entry[0], CASE_LOWER);
1386                     $newattrs = array();
1387                     if (!empty($info[$this->config->expireattr][0])) {
1388                         // Set expiration time only if passwordExpirationInterval is defined
1389                         if (!empty($info['passwordexpirationinterval'][0])) {
1390                            $expirationtime = time() + $info['passwordexpirationinterval'][0];
1391                            $ldapexpirationtime = $this->ldap_unix2expirationtime($expirationtime);
1392                            $newattrs['passwordExpirationTime'] = $ldapexpirationtime;
1393                         }
1395                         // Set gracelogin count
1396                         if (!empty($info['logingracelimit'][0])) {
1397                            $newattrs['loginGraceRemaining']= $info['logingracelimit'][0];
1398                         }
1400                         // Store attribute changes in LDAP
1401                         $result = ldap_modify($ldapconnection, $user_dn, $newattrs);
1402                         if (!$result) {
1403                             error_log($this->errorlogtag.get_string ('updatepasserrorexpiregrace', 'auth_ldap',
1404                                                                        array('errno'=>ldap_errno($ldapconnection),
1405                                                                              'errstring'=>ldap_err2str(ldap_errno($ldapconnection)))));
1406                         }
1407                     }
1408                 }
1409                 else {
1410                     error_log($this->errorlogtag.get_string ('updatepasserrorexpire', 'auth_ldap',
1411                                                              array('errno'=>ldap_errno($ldapconnection),
1412                                                                    'errstring'=>ldap_err2str(ldap_errno($ldapconnection)))));
1413                 }
1414                 break;
1416             case 'ad':
1417                 // Passwords in Active Directory must be encoded as Unicode
1418                 // strings (UCS-2 Little Endian format) and surrounded with
1419                 // double quotes. See http://support.microsoft.com/?kbid=269190
1420                 if (!function_exists('mb_convert_encoding')) {
1421                     error_log($this->errorlogtag.get_string ('needmbstring', 'auth_ldap'));
1422                     return false;
1423                 }
1424                 $extpassword = mb_convert_encoding('"'.$extpassword.'"', "UCS-2LE", $this->config->ldapencoding);
1425                 $result = ldap_modify($ldapconnection, $user_dn, array('unicodePwd' => $extpassword));
1426                 if (!$result) {
1427                     error_log($this->errorlogtag.get_string ('updatepasserror', 'auth_ldap',
1428                                                              array('errno'=>ldap_errno($ldapconnection),
1429                                                                    'errstring'=>ldap_err2str(ldap_errno($ldapconnection)))));
1430                 }
1431                 break;
1433             default:
1434                 // Send LDAP the password in cleartext, it will md5 it itself
1435                 $result = ldap_modify($ldapconnection, $user_dn, array('userPassword' => $extpassword));
1436                 if (!$result) {
1437                     error_log($this->errorlogtag.get_string ('updatepasserror', 'auth_ldap',
1438                                                              array('errno'=>ldap_errno($ldapconnection),
1439                                                                    'errstring'=>ldap_err2str(ldap_errno($ldapconnection)))));
1440                 }
1442         }
1444         $this->ldap_close();
1445         return $result;
1446     }
1448     /**
1449      * Take expirationtime and return it as unix timestamp in seconds
1450      *
1451      * Takes expiration timestamp as read from LDAP and returns it as unix timestamp in seconds
1452      * Depends on $this->config->user_type variable
1453      *
1454      * @param mixed time   Time stamp read from LDAP as it is.
1455      * @param string $ldapconnection Only needed for Active Directory.
1456      * @param string $user_dn User distinguished name for the user we are checking password expiration (only needed for Active Directory).
1457      * @return timestamp
1458      */
1459     function ldap_expirationtime2unix ($time, $ldapconnection, $user_dn) {
1460         $result = false;
1461         switch ($this->config->user_type) {
1462             case 'edir':
1463                 $yr=substr($time, 0, 4);
1464                 $mo=substr($time, 4, 2);
1465                 $dt=substr($time, 6, 2);
1466                 $hr=substr($time, 8, 2);
1467                 $min=substr($time, 10, 2);
1468                 $sec=substr($time, 12, 2);
1469                 $result = mktime($hr, $min, $sec, $mo, $dt, $yr);
1470                 break;
1471             case 'rfc2307':
1472             case 'rfc2307bis':
1473                 $result = $time * DAYSECS; // The shadowExpire contains the number of DAYS between 01/01/1970 and the actual expiration date
1474                 break;
1475             case 'ad':
1476                 $result = $this->ldap_get_ad_pwdexpire($time, $ldapconnection, $user_dn);
1477                 break;
1478             default:
1479                 print_error('auth_ldap_usertypeundefined', 'auth_ldap');
1480         }
1481         return $result;
1482     }
1484     /**
1485      * Takes unix timestamp and returns it formated for storing in LDAP
1486      *
1487      * @param integer unix time stamp
1488      */
1489     function ldap_unix2expirationtime($time) {
1490         $result = false;
1491         switch ($this->config->user_type) {
1492             case 'edir':
1493                 $result=date('YmdHis', $time).'Z';
1494                 break;
1495             case 'rfc2307':
1496             case 'rfc2307bis':
1497                 $result = $time ; // Already in correct format
1498                 break;
1499             default:
1500                 print_error('auth_ldap_usertypeundefined2', 'auth_ldap');
1501         }
1502         return $result;
1504     }
1506     /**
1507      * Returns user attribute mappings between moodle and LDAP
1508      *
1509      * @return array
1510      */
1512     function ldap_attributes () {
1513         $moodleattributes = array();
1514         // If we have custom fields then merge them with user fields.
1515         $customfields = $this->get_custom_user_profile_fields();
1516         if (!empty($customfields) && !empty($this->userfields)) {
1517             $userfields = array_merge($this->userfields, $customfields);
1518         } else {
1519             $userfields = $this->userfields;
1520         }
1522         foreach ($userfields as $field) {
1523             if (!empty($this->config->{"field_map_$field"})) {
1524                 $moodleattributes[$field] = core_text::strtolower(trim($this->config->{"field_map_$field"}));
1525                 if (preg_match('/,/', $moodleattributes[$field])) {
1526                     $moodleattributes[$field] = explode(',', $moodleattributes[$field]); // split ?
1527                 }
1528             }
1529         }
1530         $moodleattributes['username'] = core_text::strtolower(trim($this->config->user_attribute));
1531         return $moodleattributes;
1532     }
1534     /**
1535      * Returns all usernames from LDAP
1536      *
1537      * @param $filter An LDAP search filter to select desired users
1538      * @return array of LDAP user names converted to UTF-8
1539      */
1540     function ldap_get_userlist($filter='*') {
1541         $fresult = array();
1543         $ldapconnection = $this->ldap_connect();
1545         if ($filter == '*') {
1546            $filter = '(&('.$this->config->user_attribute.'=*)'.$this->config->objectclass.')';
1547         }
1549         $contexts = explode(';', $this->config->contexts);
1550         if (!empty($this->config->create_context)) {
1551             array_push($contexts, $this->config->create_context);
1552         }
1554         $ldap_cookie = '';
1555         $ldap_pagedresults = ldap_paged_results_supported($this->config->ldap_version);
1556         foreach ($contexts as $context) {
1557             $context = trim($context);
1558             if (empty($context)) {
1559                 continue;
1560             }
1562             do {
1563                 if ($ldap_pagedresults) {
1564                     ldap_control_paged_result($ldapconnection, $this->config->pagesize, true, $ldap_cookie);
1565                 }
1566                 if ($this->config->search_sub) {
1567                     // Use ldap_search to find first user from subtree.
1568                     $ldap_result = ldap_search($ldapconnection, $context, $filter, array($this->config->user_attribute));
1569                 } else {
1570                     // Search only in this context.
1571                     $ldap_result = ldap_list($ldapconnection, $context, $filter, array($this->config->user_attribute));
1572                 }
1573                 if(!$ldap_result) {
1574                     continue;
1575                 }
1576                 if ($ldap_pagedresults) {
1577                     ldap_control_paged_result_response($ldapconnection, $ldap_result, $ldap_cookie);
1578                 }
1579                 $users = ldap_get_entries_moodle($ldapconnection, $ldap_result);
1580                 // Add found users to list.
1581                 for ($i = 0; $i < count($users); $i++) {
1582                     $extuser = core_text::convert($users[$i][$this->config->user_attribute][0],
1583                                                 $this->config->ldapencoding, 'utf-8');
1584                     array_push($fresult, $extuser);
1585                 }
1586                 unset($ldap_result); // Free mem.
1587             } while ($ldap_pagedresults && !empty($ldap_cookie));
1588         }
1590         // If paged results were used, make sure the current connection is completely closed
1591         $this->ldap_close($ldap_pagedresults);
1592         return $fresult;
1593     }
1595     /**
1596      * Indicates if password hashes should be stored in local moodle database.
1597      *
1598      * @return bool true means flag 'not_cached' stored instead of password hash
1599      */
1600     function prevent_local_passwords() {
1601         return !empty($this->config->preventpassindb);
1602     }
1604     /**
1605      * Returns true if this authentication plugin is 'internal'.
1606      *
1607      * @return bool
1608      */
1609     function is_internal() {
1610         return false;
1611     }
1613     /**
1614      * Returns true if this authentication plugin can change the user's
1615      * password.
1616      *
1617      * @return bool
1618      */
1619     function can_change_password() {
1620         return !empty($this->config->stdchangepassword) or !empty($this->config->changepasswordurl);
1621     }
1623     /**
1624      * Returns the URL for changing the user's password, or empty if the default can
1625      * be used.
1626      *
1627      * @return moodle_url
1628      */
1629     function change_password_url() {
1630         if (empty($this->config->stdchangepassword)) {
1631             if (!empty($this->config->changepasswordurl)) {
1632                 return new moodle_url($this->config->changepasswordurl);
1633             } else {
1634                 return null;
1635             }
1636         } else {
1637             return null;
1638         }
1639     }
1641     /**
1642      * Will get called before the login page is shownr. Ff NTLM SSO
1643      * is enabled, and the user is in the right network, we'll redirect
1644      * to the magic NTLM page for SSO...
1645      *
1646      */
1647     function loginpage_hook() {
1648         global $CFG, $SESSION;
1650         // HTTPS is potentially required
1651         //httpsrequired(); - this must be used before setting the URL, it is already done on the login/index.php
1653         if (($_SERVER['REQUEST_METHOD'] === 'GET'         // Only on initial GET of loginpage
1654              || ($_SERVER['REQUEST_METHOD'] === 'POST'
1655                  && (get_local_referer() != strip_querystring(qualified_me()))))
1656                                                           // Or when POSTed from another place
1657                                                           // See MDL-14071
1658             && !empty($this->config->ntlmsso_enabled)     // SSO enabled
1659             && !empty($this->config->ntlmsso_subnet)      // have a subnet to test for
1660             && empty($_GET['authldap_skipntlmsso'])       // haven't failed it yet
1661             && (isguestuser() || !isloggedin())           // guestuser or not-logged-in users
1662             && address_in_subnet(getremoteaddr(), $this->config->ntlmsso_subnet)) {
1664             // First, let's remember where we were trying to get to before we got here
1665             if (empty($SESSION->wantsurl)) {
1666                 $SESSION->wantsurl = null;
1667                 $referer = get_local_referer(false);
1668                 if ($referer &&
1669                         $referer != $CFG->wwwroot &&
1670                         $referer != $CFG->wwwroot . '/' &&
1671                         $referer != $CFG->httpswwwroot . '/login/' &&
1672                         $referer != $CFG->httpswwwroot . '/login/index.php') {
1673                     $SESSION->wantsurl = $referer;
1674                 }
1675             }
1677             // Now start the whole NTLM machinery.
1678             if($this->config->ntlmsso_ie_fastpath == AUTH_NTLM_FASTPATH_YESATTEMPT ||
1679                 $this->config->ntlmsso_ie_fastpath == AUTH_NTLM_FASTPATH_YESFORM) {
1680                 if (core_useragent::is_ie()) {
1681                     $sesskey = sesskey();
1682                     redirect($CFG->wwwroot.'/auth/ldap/ntlmsso_magic.php?sesskey='.$sesskey);
1683                 } else if ($this->config->ntlmsso_ie_fastpath == AUTH_NTLM_FASTPATH_YESFORM) {
1684                     redirect($CFG->httpswwwroot.'/login/index.php?authldap_skipntlmsso=1');
1685                 }
1686             }
1687             redirect($CFG->wwwroot.'/auth/ldap/ntlmsso_attempt.php');
1688         }
1690         // No NTLM SSO, Use the normal login page instead.
1692         // If $SESSION->wantsurl is empty and we have a 'Referer:' header, the login
1693         // page insists on redirecting us to that page after user validation. If
1694         // we clicked on the redirect link at the ntlmsso_finish.php page (instead
1695         // of waiting for the redirection to happen) then we have a 'Referer:' header
1696         // we don't want to use at all. As we can't get rid of it, just point
1697         // $SESSION->wantsurl to $CFG->wwwroot (after all, we came from there).
1698         if (empty($SESSION->wantsurl)
1699             && (get_local_referer() == $CFG->httpswwwroot.'/auth/ldap/ntlmsso_finish.php')) {
1701             $SESSION->wantsurl = $CFG->wwwroot;
1702         }
1703     }
1705     /**
1706      * To be called from a page running under NTLM's
1707      * "Integrated Windows Authentication".
1708      *
1709      * If successful, it will set a special "cookie" (not an HTTP cookie!)
1710      * in cache_flags under the $this->pluginconfig/ntlmsess "plugin" and return true.
1711      * The "cookie" will be picked up by ntlmsso_finish() to complete the
1712      * process.
1713      *
1714      * On failure it will return false for the caller to display an appropriate
1715      * error message (probably saying that Integrated Windows Auth isn't enabled!)
1716      *
1717      * NOTE that this code will execute under the OS user credentials,
1718      * so we MUST avoid dealing with files -- such as session files.
1719      * (The caller should define('NO_MOODLE_COOKIES', true) before including config.php)
1720      *
1721      */
1722     function ntlmsso_magic($sesskey) {
1723         if (isset($_SERVER['REMOTE_USER']) && !empty($_SERVER['REMOTE_USER'])) {
1725             // HTTP __headers__ seem to be sent in ISO-8859-1 encoding
1726             // (according to my reading of RFC-1945, RFC-2616 and RFC-2617 and
1727             // my local tests), so we need to convert the REMOTE_USER value
1728             // (i.e., what we got from the HTTP WWW-Authenticate header) into UTF-8
1729             $username = core_text::convert($_SERVER['REMOTE_USER'], 'iso-8859-1', 'utf-8');
1731             switch ($this->config->ntlmsso_type) {
1732                 case 'ntlm':
1733                     // The format is now configurable, so try to extract the username
1734                     $username = $this->get_ntlm_remote_user($username);
1735                     if (empty($username)) {
1736                         return false;
1737                     }
1738                     break;
1739                 case 'kerberos':
1740                     // Format is username@DOMAIN
1741                     $username = substr($username, 0, strpos($username, '@'));
1742                     break;
1743                 default:
1744                     error_log($this->errorlogtag.get_string ('ntlmsso_unknowntype', 'auth_ldap'));
1745                     return false; // Should never happen!
1746             }
1748             $username = core_text::strtolower($username); // Compatibility hack
1749             set_cache_flag($this->pluginconfig.'/ntlmsess', $sesskey, $username, AUTH_NTLMTIMEOUT);
1750             return true;
1751         }
1752         return false;
1753     }
1755     /**
1756      * Find the session set by ntlmsso_magic(), validate it and
1757      * call authenticate_user_login() to authenticate the user through
1758      * the auth machinery.
1759      *
1760      * It is complemented by a similar check in user_login().
1761      *
1762      * If it succeeds, it never returns.
1763      *
1764      */
1765     function ntlmsso_finish() {
1766         global $CFG, $USER, $SESSION;
1768         $key = sesskey();
1769         $cf = get_cache_flags($this->pluginconfig.'/ntlmsess');
1770         if (!isset($cf[$key]) || $cf[$key] === '') {
1771             return false;
1772         }
1773         $username   = $cf[$key];
1775         // Here we want to trigger the whole authentication machinery
1776         // to make sure no step is bypassed...
1777         $user = authenticate_user_login($username, $key);
1778         if ($user) {
1779             complete_user_login($user);
1781             // Cleanup the key to prevent reuse...
1782             // and to allow re-logins with normal credentials
1783             unset_cache_flag($this->pluginconfig.'/ntlmsess', $key);
1785             // Redirection
1786             if (user_not_fully_set_up($USER)) {
1787                 $urltogo = $CFG->wwwroot.'/user/edit.php';
1788                 // We don't delete $SESSION->wantsurl yet, so we get there later
1789             } else if (isset($SESSION->wantsurl) and (strpos($SESSION->wantsurl, $CFG->wwwroot) === 0)) {
1790                 $urltogo = $SESSION->wantsurl;    // Because it's an address in this site
1791                 unset($SESSION->wantsurl);
1792             } else {
1793                 // No wantsurl stored or external - go to homepage
1794                 $urltogo = $CFG->wwwroot.'/';
1795                 unset($SESSION->wantsurl);
1796             }
1797             // We do not want to redirect if we are in a PHPUnit test.
1798             if (!PHPUNIT_TEST) {
1799                 redirect($urltogo);
1800             }
1801         }
1802         // Should never reach here.
1803         return false;
1804     }
1806     /**
1807      * Sync roles for this user
1808      *
1809      * @param $user object user object (without system magic quotes)
1810      */
1811     function sync_roles($user) {
1812         $iscreator = $this->iscreator($user->username);
1813         if ($iscreator === null) {
1814             return; // Nothing to sync - creators not configured
1815         }
1817         if ($roles = get_archetype_roles('coursecreator')) {
1818             $creatorrole = array_shift($roles);      // We can only use one, let's use the first one
1819             $systemcontext = context_system::instance();
1821             if ($iscreator) { // Following calls will not create duplicates
1822                 role_assign($creatorrole->id, $user->id, $systemcontext->id, $this->roleauth);
1823             } else {
1824                 // Unassign only if previously assigned by this plugin!
1825                 role_unassign($creatorrole->id, $user->id, $systemcontext->id, $this->roleauth);
1826             }
1827         }
1828     }
1830     /**
1831      * Prints a form for configuring this authentication plugin.
1832      *
1833      * This function is called from admin/auth.php, and outputs a full page with
1834      * a form for configuring this plugin.
1835      *
1836      * @param array $page An object containing all the data for this page.
1837      */
1838     function config_form($config, $err, $user_fields) {
1839         global $CFG, $OUTPUT;
1841         if (!function_exists('ldap_connect')) { // Is php-ldap really there?
1842             echo $OUTPUT->notification(get_string('auth_ldap_noextension', 'auth_ldap'));
1843             return;
1844         }
1846         include($CFG->dirroot.'/auth/ldap/config.html');
1847     }
1849     /**
1850      * Processes and stores configuration data for this authentication plugin.
1851      */
1852     function process_config($config) {
1853         // Set to defaults if undefined
1854         if (!isset($config->host_url)) {
1855              $config->host_url = '';
1856         }
1857         if (!isset($config->start_tls)) {
1858              $config->start_tls = false;
1859         }
1860         if (empty($config->ldapencoding)) {
1861          $config->ldapencoding = 'utf-8';
1862         }
1863         if (!isset($config->pagesize)) {
1864             $config->pagesize = LDAP_DEFAULT_PAGESIZE;
1865         }
1866         if (!isset($config->contexts)) {
1867              $config->contexts = '';
1868         }
1869         if (!isset($config->user_type)) {
1870              $config->user_type = 'default';
1871         }
1872         if (!isset($config->user_attribute)) {
1873              $config->user_attribute = '';
1874         }
1875         if (!isset($config->search_sub)) {
1876              $config->search_sub = '';
1877         }
1878         if (!isset($config->opt_deref)) {
1879              $config->opt_deref = LDAP_DEREF_NEVER;
1880         }
1881         if (!isset($config->preventpassindb)) {
1882              $config->preventpassindb = 0;
1883         }
1884         if (!isset($config->bind_dn)) {
1885             $config->bind_dn = '';
1886         }
1887         if (!isset($config->bind_pw)) {
1888             $config->bind_pw = '';
1889         }
1890         if (!isset($config->ldap_version)) {
1891             $config->ldap_version = '3';
1892         }
1893         if (!isset($config->objectclass)) {
1894             $config->objectclass = '';
1895         }
1896         if (!isset($config->memberattribute)) {
1897             $config->memberattribute = '';
1898         }
1899         if (!isset($config->memberattribute_isdn)) {
1900             $config->memberattribute_isdn = '';
1901         }
1902         if (!isset($config->creators)) {
1903             $config->creators = '';
1904         }
1905         if (!isset($config->create_context)) {
1906             $config->create_context = '';
1907         }
1908         if (!isset($config->expiration)) {
1909             $config->expiration = '';
1910         }
1911         if (!isset($config->expiration_warning)) {
1912             $config->expiration_warning = '10';
1913         }
1914         if (!isset($config->expireattr)) {
1915             $config->expireattr = '';
1916         }
1917         if (!isset($config->gracelogins)) {
1918             $config->gracelogins = '';
1919         }
1920         if (!isset($config->graceattr)) {
1921             $config->graceattr = '';
1922         }
1923         if (!isset($config->auth_user_create)) {
1924             $config->auth_user_create = '';
1925         }
1926         if (!isset($config->forcechangepassword)) {
1927             $config->forcechangepassword = 0;
1928         }
1929         if (!isset($config->stdchangepassword)) {
1930             $config->stdchangepassword = 0;
1931         }
1932         if (!isset($config->passtype)) {
1933             $config->passtype = 'plaintext';
1934         }
1935         if (!isset($config->changepasswordurl)) {
1936             $config->changepasswordurl = '';
1937         }
1938         if (!isset($config->removeuser)) {
1939             $config->removeuser = AUTH_REMOVEUSER_KEEP;
1940         }
1941         if (!isset($config->ntlmsso_enabled)) {
1942             $config->ntlmsso_enabled = 0;
1943         }
1944         if (!isset($config->ntlmsso_subnet)) {
1945             $config->ntlmsso_subnet = '';
1946         }
1947         if (!isset($config->ntlmsso_ie_fastpath)) {
1948             $config->ntlmsso_ie_fastpath = 0;
1949         }
1950         if (!isset($config->ntlmsso_type)) {
1951             $config->ntlmsso_type = 'ntlm';
1952         }
1953         if (!isset($config->ntlmsso_remoteuserformat)) {
1954             $config->ntlmsso_remoteuserformat = '';
1955         }
1957         // Try to remove duplicates before storing the contexts (to avoid problems in sync_users()).
1958         $config->contexts = explode(';', $config->contexts);
1959         $config->contexts = array_map(create_function('$x', 'return core_text::strtolower(trim($x));'),
1960                                       $config->contexts);
1961         $config->contexts = implode(';', array_unique($config->contexts));
1963         // Save settings
1964         set_config('host_url', trim($config->host_url), $this->pluginconfig);
1965         set_config('start_tls', $config->start_tls, $this->pluginconfig);
1966         set_config('ldapencoding', trim($config->ldapencoding), $this->pluginconfig);
1967         set_config('pagesize', (int)trim($config->pagesize), $this->pluginconfig);
1968         set_config('contexts', $config->contexts, $this->pluginconfig);
1969         set_config('user_type', core_text::strtolower(trim($config->user_type)), $this->pluginconfig);
1970         set_config('user_attribute', core_text::strtolower(trim($config->user_attribute)), $this->pluginconfig);
1971         set_config('search_sub', $config->search_sub, $this->pluginconfig);
1972         set_config('opt_deref', $config->opt_deref, $this->pluginconfig);
1973         set_config('preventpassindb', $config->preventpassindb, $this->pluginconfig);
1974         set_config('bind_dn', trim($config->bind_dn), $this->pluginconfig);
1975         set_config('bind_pw', $config->bind_pw, $this->pluginconfig);
1976         set_config('ldap_version', $config->ldap_version, $this->pluginconfig);
1977         set_config('objectclass', trim($config->objectclass), $this->pluginconfig);
1978         set_config('memberattribute', core_text::strtolower(trim($config->memberattribute)), $this->pluginconfig);
1979         set_config('memberattribute_isdn', $config->memberattribute_isdn, $this->pluginconfig);
1980         set_config('creators', trim($config->creators), $this->pluginconfig);
1981         set_config('create_context', trim($config->create_context), $this->pluginconfig);
1982         set_config('expiration', $config->expiration, $this->pluginconfig);
1983         set_config('expiration_warning', trim($config->expiration_warning), $this->pluginconfig);
1984         set_config('expireattr', core_text::strtolower(trim($config->expireattr)), $this->pluginconfig);
1985         set_config('gracelogins', $config->gracelogins, $this->pluginconfig);
1986         set_config('graceattr', core_text::strtolower(trim($config->graceattr)), $this->pluginconfig);
1987         set_config('auth_user_create', $config->auth_user_create, $this->pluginconfig);
1988         set_config('forcechangepassword', $config->forcechangepassword, $this->pluginconfig);
1989         set_config('stdchangepassword', $config->stdchangepassword, $this->pluginconfig);
1990         set_config('passtype', $config->passtype, $this->pluginconfig);
1991         set_config('changepasswordurl', trim($config->changepasswordurl), $this->pluginconfig);
1992         set_config('removeuser', $config->removeuser, $this->pluginconfig);
1993         set_config('ntlmsso_enabled', (int)$config->ntlmsso_enabled, $this->pluginconfig);
1994         set_config('ntlmsso_subnet', trim($config->ntlmsso_subnet), $this->pluginconfig);
1995         set_config('ntlmsso_ie_fastpath', (int)$config->ntlmsso_ie_fastpath, $this->pluginconfig);
1996         set_config('ntlmsso_type', $config->ntlmsso_type, 'auth/ldap');
1997         set_config('ntlmsso_remoteuserformat', trim($config->ntlmsso_remoteuserformat), 'auth/ldap');
1999         return true;
2000     }
2002     /**
2003      * Get password expiration time for a given user from Active Directory
2004      *
2005      * @param string $pwdlastset The time last time we changed the password.
2006      * @param resource $lcapconn The open LDAP connection.
2007      * @param string $user_dn The distinguished name of the user we are checking.
2008      *
2009      * @return string $unixtime
2010      */
2011     function ldap_get_ad_pwdexpire($pwdlastset, $ldapconn, $user_dn){
2012         global $CFG;
2014         if (!function_exists('bcsub')) {
2015             error_log($this->errorlogtag.get_string ('needbcmath', 'auth_ldap'));
2016             return 0;
2017         }
2019         // If UF_DONT_EXPIRE_PASSWD flag is set in user's
2020         // userAccountControl attribute, the password doesn't expire.
2021         $sr = ldap_read($ldapconn, $user_dn, '(objectClass=*)',
2022                         array('userAccountControl'));
2023         if (!$sr) {
2024             error_log($this->errorlogtag.get_string ('useracctctrlerror', 'auth_ldap', $user_dn));
2025             // Don't expire password, as we are not sure if it has to be
2026             // expired or not.
2027             return 0;
2028         }
2030         $entry = ldap_get_entries_moodle($ldapconn, $sr);
2031         $info = array_change_key_case($entry[0], CASE_LOWER);
2032         $useraccountcontrol = $info['useraccountcontrol'][0];
2033         if ($useraccountcontrol & UF_DONT_EXPIRE_PASSWD) {
2034             // Password doesn't expire.
2035             return 0;
2036         }
2038         // If pwdLastSet is zero, the user must change his/her password now
2039         // (unless UF_DONT_EXPIRE_PASSWD flag is set, but we already
2040         // tested this above)
2041         if ($pwdlastset === '0') {
2042             // Password has expired
2043             return -1;
2044         }
2046         // ----------------------------------------------------------------
2047         // Password expiration time in Active Directory is the composition of
2048         // two values:
2049         //
2050         //   - User's pwdLastSet attribute, that stores the last time
2051         //     the password was changed.
2052         //
2053         //   - Domain's maxPwdAge attribute, that sets how long
2054         //     passwords last in this domain.
2055         //
2056         // We already have the first value (passed in as a parameter). We
2057         // need to get the second one. As we don't know the domain DN, we
2058         // have to query rootDSE's defaultNamingContext attribute to get
2059         // it. Then we have to query that DN's maxPwdAge attribute to get
2060         // the real value.
2061         //
2062         // Once we have both values, we just need to combine them. But MS
2063         // chose to use a different base and unit for time measurements.
2064         // So we need to convert the values to Unix timestamps (see
2065         // details below).
2066         // ----------------------------------------------------------------
2068         $sr = ldap_read($ldapconn, ROOTDSE, '(objectClass=*)',
2069                         array('defaultNamingContext'));
2070         if (!$sr) {
2071             error_log($this->errorlogtag.get_string ('rootdseerror', 'auth_ldap'));
2072             return 0;
2073         }
2075         $entry = ldap_get_entries_moodle($ldapconn, $sr);
2076         $info = array_change_key_case($entry[0], CASE_LOWER);
2077         $domaindn = $info['defaultnamingcontext'][0];
2079         $sr = ldap_read ($ldapconn, $domaindn, '(objectClass=*)',
2080                          array('maxPwdAge'));
2081         $entry = ldap_get_entries_moodle($ldapconn, $sr);
2082         $info = array_change_key_case($entry[0], CASE_LOWER);
2083         $maxpwdage = $info['maxpwdage'][0];
2084         if ($sr = ldap_read($ldapconn, $user_dn, '(objectClass=*)', array('msDS-ResultantPSO', 'msDS-MaximumPasswordAge'))) {
2085             if ($entry = ldap_get_entries_moodle($ldapconn, $sr)) {
2086                 $info = array_change_key_case($entry[0], CASE_LOWER);
2087                 $userpso = $info['msds-resultantpso'][0];
2089                 // If a PSO exists, FGPP is being utilized.
2090                 // Grab the new maxpwdage from the msDS-MaximumPasswordAge attribute of the PSO.
2091                 if (!empty($userpso)) {
2092                     if ($entry = ldap_get_entries_moodle($ldapconn, $sr)) {
2093                         $info = array_change_key_case($entry[0], CASE_LOWER);
2094                         $maxpwdage = $info['msds-maximumpasswordage'][0];
2095                     }
2096                 }
2097             }
2098         }
2099         // ----------------------------------------------------------------
2100         // MSDN says that "pwdLastSet contains the number of 100 nanosecond
2101         // intervals since January 1, 1601 (UTC), stored in a 64 bit integer".
2102         //
2103         // According to Perl's Date::Manip, the number of seconds between
2104         // this date and Unix epoch is 11644473600. So we have to
2105         // substract this value to calculate a Unix time, once we have
2106         // scaled pwdLastSet to seconds. This is the script used to
2107         // calculate the value shown above:
2108         //
2109         //    #!/usr/bin/perl -w
2110         //
2111         //    use Date::Manip;
2112         //
2113         //    $date1 = ParseDate ("160101010000 UTC");
2114         //    $date2 = ParseDate ("197001010000 UTC");
2115         //    $delta = DateCalc($date1, $date2, \$err);
2116         //    $secs = Delta_Format($delta, 0, "%st");
2117         //    print "$secs \n";
2118         //
2119         // MSDN also says that "maxPwdAge is stored as a large integer that
2120         // represents the number of 100 nanosecond intervals from the time
2121         // the password was set before the password expires." We also need
2122         // to scale this to seconds. Bear in mind that this value is stored
2123         // as a _negative_ quantity (at least in my AD domain).
2124         //
2125         // As a last remark, if the low 32 bits of maxPwdAge are equal to 0,
2126         // the maximum password age in the domain is set to 0, which means
2127         // passwords do not expire (see
2128         // http://msdn2.microsoft.com/en-us/library/ms974598.aspx)
2129         //
2130         // As the quantities involved are too big for PHP integers, we
2131         // need to use BCMath functions to work with arbitrary precision
2132         // numbers.
2133         // ----------------------------------------------------------------
2135         // If the low order 32 bits are 0, then passwords do not expire in
2136         // the domain. Just do '$maxpwdage mod 2^32' and check the result
2137         // (2^32 = 4294967296)
2138         if (bcmod ($maxpwdage, 4294967296) === '0') {
2139             return 0;
2140         }
2142         // Add up pwdLastSet and maxPwdAge to get password expiration
2143         // time, in MS time units. Remember maxPwdAge is stored as a
2144         // _negative_ quantity, so we need to substract it in fact.
2145         $pwdexpire = bcsub ($pwdlastset, $maxpwdage);
2147         // Scale the result to convert it to Unix time units and return
2148         // that value.
2149         return bcsub( bcdiv($pwdexpire, '10000000'), '11644473600');
2150     }
2152     /**
2153      * Connect to the LDAP server, using the plugin configured
2154      * settings. It's actually a wrapper around ldap_connect_moodle()
2155      *
2156      * @return resource A valid LDAP connection (or dies if it can't connect)
2157      */
2158     function ldap_connect() {
2159         // Cache ldap connections. They are expensive to set up
2160         // and can drain the TCP/IP ressources on the server if we
2161         // are syncing a lot of users (as we try to open a new connection
2162         // to get the user details). This is the least invasive way
2163         // to reuse existing connections without greater code surgery.
2164         if(!empty($this->ldapconnection)) {
2165             $this->ldapconns++;
2166             return $this->ldapconnection;
2167         }
2169         if($ldapconnection = ldap_connect_moodle($this->config->host_url, $this->config->ldap_version,
2170                                                  $this->config->user_type, $this->config->bind_dn,
2171                                                  $this->config->bind_pw, $this->config->opt_deref,
2172                                                  $debuginfo, $this->config->start_tls)) {
2173             $this->ldapconns = 1;
2174             $this->ldapconnection = $ldapconnection;
2175             return $ldapconnection;
2176         }
2178         print_error('auth_ldap_noconnect_all', 'auth_ldap', '', $debuginfo);
2179     }
2181     /**
2182      * Disconnects from a LDAP server
2183      *
2184      * @param force boolean Forces closing the real connection to the LDAP server, ignoring any
2185      *                      cached connections. This is needed when we've used paged results
2186      *                      and want to use normal results again.
2187      */
2188     function ldap_close($force=false) {
2189         $this->ldapconns--;
2190         if (($this->ldapconns == 0) || ($force)) {
2191             $this->ldapconns = 0;
2192             @ldap_close($this->ldapconnection);
2193             unset($this->ldapconnection);
2194         }
2195     }
2197     /**
2198      * Search specified contexts for username and return the user dn
2199      * like: cn=username,ou=suborg,o=org. It's actually a wrapper
2200      * around ldap_find_userdn().
2201      *
2202      * @param resource $ldapconnection a valid LDAP connection
2203      * @param string $extusername the username to search (in external LDAP encoding, no db slashes)
2204      * @return mixed the user dn (external LDAP encoding) or false
2205      */
2206     function ldap_find_userdn($ldapconnection, $extusername) {
2207         $ldap_contexts = explode(';', $this->config->contexts);
2208         if (!empty($this->config->create_context)) {
2209             array_push($ldap_contexts, $this->config->create_context);
2210         }
2212         return ldap_find_userdn($ldapconnection, $extusername, $ldap_contexts, $this->config->objectclass,
2213                                 $this->config->user_attribute, $this->config->search_sub);
2214     }
2217     /**
2218      * A chance to validate form data, and last chance to do stuff
2219      * before it is inserted in config_plugin
2220      *
2221      * @param object object with submitted configuration settings (without system magic quotes)
2222      * @param array $err array of error messages (passed by reference)
2223      */
2224     function validate_form($form, &$err) {
2225         if ($form->ntlmsso_type == 'ntlm') {
2226             $format = trim($form->ntlmsso_remoteuserformat);
2227             if (!empty($format) && !preg_match('/%username%/i', $format)) {
2228                 $err['ntlmsso_remoteuserformat'] = get_string('auth_ntlmsso_missing_username', 'auth_ldap');
2229             }
2230         }
2231     }
2234     /**
2235      * When using NTLM SSO, the format of the remote username we get in
2236      * $_SERVER['REMOTE_USER'] may vary, depending on where from and how the web
2237      * server gets the data. So we let the admin configure the format using two
2238      * place holders (%domain% and %username%). This function tries to extract
2239      * the username (stripping the domain part and any separators if they are
2240      * present) from the value present in $_SERVER['REMOTE_USER'], using the
2241      * configured format.
2242      *
2243      * @param string $remoteuser The value from $_SERVER['REMOTE_USER'] (converted to UTF-8)
2244      *
2245      * @return string The remote username (without domain part or
2246      *                separators). Empty string if we can't extract the username.
2247      */
2248     protected function get_ntlm_remote_user($remoteuser) {
2249         if (empty($this->config->ntlmsso_remoteuserformat)) {
2250             $format = AUTH_NTLM_DEFAULT_FORMAT;
2251         } else {
2252             $format = $this->config->ntlmsso_remoteuserformat;
2253         }
2255         $format = preg_quote($format);
2256         $formatregex = preg_replace(array('#%domain%#', '#%username%#'),
2257                                     array('('.AUTH_NTLM_VALID_DOMAINNAME.')', '('.AUTH_NTLM_VALID_USERNAME.')'),
2258                                     $format);
2259         if (preg_match('#^'.$formatregex.'$#', $remoteuser, $matches)) {
2260             $user = end($matches);
2261             return $user;
2262         }
2264         /* We are unable to extract the username with the configured format. Probably
2265          * the format specified is wrong, so log a warning for the admin and return
2266          * an empty username.
2267          */
2268         error_log($this->errorlogtag.get_string ('auth_ntlmsso_maybeinvalidformat', 'auth_ldap'));
2269         return '';
2270     }
2272     /**
2273      * Check if the diagnostic message for the LDAP login error tells us that the
2274      * login is denied because the user password has expired or the password needs
2275      * to be changed on first login (using interactive SMB/Windows logins, not
2276      * LDAP logins).
2277      *
2278      * @param string the diagnostic message for the LDAP login error
2279      * @return bool true if the password has expired or the password must be changed on first login
2280      */
2281     protected function ldap_ad_pwdexpired_from_diagmsg($diagmsg) {
2282         // The format of the diagnostic message is (actual examples from W2003 and W2008):
2283         // "80090308: LdapErr: DSID-0C090334, comment: AcceptSecurityContext error, data 52e, vece"  (W2003)
2284         // "80090308: LdapErr: DSID-0C090334, comment: AcceptSecurityContext error, data 773, vece"  (W2003)
2285         // "80090308: LdapErr: DSID-0C0903AA, comment: AcceptSecurityContext error, data 52e, v1771" (W2008)
2286         // "80090308: LdapErr: DSID-0C0903AA, comment: AcceptSecurityContext error, data 773, v1771" (W2008)
2287         // We are interested in the 'data nnn' part.
2288         //   if nnn == 773 then user must change password on first login
2289         //   if nnn == 532 then user password has expired
2290         $diagmsg = explode(',', $diagmsg);
2291         if (preg_match('/data (773|532)/i', trim($diagmsg[2]))) {
2292             return true;
2293         }
2294         return false;
2295     }
2297 } // End of the class