MDL-32572 fix notice when changing internal auth_db passwords
[moodle.git] / auth / db / auth.php
1 <?php
2 /**
3  * Authentication Plugin: External Database Authentication
4  *
5  * Checks against an external database.
6  *
7  * @package    auth
8  * @subpackage db
9  * @author     Martin Dougiamas
10  * @license    http://www.gnu.org/copyleft/gpl.html GNU Public License
11  */
13 defined('MOODLE_INTERNAL') || die();
15 require_once($CFG->libdir.'/authlib.php');
16 require_once($CFG->libdir.'/adodb/adodb.inc.php');
18 /**
19  * External database authentication plugin.
20  */
21 class auth_plugin_db extends auth_plugin_base {
23     /**
24      * Constructor.
25      */
26     function auth_plugin_db() {
27         $this->authtype = 'db';
28         $this->config = get_config('auth/db');
29         if (empty($this->config->extencoding)) {
30             $this->config->extencoding = 'utf-8';
31         }
32     }
34     /**
35      * Returns true if the username and password work and false if they are
36      * wrong or don't exist.
37      *
38      * @param string $username The username
39      * @param string $password The password
40      *
41      * @return bool Authentication success or failure.
42      */
43     function user_login($username, $password) {
44         global $CFG, $DB;
46         $extusername = textlib::convert($username, 'utf-8', $this->config->extencoding);
47         $extpassword = textlib::convert($password, 'utf-8', $this->config->extencoding);
49         $authdb = $this->db_init();
51         if ($this->is_internal()) {
52             // lookup username externally, but resolve
53             // password locally -- to support backend that
54             // don't track passwords
55             $rs = $authdb->Execute("SELECT * FROM {$this->config->table}
56                                      WHERE {$this->config->fielduser} = '".$this->ext_addslashes($extusername)."' ");
57             if (!$rs) {
58                 $authdb->Close();
59                 debugging(get_string('auth_dbcantconnect','auth_db'));
60                 return false;
61             }
63             if (!$rs->EOF) {
64                 $rs->Close();
65                 $authdb->Close();
66                 // user exists externally
67                 // check username/password internally
68                 if ($user = $DB->get_record('user', array('username'=>$username, 'mnethostid'=>$CFG->mnet_localhost_id, 'auth'=>$this->authtype))) {
69                     return validate_internal_user_password($user, $password);
70                 }
71             } else {
72                 $rs->Close();
73                 $authdb->Close();
74                 // user does not exist externally
75                 return false;
76             }
78         } else {
79             // normal case: use external db for both usernames and passwords
81             if ($this->config->passtype === 'md5') {   // Re-format password accordingly
82                 $extpassword = md5($extpassword);
83             } else if ($this->config->passtype === 'sha1') {
84                 $extpassword = sha1($extpassword);
85             }
87             $rs = $authdb->Execute("SELECT * FROM {$this->config->table}
88                                 WHERE {$this->config->fielduser} = '".$this->ext_addslashes($extusername)."'
89                                   AND {$this->config->fieldpass} = '".$this->ext_addslashes($extpassword)."' ");
90             if (!$rs) {
91                 $authdb->Close();
92                 debugging(get_string('auth_dbcantconnect','auth_db'));
93                 return false;
94             }
96             if (!$rs->EOF) {
97                 $rs->Close();
98                 $authdb->Close();
99                 return true;
100             } else {
101                 $rs->Close();
102                 $authdb->Close();
103                 return false;
104             }
106         }
107     }
109     function db_init() {
110         // Connect to the external database (forcing new connection)
111         $authdb = ADONewConnection($this->config->type);
112         if (!empty($this->config->debugauthdb)) {
113             $authdb->debug = true;
114             ob_start();//start output buffer to allow later use of the page headers
115         }
116         $authdb->Connect($this->config->host, $this->config->user, $this->config->pass, $this->config->name, true);
117         $authdb->SetFetchMode(ADODB_FETCH_ASSOC);
118         if (!empty($this->config->setupsql)) {
119             $authdb->Execute($this->config->setupsql);
120         }
122         return $authdb;
123     }
125     /**
126      * Returns user attribute mappings between moodle and ldap
127      *
128      * @return array
129      */
130     function db_attributes() {
131         $moodleattributes = array();
132         foreach ($this->userfields as $field) {
133             if (!empty($this->config->{"field_map_$field"})) {
134                 $moodleattributes[$field] = $this->config->{"field_map_$field"};
135             }
136         }
137         $moodleattributes['username'] = $this->config->fielduser;
138         return $moodleattributes;
139     }
141     /**
142      * Reads any other information for a user from external database,
143      * then returns it in an array
144      *
145      * @param string $username
146      *
147      * @return array without magic quotes
148      */
149     function get_userinfo($username) {
150         global $CFG;
152         $extusername = textlib::convert($username, 'utf-8', $this->config->extencoding);
154         $authdb = $this->db_init();
156         //Array to map local fieldnames we want, to external fieldnames
157         $selectfields = $this->db_attributes();
159         $result = array();
160         //If at least one field is mapped from external db, get that mapped data:
161         if ($selectfields) {
162             $select = '';
163             foreach ($selectfields as $localname=>$externalname) {
164                 $select .= ", $externalname AS $localname";
165             }
166             $select = 'SELECT ' . substr($select,1);
167             $sql = $select .
168                 " FROM {$this->config->table}" .
169                 " WHERE {$this->config->fielduser} = '".$this->ext_addslashes($extusername)."'";
170             if ($rs = $authdb->Execute($sql)) {
171                 if ( !$rs->EOF ) {
172                     $fields_obj = $rs->FetchObj();
173                     $fields_obj = (object)array_change_key_case((array)$fields_obj , CASE_LOWER);
174                     foreach ($selectfields as $localname=>$externalname) {
175                         $result[$localname] = textlib::convert($fields_obj->{$localname}, $this->config->extencoding, 'utf-8');
176                      }
177                  }
178                  $rs->Close();
179             }
180         }
181         $authdb->Close();
182         return $result;
183     }
185     /**
186      * Change a user's password
187      *
188      * @param  object  $user        User table object
189      * @param  string  $newpassword Plaintext password
190      *
191      * @return bool                  True on success
192      */
193     function user_update_password($user, $newpassword) {
194         global $DB;
196         if ($this->is_internal()) {
197             $puser = $DB->get_record('user', array('id'=>$user->id), '*', MUST_EXIST);
198             if (update_internal_user_password($puser, $newpassword)) {
199                 $user->password = $puser->password;
200                 return true;
201             } else {
202                 return false;
203             }
204         } else {
205             // we should have never been called!
206             return false;
207         }
208     }
210     /**
211      * synchronizes user from external db to moodle user table
212      *
213      * Sync should be done by using idnumber attribute, not username.
214      * You need to pass firstsync parameter to function to fill in
215      * idnumbers if they don't exists in moodle user table.
216      *
217      * Syncing users removes (disables) users that don't exists anymore in external db.
218      * Creates new users and updates coursecreator status of users.
219      *
220      * This implementation is simpler but less scalable than the one found in the LDAP module.
221      *
222      * @param bool $do_updates  Optional: set to true to force an update of existing accounts
223      * @param bool $verbose
224      * @return int 0 means success, 1 means failure
225      */
226     function sync_users($do_updates=false, $verbose=false) {
227         global $CFG, $DB;
229         // list external users
230         $userlist = $this->get_userlist();
232         // delete obsolete internal users
233         if (!empty($this->config->removeuser)) {
235             // find obsolete users
236             if (count($userlist)) {
237                 list($notin_sql, $params) = $DB->get_in_or_equal($userlist, SQL_PARAMS_NAMED, 'u', false);
238                 $params['authtype'] = $this->authtype;
239                 $sql = "SELECT u.*
240                           FROM {user} u
241                          WHERE u.auth=:authtype AND u.deleted=0 AND u.username $notin_sql";
242             } else {
243                 $sql = "SELECT u.*
244                           FROM {user} u
245                          WHERE u.auth=:authtype AND u.deleted=0";
246                 $params = array();
247                 $params['authtype'] = $this->authtype;
248             }
249             $remove_users = $DB->get_records_sql($sql, $params);
251             if (!empty($remove_users)) {
252                 if ($verbose) {
253                     mtrace(print_string('auth_dbuserstoremove','auth_db', count($remove_users)));
254                 }
256                 foreach ($remove_users as $user) {
257                     if ($this->config->removeuser == AUTH_REMOVEUSER_FULLDELETE) {
258                         delete_user($user);
259                         if ($verbose) {
260                             mtrace("\t".get_string('auth_dbdeleteuser', 'auth_db', array('name'=>$user->username, 'id'=>$user->id)));
261                         }
262                     } else if ($this->config->removeuser == AUTH_REMOVEUSER_SUSPEND) {
263                         $updateuser = new stdClass();
264                         $updateuser->id   = $user->id;
265                         $updateuser->auth = 'nologin';
266                         $updateuser->timemodified = time();
267                         $DB->update_record('user', $updateuser);
268                         if ($verbose) {
269                             mtrace("\t".get_string('auth_dbsuspenduser', 'auth_db', array('name'=>$user->username, 'id'=>$user->id)));
270                         }
271                     }
272                 }
273             }
274             unset($remove_users); // free mem!
275         }
277         if (!count($userlist)) {
278             // exit right here
279             // nothing else to do
280             return 0;
281         }
283         ///
284         /// update existing accounts
285         ///
286         if ($do_updates) {
287             // narrow down what fields we need to update
288             $all_keys = array_keys(get_object_vars($this->config));
289             $updatekeys = array();
290             foreach ($all_keys as $key) {
291                 if (preg_match('/^field_updatelocal_(.+)$/',$key, $match)) {
292                     if ($this->config->{$key} === 'onlogin') {
293                         array_push($updatekeys, $match[1]); // the actual key name
294                     }
295                 }
296             }
297             // print_r($all_keys); print_r($updatekeys);
298             unset($all_keys); unset($key);
300             // only go ahead if we actually
301             // have fields to update locally
302             if (!empty($updatekeys)) {
303                 list($in_sql, $params) = $DB->get_in_or_equal($userlist, SQL_PARAMS_NAMED, 'u', true);
304                 $params['authtype'] = $this->authtype;
305                 $sql = "SELECT u.id, u.username
306                           FROM {user} u
307                          WHERE u.auth=:authtype AND u.deleted=0 AND u.username {$in_sql}";
308                 if ($update_users = $DB->get_records_sql($sql, $params)) {
309                     if ($verbose) {
310                         mtrace("User entries to update: ".count($update_users));
311                     }
313                     foreach ($update_users as $user) {
314                         if ($this->update_user_record($user->username, $updatekeys)) {
315                             if ($verbose) {
316                                 mtrace("\t".get_string('auth_dbupdatinguser', 'auth_db', array('name'=>$user->username, 'id'=>$user->id)));
317                             }
318                         } else {
319                             if ($verbose) {
320                                 mtrace("\t".get_string('auth_dbupdatinguser', 'auth_db', array('name'=>$user->username, 'id'=>$user->id))." - ".get_string('skipped'));
321                             }
322                         }
323                     }
324                     unset($update_users); // free memory
325                 }
326             }
327         }
330         ///
331         /// create missing accounts
332         ///
333         // NOTE: this is very memory intensive
334         // and generally inefficient
335         $sql = 'SELECT u.id, u.username
336                 FROM {user} u
337                 WHERE u.auth=\'' . $this->authtype . '\' AND u.deleted=\'0\'';
339         $users = $DB->get_records_sql($sql);
341         // simplify down to usernames
342         $usernames = array();
343         if (!empty($users)) {
344             foreach ($users as $user) {
345                 array_push($usernames, $user->username);
346             }
347             unset($users);
348         }
350         $add_users = array_diff($userlist, $usernames);
351         unset($usernames);
353         if (!empty($add_users)) {
354             if ($verbose) {
355                 mtrace(get_string('auth_dbuserstoadd','auth_db',count($add_users)));
356             }
357             // Do not use transactions around this foreach, we want to skip problematic users, not revert everything.
358             foreach($add_users as $user) {
359                 $username = $user;
360                 $user = $this->get_userinfo_asobj($user);
362                 // prep a few params
363                 $user->username   = $username;
364                 $user->confirmed  = 1;
365                 $user->auth       = $this->authtype;
366                 $user->mnethostid = $CFG->mnet_localhost_id;
367                 if (empty($user->lang)) {
368                     $user->lang = $CFG->lang;
369                 }
371                 // maybe the user has been deleted before
372                 if ($old_user = $DB->get_record('user', array('username'=>$user->username, 'deleted'=>1, 'mnethostid'=>$user->mnethostid, 'auth'=>$user->auth))) {
373                     // note: this undeleting is deprecated and will be eliminated soon
374                     $DB->set_field('user', 'deleted', 0, array('id'=>$old_user->id));
375                     $DB->set_field('user', 'timemodified', time(), array('id'=>$old_user->id));
376                     if ($verbose) {
377                         mtrace("\t".get_string('auth_dbreviveduser', 'auth_db', array('name'=>$old_user->username, 'id'=>$old_user->id)));
378                     }
380                 } else {
381                     $user->timecreated = time();
382                     $user->timemodified = $user->timecreated;
383                     try {
384                         $id = $DB->insert_record('user', $user); // it is truly a new user
385                         if ($verbose) {
386                             mtrace("\t".get_string('auth_dbinsertuser', 'auth_db', array('name'=>$user->username, 'id'=>$id)));
387                         }
388                     } catch (moodle_exception $e) {
389                         if ($verbose) {
390                             mtrace("\t".get_string('auth_dbinsertusererror', 'auth_db', $user->username));
391                         }
392                         continue;
393                     }
394                     // if relevant, tag for password generation
395                     if ($this->is_internal()) {
396                         set_user_preference('auth_forcepasswordchange', 1, $id);
397                         set_user_preference('create_password',          1, $id);
398                     }
399                 }
400             }
401             unset($add_users); // free mem
402         }
403         return 0;
404     }
406     function user_exists($username) {
408     /// Init result value
409         $result = false;
411         $extusername = textlib::convert($username, 'utf-8', $this->config->extencoding);
413         $authdb = $this->db_init();
415         $rs = $authdb->Execute("SELECT * FROM {$this->config->table}
416                                      WHERE {$this->config->fielduser} = '".$this->ext_addslashes($extusername)."' ");
418         if (!$rs) {
419             print_error('auth_dbcantconnect','auth_db');
420         } else if (!$rs->EOF) {
421             // user exists externally
422             $result = true;
423         }
425         $authdb->Close();
426         return $result;
427     }
430     function get_userlist() {
432     /// Init result value
433         $result = array();
435         $authdb = $this->db_init();
437         // fetch userlist
438         $rs = $authdb->Execute("SELECT {$this->config->fielduser} AS username
439                                 FROM   {$this->config->table} ");
441         if (!$rs) {
442             print_error('auth_dbcantconnect','auth_db');
443         } else if (!$rs->EOF) {
444             while ($rec = $rs->FetchRow()) {
445                 $rec = (object)array_change_key_case((array)$rec , CASE_LOWER);
446                 array_push($result, $rec->username);
447             }
448         }
450         $authdb->Close();
451         return $result;
452     }
454     /**
455      * reads user information from DB and return it in an object
456      *
457      * @param string $username username (with system magic quotes)
458      * @return array
459      */
460     function get_userinfo_asobj($username) {
461         $user_array = truncate_userinfo($this->get_userinfo($username));
462         $user = new stdClass();
463         foreach($user_array as $key=>$value) {
464             $user->{$key} = $value;
465         }
466         return $user;
467     }
469     /**
470      * will update a local user record from an external source.
471      * is a lighter version of the one in moodlelib -- won't do
472      * expensive ops such as enrolment
473      *
474      * If you don't pass $updatekeys, there is a performance hit and
475      * values removed from DB won't be removed from moodle.
476      *
477      * @param string $username username
478      * @param bool $updatekeys
479      * @return stdClass
480      */
481     function update_user_record($username, $updatekeys=false) {
482         global $CFG, $DB;
484         //just in case check text case
485         $username = trim(textlib::strtolower($username));
487         // get the current user record
488         $user = $DB->get_record('user', array('username'=>$username, 'mnethostid'=>$CFG->mnet_localhost_id));
489         if (empty($user)) { // trouble
490             error_log("Cannot update non-existent user: $username");
491             print_error('auth_dbusernotexist','auth_db',$username);
492             die;
493         }
495         // Ensure userid is not overwritten
496         $userid = $user->id;
497         $updated = false;
499         if ($newinfo = $this->get_userinfo($username)) {
500             $newinfo = truncate_userinfo($newinfo);
502             if (empty($updatekeys)) { // all keys? this does not support removing values
503                 $updatekeys = array_keys($newinfo);
504             }
506             foreach ($updatekeys as $key) {
507                 if (isset($newinfo[$key])) {
508                     $value = $newinfo[$key];
509                 } else {
510                     $value = '';
511                 }
513                 if (!empty($this->config->{'field_updatelocal_' . $key})) {
514                     if (isset($user->{$key}) and $user->{$key} != $value) { // only update if it's changed
515                         $DB->set_field('user', $key, $value, array('id'=>$userid));
516                         $updated = true;
517                     }
518                 }
519             }
520         }
521         if ($updated) {
522             $DB->set_field('user', 'timemodified', time(), array('id'=>$userid));
523         }
524         return $DB->get_record('user', array('id'=>$userid, 'deleted'=>0));
525     }
527     /**
528      * Called when the user record is updated.
529      * Modifies user in external database. It takes olduser (before changes) and newuser (after changes)
530      * compares information saved modified information to external db.
531      *
532      * @param mixed $olduser     Userobject before modifications
533      * @param mixed $newuser     Userobject new modified userobject
534      * @return boolean result
535      *
536      */
537     function user_update($olduser, $newuser) {
538         if (isset($olduser->username) and isset($newuser->username) and $olduser->username != $newuser->username) {
539             error_log("ERROR:User renaming not allowed in ext db");
540             return false;
541         }
543         if (isset($olduser->auth) and $olduser->auth != $this->authtype) {
544             return true; // just change auth and skip update
545         }
547         $curruser = $this->get_userinfo($olduser->username);
548         if (empty($curruser)) {
549             error_log("ERROR:User $olduser->username found in ext db");
550             return false;
551         }
553         $extusername = textlib::convert($olduser->username, 'utf-8', $this->config->extencoding);
555         $authdb = $this->db_init();
557         $update = array();
558         foreach($curruser as $key=>$value) {
559             if ($key == 'username') {
560                 continue; // skip this
561             }
562             if (empty($this->config->{"field_updateremote_$key"})) {
563                 continue; // remote update not requested
564             }
565             if (!isset($newuser->$key)) {
566                 continue;
567             }
568             $nuvalue = $newuser->$key;
569             if ($nuvalue != $value) {
570                 $update[] = $this->config->{"field_map_$key"}."='".$this->ext_addslashes(textlib::convert($nuvalue, 'utf-8', $this->config->extencoding))."'";
571             }
572         }
573         if (!empty($update)) {
574             $authdb->Execute("UPDATE {$this->config->table}
575                                  SET ".implode(',', $update)."
576                                WHERE {$this->config->fielduser}='".$this->ext_addslashes($extusername)."'");
577         }
578         $authdb->Close();
579         return true;
580     }
582     /**
583      * A chance to validate form data, and last chance to
584      * do stuff before it is inserted in config_plugin
585      *
586      * @param stfdClass config form
587      * @param array $error errors
588      * @return void
589      */
590      function validate_form($form, &$err) {
591         if ($form->passtype === 'internal') {
592             $this->config->changepasswordurl = '';
593             set_config('changepasswordurl', '', 'auth/db');
594         }
595     }
597     function prevent_local_passwords() {
598         return !$this->is_internal();
599     }
601     /**
602      * Returns true if this authentication plugin is "internal".
603      *
604      * Internal plugins use password hashes from Moodle user table for authentication.
605      *
606      * @return bool
607      */
608     function is_internal() {
609         if (!isset($this->config->passtype)) {
610             return true;
611         }
612         return ($this->config->passtype === 'internal');
613     }
615     /**
616      * Indicates if moodle should automatically update internal user
617      * records with data from external sources using the information
618      * from auth_plugin_base::get_userinfo().
619      *
620      * @return bool true means automatically copy data from ext to user table
621      */
622     function is_synchronised_with_external() {
623         return true;
624     }
626     /**
627      * Returns true if this authentication plugin can change the user's
628      * password.
629      *
630      * @return bool
631      */
632     function can_change_password() {
633         return ($this->is_internal() or !empty($this->config->changepasswordurl));
634     }
636     /**
637      * Returns the URL for changing the user's pw, or empty if the default can
638      * be used.
639      *
640      * @return moodle_url
641      */
642     function change_password_url() {
643         if ($this->is_internal()) {
644             // standard form
645             return null;
646         } else {
647             // use admin defined custom url
648             return new moodle_url($this->config->changepasswordurl);
649         }
650     }
652     /**
653      * Returns true if plugin allows resetting of internal password.
654      *
655      * @return bool
656      */
657     function can_reset_password() {
658         return $this->is_internal();
659     }
661     /**
662      * Prints a form for configuring this authentication plugin.
663      *
664      * This function is called from admin/auth.php, and outputs a full page with
665      * a form for configuring this plugin.
666      *
667      * @param stdClass $config
668      * @param array $err errors
669      * @param array $user_fields
670      * @return void
671      */
672     function config_form($config, $err, $user_fields) {
673         include 'config.html';
674     }
676     /**
677      * Processes and stores configuration data for this authentication plugin.
678      * @param srdClass $config
679      * @return bool always true or exception
680      */
681     function process_config($config) {
682         // set to defaults if undefined
683         if (!isset($config->host)) {
684             $config->host = 'localhost';
685         }
686         if (!isset($config->type)) {
687             $config->type = 'mysql';
688         }
689         if (!isset($config->sybasequoting)) {
690             $config->sybasequoting = 0;
691         }
692         if (!isset($config->name)) {
693             $config->name = '';
694         }
695         if (!isset($config->user)) {
696             $config->user = '';
697         }
698         if (!isset($config->pass)) {
699             $config->pass = '';
700         }
701         if (!isset($config->table)) {
702             $config->table = '';
703         }
704         if (!isset($config->fielduser)) {
705             $config->fielduser = '';
706         }
707         if (!isset($config->fieldpass)) {
708             $config->fieldpass = '';
709         }
710         if (!isset($config->passtype)) {
711             $config->passtype = 'plaintext';
712         }
713         if (!isset($config->extencoding)) {
714             $config->extencoding = 'utf-8';
715         }
716         if (!isset($config->setupsql)) {
717             $config->setupsql = '';
718         }
719         if (!isset($config->debugauthdb)) {
720             $config->debugauthdb = 0;
721         }
722         if (!isset($config->removeuser)) {
723             $config->removeuser = AUTH_REMOVEUSER_KEEP;
724         }
725         if (!isset($config->changepasswordurl)) {
726             $config->changepasswordurl = '';
727         }
729         // save settings
730         set_config('host',          $config->host,          'auth/db');
731         set_config('type',          $config->type,          'auth/db');
732         set_config('sybasequoting', $config->sybasequoting, 'auth/db');
733         set_config('name',          $config->name,          'auth/db');
734         set_config('user',          $config->user,          'auth/db');
735         set_config('pass',          $config->pass,          'auth/db');
736         set_config('table',         $config->table,         'auth/db');
737         set_config('fielduser',     $config->fielduser,     'auth/db');
738         set_config('fieldpass',     $config->fieldpass,     'auth/db');
739         set_config('passtype',      $config->passtype,      'auth/db');
740         set_config('extencoding',   trim($config->extencoding), 'auth/db');
741         set_config('setupsql',      trim($config->setupsql),'auth/db');
742         set_config('debugauthdb',   $config->debugauthdb,   'auth/db');
743         set_config('removeuser',    $config->removeuser,    'auth/db');
744         set_config('changepasswordurl', trim($config->changepasswordurl), 'auth/db');
746         return true;
747     }
749     function ext_addslashes($text) {
750         // using custom made function for now
751         if (empty($this->config->sybasequoting)) {
752             $text = str_replace('\\', '\\\\', $text);
753             $text = str_replace(array('\'', '"', "\0"), array('\\\'', '\\"', '\\0'), $text);
754         } else {
755             $text = str_replace("'", "''", $text);
756         }
757         return $text;
758     }