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