MDL-66173 login: Added form injection, validation and post action hooks
[moodle.git] / login / lib.php
1 <?php
2 // This file is part of Moodle - http://moodle.org/
3 //
4 // Moodle is free software: you can redistribute it and/or modify
5 // it under the terms of the GNU General Public License as published by
6 // the Free Software Foundation, either version 3 of the License, or
7 // (at your option) any later version.
8 //
9 // Moodle is distributed in the hope that it will be useful,
10 // but WITHOUT ANY WARRANTY; without even the implied warranty of
11 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 // GNU General Public License for more details.
13 //
14 // You should have received a copy of the GNU General Public License
15 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
17 /**
18  *
19  * Login library file of login/password related Moodle functions.
20  *
21  * @package    core
22  * @subpackage lib
23  * @copyright  Catalyst IT
24  * @copyright  Peter Bulmer
25  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26  */
27 define('PWRESET_STATUS_NOEMAILSENT', 1);
28 define('PWRESET_STATUS_TOKENSENT', 2);
29 define('PWRESET_STATUS_OTHEREMAILSENT', 3);
30 define('PWRESET_STATUS_ALREADYSENT', 4);
32 /**
33  *  Processes a user's request to set a new password in the event they forgot the old one.
34  *  If no user identifier has been supplied, it displays a form where they can submit their identifier.
35  *  Where they have supplied identifier, the function will check their status, and send email as appropriate.
36  */
37 function core_login_process_password_reset_request() {
38     global $OUTPUT, $PAGE;
39     $mform = new login_forgot_password_form();
41     if ($mform->is_cancelled()) {
42         redirect(get_login_url());
44     } else if ($data = $mform->get_data()) {
46         $username = $email = '';
47         if (!empty($data->username)) {
48             $username = $data->username;
49         } else {
50             $email = $data->email;
51         }
52         list($status, $notice, $url) = core_login_process_password_reset($username, $email);
54         // Plugins can perform post forgot password actions once data has been validated.
55         core_login_post_forgot_password_requests($data);
57         // Any email has now been sent.
58         // Next display results to requesting user if settings permit.
59         echo $OUTPUT->header();
60         notice($notice, $url);
61         die; // Never reached.
62     }
64     // DISPLAY FORM.
66     echo $OUTPUT->header();
67     echo $OUTPUT->box(get_string('passwordforgotteninstructions2'), 'generalbox boxwidthnormal boxaligncenter');
68     $mform->display();
70     echo $OUTPUT->footer();
71 }
73 /**
74  * Process the password reset for the given user (via username or email).
75  *
76  * @param  string $username the user name
77  * @param  string $email    the user email
78  * @return array an array containing fields indicating the reset status, a info notice and redirect URL.
79  * @since  Moodle 3.4
80  */
81 function core_login_process_password_reset($username, $email) {
82     global $CFG, $DB;
84     if (empty($username) && empty($email)) {
85         print_error('cannotmailconfirm');
86     }
88     // Next find the user account in the database which the requesting user claims to own.
89     if (!empty($username)) {
90         // Username has been specified - load the user record based on that.
91         $username = core_text::strtolower($username); // Mimic the login page process.
92         $userparams = array('username' => $username, 'mnethostid' => $CFG->mnet_localhost_id, 'deleted' => 0, 'suspended' => 0);
93         $user = $DB->get_record('user', $userparams);
94     } else {
95         // Try to load the user record based on email address.
96         // this is tricky because
97         // 1/ the email is not guaranteed to be unique - TODO: send email with all usernames to select the account for pw reset
98         // 2/ mailbox may be case sensitive, the email domain is case insensitive - let's pretend it is all case-insensitive.
100         $select = $DB->sql_like('email', ':email', false, true, false, '|') .
101                 " AND mnethostid = :mnethostid AND deleted=0 AND suspended=0";
102         $params = array('email' => $DB->sql_like_escape($email, '|'), 'mnethostid' => $CFG->mnet_localhost_id);
103         $user = $DB->get_record_select('user', $select, $params, '*', IGNORE_MULTIPLE);
104     }
106     // Target user details have now been identified, or we know that there is no such account.
107     // Send email address to account's email address if appropriate.
108     $pwresetstatus = PWRESET_STATUS_NOEMAILSENT;
109     if ($user and !empty($user->confirmed)) {
110         $systemcontext = context_system::instance();
112         $userauth = get_auth_plugin($user->auth);
113         if (!$userauth->can_reset_password() or !is_enabled_auth($user->auth)
114           or !has_capability('moodle/user:changeownpassword', $systemcontext, $user->id)) {
115             if (send_password_change_info($user)) {
116                 $pwresetstatus = PWRESET_STATUS_OTHEREMAILSENT;
117             } else {
118                 print_error('cannotmailconfirm');
119             }
120         } else {
121             // The account the requesting user claims to be is entitled to change their password.
122             // Next, check if they have an existing password reset in progress.
123             $resetinprogress = $DB->get_record('user_password_resets', array('userid' => $user->id));
124             if (empty($resetinprogress)) {
125                 // Completely new reset request - common case.
126                 $resetrecord = core_login_generate_password_reset($user);
127                 $sendemail = true;
128             } else if ($resetinprogress->timerequested < (time() - $CFG->pwresettime)) {
129                 // Preexisting, but expired request - delete old record & create new one.
130                 // Uncommon case - expired requests are cleaned up by cron.
131                 $DB->delete_records('user_password_resets', array('id' => $resetinprogress->id));
132                 $resetrecord = core_login_generate_password_reset($user);
133                 $sendemail = true;
134             } else if (empty($resetinprogress->timererequested)) {
135                 // Preexisting, valid request. This is the first time user has re-requested the reset.
136                 // Re-sending the same email once can actually help in certain circumstances
137                 // eg by reducing the delay caused by greylisting.
138                 $resetinprogress->timererequested = time();
139                 $DB->update_record('user_password_resets', $resetinprogress);
140                 $resetrecord = $resetinprogress;
141                 $sendemail = true;
142             } else {
143                 // Preexisting, valid request. User has already re-requested email.
144                 $pwresetstatus = PWRESET_STATUS_ALREADYSENT;
145                 $sendemail = false;
146             }
148             if ($sendemail) {
149                 $sendresult = send_password_change_confirmation_email($user, $resetrecord);
150                 if ($sendresult) {
151                     $pwresetstatus = PWRESET_STATUS_TOKENSENT;
152                 } else {
153                     print_error('cannotmailconfirm');
154                 }
155             }
156         }
157     }
159     $url = $CFG->wwwroot.'/index.php';
160     if (!empty($CFG->protectusernames)) {
161         // Neither confirm, nor deny existance of any username or email address in database.
162         // Print general (non-commital) message.
163         $status = 'emailpasswordconfirmmaybesent';
164         $notice = get_string($status);
165     } else if (empty($user)) {
166         // Protect usernames is off, and we couldn't find the user with details specified.
167         // Print failure advice.
168         $status = 'emailpasswordconfirmnotsent';
169         $notice = get_string($status);
170         $url = $CFG->wwwroot.'/forgot_password.php';
171     } else if (empty($user->email)) {
172         // User doesn't have an email set - can't send a password change confimation email.
173         $status = 'emailpasswordconfirmnoemail';
174         $notice = get_string($status);
175     } else if ($pwresetstatus == PWRESET_STATUS_ALREADYSENT) {
176         // User found, protectusernames is off, but user has already (re) requested a reset.
177         // Don't send a 3rd reset email.
178         $status = 'emailalreadysent';
179         $notice = get_string($status);
180     } else if ($pwresetstatus == PWRESET_STATUS_NOEMAILSENT) {
181         // User found, protectusernames is off, but user is not confirmed.
182         // Pretend we sent them an email.
183         // This is a big usability problem - need to tell users why we didn't send them an email.
184         // Obfuscate email address to protect privacy.
185         $protectedemail = preg_replace('/([^@]*)@(.*)/', '******@$2', $user->email);
186         $status = 'emailpasswordconfirmsent';
187         $notice = get_string($status, '', $protectedemail);
188     } else {
189         // Confirm email sent. (Obfuscate email address to protect privacy).
190         $protectedemail = preg_replace('/([^@]*)@(.*)/', '******@$2', $user->email);
191         // This is a small usability problem - may be obfuscating the email address which the user has just supplied.
192         $status = 'emailresetconfirmsent';
193         $notice = get_string($status, '', $protectedemail);
194     }
195     return array($status, $notice, $url);
198 /**
199  * This function processes a user's submitted token to validate the request to set a new password.
200  * If the user's token is validated, they are prompted to set a new password.
201  * @param string $token the one-use identifier which should verify the password reset request as being valid.
202  * @return void
203  */
204 function core_login_process_password_set($token) {
205     global $DB, $CFG, $OUTPUT, $PAGE, $SESSION;
206     require_once($CFG->dirroot.'/user/lib.php');
208     $pwresettime = isset($CFG->pwresettime) ? $CFG->pwresettime : 1800;
209     $sql = "SELECT u.*, upr.token, upr.timerequested, upr.id as tokenid
210               FROM {user} u
211               JOIN {user_password_resets} upr ON upr.userid = u.id
212              WHERE upr.token = ?";
213     $user = $DB->get_record_sql($sql, array($token));
215     $forgotpasswordurl = "{$CFG->wwwroot}/login/forgot_password.php";
216     if (empty($user) or ($user->timerequested < (time() - $pwresettime - DAYSECS))) {
217         // There is no valid reset request record - not even a recently expired one.
218         // (suspicious)
219         // Direct the user to the forgot password page to request a password reset.
220         echo $OUTPUT->header();
221         notice(get_string('noresetrecord'), $forgotpasswordurl);
222         die; // Never reached.
223     }
224     if ($user->timerequested < (time() - $pwresettime)) {
225         // There is a reset record, but it's expired.
226         // Direct the user to the forgot password page to request a password reset.
227         $pwresetmins = floor($pwresettime / MINSECS);
228         echo $OUTPUT->header();
229         notice(get_string('resetrecordexpired', '', $pwresetmins), $forgotpasswordurl);
230         die; // Never reached.
231     }
233     if ($user->auth === 'nologin' or !is_enabled_auth($user->auth)) {
234         // Bad luck - user is not able to login, do not let them set password.
235         echo $OUTPUT->header();
236         print_error('forgotteninvalidurl');
237         die; // Never reached.
238     }
240     // Check this isn't guest user.
241     if (isguestuser($user)) {
242         print_error('cannotresetguestpwd');
243     }
245     // Token is correct, and unexpired.
246     $mform = new login_set_password_form(null, $user);
247     $data = $mform->get_data();
248     if (empty($data)) {
249         // User hasn't submitted form, they got here directly from email link.
250         // Next, display the form.
251         $setdata = new stdClass();
252         $setdata->username = $user->username;
253         $setdata->username2 = $user->username;
254         $setdata->token = $user->token;
255         $mform->set_data($setdata);
256         echo $OUTPUT->header();
257         echo $OUTPUT->box(get_string('setpasswordinstructions'), 'generalbox boxwidthnormal boxaligncenter');
258         $mform->display();
259         echo $OUTPUT->footer();
260         return;
261     } else {
262         // User has submitted form.
263         // Delete this token so it can't be used again.
264         $DB->delete_records('user_password_resets', array('id' => $user->tokenid));
265         $userauth = get_auth_plugin($user->auth);
266         if (!$userauth->user_update_password($user, $data->password)) {
267             print_error('errorpasswordupdate', 'auth');
268         }
269         user_add_password_history($user->id, $data->password);
270         if (!empty($CFG->passwordchangelogout)) {
271             \core\session\manager::kill_user_sessions($user->id, session_id());
272         }
273         // Reset login lockout (if present) before a new password is set.
274         login_unlock_account($user);
275         // Clear any requirement to change passwords.
276         unset_user_preference('auth_forcepasswordchange', $user);
277         unset_user_preference('create_password', $user);
279         if (!empty($user->lang)) {
280             // Unset previous session language - use user preference instead.
281             unset($SESSION->lang);
282         }
283         complete_user_login($user); // Triggers the login event.
285         \core\session\manager::apply_concurrent_login_limit($user->id, session_id());
287         $urltogo = core_login_get_return_url();
288         unset($SESSION->wantsurl);
290         // Plugins can perform post set password actions once data has been validated.
291         core_login_post_set_password_requests($data, $user);
293         redirect($urltogo, get_string('passwordset'), 1);
294     }
297 /** Create a new record in the database to track a new password set request for user.
298  * @param object $user the user record, the requester would like a new password set for.
299  * @return record created.
300  */
301 function core_login_generate_password_reset ($user) {
302     global $DB;
303     $resetrecord = new stdClass();
304     $resetrecord->timerequested = time();
305     $resetrecord->userid = $user->id;
306     $resetrecord->token = random_string(32);
307     $resetrecord->id = $DB->insert_record('user_password_resets', $resetrecord);
308     return $resetrecord;
311 /**  Determine where a user should be redirected after they have been logged in.
312  * @return string url the user should be redirected to.
313  */
314 function core_login_get_return_url() {
315     global $CFG, $SESSION, $USER;
316     // Prepare redirection.
317     if (user_not_fully_set_up($USER, true)) {
318         $urltogo = $CFG->wwwroot.'/user/edit.php';
319         // We don't delete $SESSION->wantsurl yet, so we get there later.
321     } else if (isset($SESSION->wantsurl) and (strpos($SESSION->wantsurl, $CFG->wwwroot) === 0
322             or strpos($SESSION->wantsurl, str_replace('http://', 'https://', $CFG->wwwroot)) === 0)) {
323         $urltogo = $SESSION->wantsurl;    // Because it's an address in this site.
324         unset($SESSION->wantsurl);
325     } else {
326         // No wantsurl stored or external - go to homepage.
327         $urltogo = $CFG->wwwroot.'/';
328         unset($SESSION->wantsurl);
329     }
331     // If the url to go to is the same as the site page, check for default homepage.
332     if ($urltogo == ($CFG->wwwroot . '/')) {
333         $homepage = get_home_page();
334         // Go to my-moodle page instead of site homepage if defaulthomepage set to homepage_my.
335         if ($homepage == HOMEPAGE_MY && !is_siteadmin() && !isguestuser()) {
336             if ($urltogo == $CFG->wwwroot or $urltogo == $CFG->wwwroot.'/' or $urltogo == $CFG->wwwroot.'/index.php') {
337                 $urltogo = $CFG->wwwroot.'/my/';
338             }
339         }
340     }
341     return $urltogo;
344 /**
345  * Validates the forgot password form data.
346  *
347  * This is used by the forgot_password_form and by the core_auth_request_password_rest WS.
348  * @param  array $data array containing the data to be validated (email and username)
349  * @return array array of errors compatible with mform
350  * @since  Moodle 3.4
351  */
352 function core_login_validate_forgot_password_data($data) {
353     global $CFG, $DB;
355     $errors = array();
357     if ((!empty($data['username']) and !empty($data['email'])) or (empty($data['username']) and empty($data['email']))) {
358         $errors['username'] = get_string('usernameoremail');
359         $errors['email']    = get_string('usernameoremail');
361     } else if (!empty($data['email'])) {
362         if (!validate_email($data['email'])) {
363             $errors['email'] = get_string('invalidemail');
365         } else {
366             try {
367                 $user = get_complete_user_data('email', $data['email'], null, true);
368                 if (empty($user->confirmed)) {
369                     send_confirmation_email($user);
370                     $errors['email'] = get_string('confirmednot');
371                 }
372             } catch (dml_missing_record_exception $missingexception) {
373                 // User not found. Show error when $CFG->protectusernames is turned off.
374                 if (empty($CFG->protectusernames)) {
375                     $errors['email'] = get_string('emailnotfound');
376                 }
377             } catch (dml_multiple_records_exception $multipleexception) {
378                 // Multiple records found. Ask the user to enter a username instead.
379                 $errors['email'] = get_string('forgottenduplicate');
380             }
381         }
383     } else {
384         if ($user = get_complete_user_data('username', $data['username'])) {
385             if (empty($user->confirmed)) {
386                 send_confirmation_email($user);
387                 $errors['email'] = get_string('confirmednot');
388             }
389         }
390         if (!$user and empty($CFG->protectusernames)) {
391             $errors['username'] = get_string('usernamenotfound');
392         }
393     }
395     return $errors;
398 /**
399  * Plugins can create pre sign up requests.
400  */
401 function core_login_pre_signup_requests() {
402     $callbacks = get_plugins_with_function('pre_signup_requests');
403     foreach ($callbacks as $type => $plugins) {
404         foreach ($plugins as $plugin => $pluginfunction) {
405             $pluginfunction();
406         }
407     }
410 /**
411  * Plugins can extend forms.
412  */
414  /** Inject form elements into change_password_form.
415   * @param mform $mform the form to inject elements into.
416   * @param stdClass $user the user object to use for context.
417   */
418 function core_login_extend_change_password_form($mform, $user) {
419     $callbacks = get_plugins_with_function('extend_change_password_form');
420     foreach ($callbacks as $type => $plugins) {
421         foreach ($plugins as $plugin => $pluginfunction) {
422             $pluginfunction($mform, $user);
423         }
424     }
427  /** Inject form elements into set_password_form.
428   * @param mform $mform the form to inject elements into.
429   * @param stdClass $user the user object to use for context.
430   */
431 function core_login_extend_set_password_form($mform, $user) {
432     $callbacks = get_plugins_with_function('extend_set_password_form');
433     foreach ($callbacks as $type => $plugins) {
434         foreach ($plugins as $plugin => $pluginfunction) {
435             $pluginfunction($mform, $user);
436         }
437     }
440  /** Inject form elements into forgot_password_form.
441   * @param mform $mform the form to inject elements into.
442   */
443 function core_login_extend_forgot_password_form($mform) {
444     $callbacks = get_plugins_with_function('extend_forgot_password_form');
445     foreach ($callbacks as $type => $plugins) {
446         foreach ($plugins as $plugin => $pluginfunction) {
447             $pluginfunction($mform);
448         }
449     }
452  /** Inject form elements into signup_form.
453   * @param mform $mform the form to inject elements into.
454   */
455 function core_login_extend_signup_form($mform) {
456     $callbacks = get_plugins_with_function('extend_signup_form');
457     foreach ($callbacks as $type => $plugins) {
458         foreach ($plugins as $plugin => $pluginfunction) {
459             $pluginfunction($mform);
460         }
461     }
464 /**
465  * Plugins can add additional validation to forms.
466  */
468 /** Inject validation into change_password_form.
469  * @param array $data the data array from submitted form values.
470  * @param stdClass $user the user object to use for context.
471  * @return array $errors the updated array of errors from validation.
472  */
473 function core_login_validate_extend_change_password_form($data, $user) {
474     $pluginsfunction = get_plugins_with_function('validate_extend_change_password_form');
475     $errors = array();
476     foreach ($pluginsfunction as $plugintype => $plugins) {
477         foreach ($plugins as $pluginfunction) {
478             $pluginerrors = $pluginfunction($data, $user);
479             $errors = array_merge($errors, $pluginerrors);
480         }
481     }
482     return $errors;
485 /** Inject validation into set_password_form.
486  * @param array $data the data array from submitted form values.
487  * @param stdClass $user the user object to use for context.
488  * @return array $errors the updated array of errors from validation.
489  */
490 function core_login_validate_extend_set_password_form($data, $user) {
491     $pluginsfunction = get_plugins_with_function('validate_extend_set_password_form');
492     $errors = array();
493     foreach ($pluginsfunction as $plugintype => $plugins) {
494         foreach ($plugins as $pluginfunction) {
495             $pluginerrors = $pluginfunction($data, $user);
496             $errors = array_merge($errors, $pluginerrors);
497         }
498     }
499     return $errors;
502 /** Inject validation into forgot_password_form.
503  * @param array $data the data array from submitted form values.
504  * @return array $errors the updated array of errors from validation.
505  */
506 function core_login_validate_extend_forgot_password_form($data) {
507     $pluginsfunction = get_plugins_with_function('validate_extend_forgot_password_form');
508     $errors = array();
509     foreach ($pluginsfunction as $plugintype => $plugins) {
510         foreach ($plugins as $pluginfunction) {
511             $pluginerrors = $pluginfunction($data);
512             $errors = array_merge($errors, $pluginerrors);
513         }
514     }
515     return $errors;
518 /** Inject validation into signup_form.
519  * @param array $data the data array from submitted form values.
520  * @return array $errors the updated array of errors from validation.
521  */
522 function core_login_validate_extend_signup_form($data) {
523     $pluginsfunction = get_plugins_with_function('validate_extend_signup_form');
524     $errors = array();
525     foreach ($pluginsfunction as $plugintype => $plugins) {
526         foreach ($plugins as $pluginfunction) {
527             $pluginerrors = $pluginfunction($data);
528             $errors = array_merge($errors, $pluginerrors);
529         }
530     }
531     return $errors;
534 /**
535  * Plugins can perform post submission actions.
536  */
538 /** Post change_password_form submission actions.
539  * @param stdClass $data the data object from the submitted form.
540  */
541 function core_login_post_change_password_requests($data) {
542     $pluginsfunction = get_plugins_with_function('post_change_password_requests');
543     foreach ($pluginsfunction as $plugintype => $plugins) {
544         foreach ($plugins as $pluginfunction) {
545             $pluginfunction($data);
546         }
547     }
550 /** Post set_password_form submission actions.
551  * @param stdClass $data the data object from the submitted form.
552  * @param stdClass $user the user object for set_password context.
553  */
554 function core_login_post_set_password_requests($data, $user) {
555     $pluginsfunction = get_plugins_with_function('post_set_password_requests');
556     foreach ($pluginsfunction as $plugintype => $plugins) {
557         foreach ($plugins as $pluginfunction) {
558             $pluginfunction($data, $user);
559         }
560     }
563 /** Post forgot_password_form submission actions.
564  * @param stdClass $data the data object from the submitted form.
565  */
566 function core_login_post_forgot_password_requests($data) {
567     $pluginsfunction = get_plugins_with_function('post_forgot_password_requests');
568     foreach ($pluginsfunction as $plugintype => $plugins) {
569         foreach ($plugins as $pluginfunction) {
570             $pluginfunction($data);
571         }
572     }
575 /** Post signup_form submission actions.
576  * @param stdClass $data the data object from the submitted form.
577  */
578 function core_login_post_signup_requests($data) {
579     $pluginsfunction = get_plugins_with_function('post_signup_requests');
580     foreach ($pluginsfunction as $plugintype => $plugins) {
581         foreach ($plugins as $pluginfunction) {
582             $pluginfunction($data);
583         }
584     }