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 * Anobody can login with any password.
19 *
20 * @package auth_oauth2
21 * @copyright 2017 Damyon Wiese
22 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
23 */
24
25namespace auth_oauth2;
26
27defined('MOODLE_INTERNAL') || die();
28
29use pix_icon;
30use moodle_url;
31use core_text;
32use context_system;
33use stdClass;
34use core\oauth2\issuer;
35use core\oauth2\client;
36
37require_once($CFG->libdir.'/authlib.php');
38require_once($CFG->dirroot.'/user/lib.php');
39require_once($CFG->dirroot.'/user/profile/lib.php');
40
41/**
42 * Plugin for oauth2 authentication.
43 *
44 * @package auth_oauth2
45 * @copyright 2017 Damyon Wiese
46 * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
47 */
48class auth extends \auth_plugin_base {
49
50    /**
51     * @var stdClass $userinfo The set of user info returned from the oauth handshake
52     */
53    private static $userinfo;
54
55    /**
56     * @var stdClass $userpicture The url to a picture.
57     */
58    private static $userpicture;
59
60    /**
61     * Constructor.
62     */
63    public function __construct() {
64        $this->authtype = 'oauth2';
65        $this->config = get_config('auth_oauth2');
66    }
67
68    /**
69     * Returns true if the username and password work or don't exist and false
70     * if the user exists and the password is wrong.
71     *
72     * @param string $username The username
73     * @param string $password The password
74     * @return bool Authentication success or failure.
75     */
76    public function user_login($username, $password) {
77        $cached = $this->get_static_user_info();
78        if (empty($cached)) {
79            // This means we were called as part of a normal login flow - without using oauth.
80            return false;
81        }
82        $verifyusername = $cached['username'];
83        if ($verifyusername == $username) {
84            return true;
85        }
86        return false;
87    }
88
89    /**
90     * We don't want to allow users setting an internal password.
91     *
92     * @return bool
93     */
94    public function prevent_local_passwords() {
95        return true;
96    }
97
98    /**
99     * Returns true if this authentication plugin is 'internal'.
100     *
101     * @return bool
102     */
103    public function is_internal() {
104        return false;
105    }
106
107    /**
108     * Indicates if moodle should automatically update internal user
109     * records with data from external sources using the information
110     * from auth_plugin_base::get_userinfo().
111     *
112     * @return bool true means automatically copy data from ext to user table
113     */
114    public function is_synchronised_with_external() {
115        return true;
116    }
117
118    /**
119     * Returns true if this authentication plugin can change the user's
120     * password.
121     *
122     * @return bool
123     */
124    public function can_change_password() {
125        return false;
126    }
127
128    /**
129     * Returns the URL for changing the user's pw, or empty if the default can
130     * be used.
131     *
132     * @return moodle_url
133     */
134    public function change_password_url() {
135        return null;
136    }
137
138    /**
139     * Returns true if plugin allows resetting of internal password.
140     *
141     * @return bool
142     */
143    public function can_reset_password() {
144        return false;
145    }
146
147    /**
148     * Returns true if plugin can be manually set.
149     *
150     * @return bool
151     */
152    public function can_be_manually_set() {
153        return true;
154    }
155
156    /**
157     * Return the userinfo from the oauth handshake. Will only be valid
158     * for the logged in user.
159     * @param string $username
160     */
161    public function get_userinfo($username) {
162        $cached = $this->get_static_user_info();
163        if (!empty($cached) && $cached['username'] == $username) {
164            return $cached;
165        }
166        return false;
167    }
168
169    /**
170     * Do some checks on the identity provider before showing it on the login page.
171     * @param core\oauth2\issuer $issuer
172     * @return boolean
173     */
174    private function is_ready_for_login_page(\core\oauth2\issuer $issuer) {
175        return $issuer->get('enabled') &&
176                $issuer->is_configured() &&
177                !empty($issuer->get('showonloginpage'));
178    }
179
180    /**
181     * Return a list of identity providers to display on the login page.
182     *
183     * @param string|moodle_url $wantsurl The requested URL.
184     * @return array List of arrays with keys url, iconurl and name.
185     */
186    public function loginpage_idp_list($wantsurl) {
187        $providers = \core\oauth2\api::get_all_issuers();
188        $result = [];
189        if (empty($wantsurl)) {
190            $wantsurl = '/';
191        }
192        foreach ($providers as $idp) {
193            if ($this->is_ready_for_login_page($idp)) {
194                $params = ['id' => $idp->get('id'), 'wantsurl' => $wantsurl, 'sesskey' => sesskey()];
195                $url = new moodle_url('/auth/oauth2/login.php', $params);
196                $icon = $idp->get('image');
197                $result[] = ['url' => $url, 'iconurl' => $icon, 'name' => $idp->get('name')];
198            }
199        }
200        return $result;
201    }
202
203    /**
204     * Statically cache the user info from the oauth handshake
205     * @param stdClass $userinfo
206     */
207    private function set_static_user_info($userinfo) {
208        self::$userinfo = $userinfo;
209    }
210
211    /**
212     * Get the static cached user info
213     * @return stdClass
214     */
215    private function get_static_user_info() {
216        return self::$userinfo;
217    }
218
219    /**
220     * Statically cache the user picture from the oauth handshake
221     * @param string $userpicture
222     */
223    private function set_static_user_picture($userpicture) {
224        self::$userpicture = $userpicture;
225    }
226
227    /**
228     * Get the static cached user picture
229     * @return string
230     */
231    private function get_static_user_picture() {
232        return self::$userpicture;
233    }
234
235    /**
236     * If this user has no picture - but we got one from oauth - set it.
237     * @param stdClass $user
238     * @return boolean True if the image was updated.
239     */
240    private function update_picture($user) {
241        global $CFG, $DB, $USER;
242
243        require_once($CFG->libdir . '/filelib.php');
244        require_once($CFG->libdir . '/gdlib.php');
245        require_once($CFG->dirroot . '/user/lib.php');
246
247        $fs = get_file_storage();
248        $userid = $user->id;
249        if (!empty($user->picture)) {
250            return false;
251        }
252        if (!empty($CFG->enablegravatar)) {
253            return false;
254        }
255
256        $picture = $this->get_static_user_picture();
257        if (empty($picture)) {
258            return false;
259        }
260
261        $context = \context_user::instance($userid, MUST_EXIST);
262        $fs->delete_area_files($context->id, 'user', 'newicon');
263
264        $filerecord = array(
265            'contextid' => $context->id,
266            'component' => 'user',
267            'filearea' => 'newicon',
268            'itemid' => 0,
269            'filepath' => '/',
270            'filename' => 'image'
271        );
272
273        try {
274            $fs->create_file_from_string($filerecord, $picture);
275        } catch (\file_exception $e) {
276            return get_string($e->errorcode, $e->module, $e->a);
277        }
278
279        $iconfile = $fs->get_area_files($context->id, 'user', 'newicon', false, 'itemid', false);
280
281        // There should only be one.
282        $iconfile = reset($iconfile);
283
284        // Something went wrong while creating temp file - remove the uploaded file.
285        if (!$iconfile = $iconfile->copy_content_to_temp()) {
286            $fs->delete_area_files($context->id, 'user', 'newicon');
287            return false;
288        }
289
290        // Copy file to temporary location and the send it for processing icon.
291        $newpicture = (int) process_new_icon($context, 'user', 'icon', 0, $iconfile);
292        // Delete temporary file.
293        @unlink($iconfile);
294        // Remove uploaded file.
295        $fs->delete_area_files($context->id, 'user', 'newicon');
296        // Set the user's picture.
297        $updateuser = new stdClass();
298        $updateuser->id = $userid;
299        $updateuser->picture = $newpicture;
300        $USER->picture = $newpicture;
301        user_update_user($updateuser);
302        return true;
303    }
304
305    /**
306     * Update user data according to data sent by authorization server.
307     *
308     * @param array $externaldata data from authorization server
309     * @param stdClass $userdata Current data of the user to be updated
310     * @return stdClass The updated user record, or the existing one if there's nothing to be updated.
311     */
312    private function update_user(array $externaldata, $userdata) {
313        $user = (object) [
314            'id' => $userdata->id,
315        ];
316
317        // We can only update if the default authentication type of the user is set to OAuth2 as well. Otherwise, we might mess
318        // up the user data of other users that use different authentication mechanisms (e.g. linked logins).
319        if ($userdata->auth !== $this->authtype) {
320            return $userdata;
321        }
322
323        // Go through each field from the external data.
324        foreach ($externaldata as $fieldname => $value) {
325            if (!in_array($fieldname, $this->userfields)) {
326                // Skip if this field doesn't belong to the list of fields that can be synced with the OAuth2 issuer.
327                continue;
328            }
329
330            if (!property_exists($userdata, $fieldname)) {
331                // Just in case this field is on the list, but not part of the user data. This shouldn't happen though.
332                continue;
333            }
334
335            // Get the old value.
336            $oldvalue = (string)$userdata->$fieldname;
337
338            // Get the lock configuration of the field.
339            $lockvalue = $this->config->{'field_lock_' . $fieldname};
340
341            // We should update fields that meet the following criteria:
342            // - Lock value set to 'unlocked'; or 'unlockedifempty', given the current value is empty.
343            // - The value has changed.
344            if ($lockvalue === 'unlocked' || ($lockvalue === 'unlockedifempty' && empty($oldvalue))) {
345                $value = (string)$value;
346                if ($oldvalue !== $value) {
347                    $user->$fieldname = $value;
348                }
349            }
350        }
351        // Update the user data.
352        user_update_user($user, false);
353
354        // Save user profile data.
355        profile_save_data($user);
356
357        // Refresh user for $USER variable.
358        return get_complete_user_data('id', $user->id);
359    }
360
361    /**
362     * Confirm the new user as registered.
363     *
364     * @param string $username
365     * @param string $confirmsecret
366     */
367    public function user_confirm($username, $confirmsecret) {
368        global $DB;
369        $user = get_complete_user_data('username', $username);
370
371        if (!empty($user)) {
372            if ($user->auth != $this->authtype) {
373                return AUTH_CONFIRM_ERROR;
374
375            } else if ($user->secret === $confirmsecret && $user->confirmed) {
376                return AUTH_CONFIRM_ALREADY;
377
378            } else if ($user->secret === $confirmsecret) {   // They have provided the secret key to get in.
379                $DB->set_field("user", "confirmed", 1, array("id" => $user->id));
380                return AUTH_CONFIRM_OK;
381            }
382        } else {
383            return AUTH_CONFIRM_ERROR;
384        }
385    }
386
387    /**
388     * Print a page showing that a confirm email was sent with instructions.
389     *
390     * @param string $title
391     * @param string $message
392     */
393    public function print_confirm_required($title, $message) {
394        global $PAGE, $OUTPUT, $CFG;
395
396        $PAGE->navbar->add($title);
397        $PAGE->set_title($title);
398        $PAGE->set_heading($PAGE->course->fullname);
399        echo $OUTPUT->header();
400        notice($message, "$CFG->wwwroot/index.php");
401    }
402
403    /**
404     * Complete the login process after oauth handshake is complete.
405     * @param \core\oauth2\client $client
406     * @param string $redirecturl
407     * @return void Either redirects or throws an exception
408     */
409    public function complete_login(client $client, $redirecturl) {
410        global $CFG, $SESSION, $PAGE;
411
412        $userinfo = $client->get_userinfo();
413
414        if (!$userinfo) {
415            // Trigger login failed event.
416            $failurereason = AUTH_LOGIN_NOUSER;
417            $event = \core\event\user_login_failed::create(['other' => ['username' => 'unknown',
418                                                                        'reason' => $failurereason]]);
419            $event->trigger();
420
421            $errormsg = get_string('loginerror_nouserinfo', 'auth_oauth2');
422            $SESSION->loginerrormsg = $errormsg;
423            $client->log_out();
424            redirect(new moodle_url('/login/index.php'));
425        }
426        if (empty($userinfo['username']) || empty($userinfo['email'])) {
427            // Trigger login failed event.
428            $failurereason = AUTH_LOGIN_NOUSER;
429            $event = \core\event\user_login_failed::create(['other' => ['username' => 'unknown',
430                                                                        'reason' => $failurereason]]);
431            $event->trigger();
432
433            $errormsg = get_string('loginerror_userincomplete', 'auth_oauth2');
434            $SESSION->loginerrormsg = $errormsg;
435            $client->log_out();
436            redirect(new moodle_url('/login/index.php'));
437        }
438
439        $userinfo['username'] = trim(core_text::strtolower($userinfo['username']));
440        $oauthemail = $userinfo['email'];
441
442        // Once we get here we have the user info from oauth.
443        $userwasmapped = false;
444
445        // Clean and remember the picture / lang.
446        if (!empty($userinfo['picture'])) {
447            $this->set_static_user_picture($userinfo['picture']);
448            unset($userinfo['picture']);
449        }
450
451        if (!empty($userinfo['lang'])) {
452            $userinfo['lang'] = str_replace('-', '_', trim(core_text::strtolower($userinfo['lang'])));
453            if (!get_string_manager()->translation_exists($userinfo['lang'], false)) {
454                unset($userinfo['lang']);
455            }
456        }
457
458        $issuer = $client->get_issuer();
459        // First we try and find a defined mapping.
460        $linkedlogin = api::match_username_to_user($userinfo['username'], $issuer);
461
462        if (!empty($linkedlogin) && empty($linkedlogin->get('confirmtoken'))) {
463            $mappeduser = get_complete_user_data('id', $linkedlogin->get('userid'));
464
465            if ($mappeduser && $mappeduser->suspended) {
466                $failurereason = AUTH_LOGIN_SUSPENDED;
467                $event = \core\event\user_login_failed::create([
468                    'userid' => $mappeduser->id,
469                    'other' => [
470                        'username' => $userinfo['username'],
471                        'reason' => $failurereason
472                    ]
473                ]);
474                $event->trigger();
475                $SESSION->loginerrormsg = get_string('invalidlogin');
476                $client->log_out();
477                redirect(new moodle_url('/login/index.php'));
478            } else if ($mappeduser && ($mappeduser->confirmed || !$issuer->get('requireconfirmation'))) {
479                // Update user fields.
480                $userinfo = $this->update_user($userinfo, $mappeduser);
481                $userwasmapped = true;
482            } else {
483                // Trigger login failed event.
484                $failurereason = AUTH_LOGIN_UNAUTHORISED;
485                $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
486                                                                            'reason' => $failurereason]]);
487                $event->trigger();
488
489                $errormsg = get_string('confirmationpending', 'auth_oauth2');
490                $SESSION->loginerrormsg = $errormsg;
491                $client->log_out();
492                redirect(new moodle_url('/login/index.php'));
493            }
494        } else if (!empty($linkedlogin)) {
495            // Trigger login failed event.
496            $failurereason = AUTH_LOGIN_UNAUTHORISED;
497            $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
498                                                                        'reason' => $failurereason]]);
499            $event->trigger();
500
501            $errormsg = get_string('confirmationpending', 'auth_oauth2');
502            $SESSION->loginerrormsg = $errormsg;
503            $client->log_out();
504            redirect(new moodle_url('/login/index.php'));
505        }
506
507
508        if (!$issuer->is_valid_login_domain($oauthemail)) {
509            // Trigger login failed event.
510            $failurereason = AUTH_LOGIN_UNAUTHORISED;
511            $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
512                                                                        'reason' => $failurereason]]);
513            $event->trigger();
514
515            $errormsg = get_string('notloggedindebug', 'auth_oauth2', get_string('loginerror_invaliddomain', 'auth_oauth2'));
516            $SESSION->loginerrormsg = $errormsg;
517            $client->log_out();
518            redirect(new moodle_url('/login/index.php'));
519        }
520
521        if (!$userwasmapped) {
522            // No defined mapping - we need to see if there is an existing account with the same email.
523
524            $moodleuser = \core_user::get_user_by_email($userinfo['email']);
525            if (!empty($moodleuser)) {
526                if ($issuer->get('requireconfirmation')) {
527                    $PAGE->set_url('/auth/oauth2/confirm-link-login.php');
528                    $PAGE->set_context(context_system::instance());
529
530                    \auth_oauth2\api::send_confirm_link_login_email($userinfo, $issuer, $moodleuser->id);
531                    // Request to link to existing account.
532                    $emailconfirm = get_string('emailconfirmlink', 'auth_oauth2');
533                    $message = get_string('emailconfirmlinksent', 'auth_oauth2', $moodleuser->email);
534                    $this->print_confirm_required($emailconfirm, $message);
535                    exit();
536                } else {
537                    \auth_oauth2\api::link_login($userinfo, $issuer, $moodleuser->id, true);
538                    $userinfo = $this->update_user($userinfo, $moodleuser);
539                    // No redirect, we will complete this login.
540                }
541
542            } else {
543                // This is a new account.
544                $exists = \core_user::get_user_by_username($userinfo['username']);
545                // Creating a new user?
546                if ($exists) {
547                    // Trigger login failed event.
548                    $failurereason = AUTH_LOGIN_FAILED;
549                    $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
550                                                                                'reason' => $failurereason]]);
551                    $event->trigger();
552
553                    // The username exists but the emails don't match. Refuse to continue.
554                    $errormsg = get_string('accountexists', 'auth_oauth2');
555                    $SESSION->loginerrormsg = $errormsg;
556                    $client->log_out();
557                    redirect(new moodle_url('/login/index.php'));
558                }
559
560                if (email_is_not_allowed($userinfo['email'])) {
561                    // Trigger login failed event.
562                    $failurereason = AUTH_LOGIN_FAILED;
563                    $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
564                                                                                'reason' => $failurereason]]);
565                    $event->trigger();
566                    // The username exists but the emails don't match. Refuse to continue.
567                    $reason = get_string('loginerror_invaliddomain', 'auth_oauth2');
568                    $errormsg = get_string('notloggedindebug', 'auth_oauth2', $reason);
569                    $SESSION->loginerrormsg = $errormsg;
570                    $client->log_out();
571                    redirect(new moodle_url('/login/index.php'));
572                }
573
574                if (!empty($CFG->authpreventaccountcreation)) {
575                    // Trigger login failed event.
576                    $failurereason = AUTH_LOGIN_UNAUTHORISED;
577                    $event = \core\event\user_login_failed::create(['other' => ['username' => $userinfo['username'],
578                                                                                'reason' => $failurereason]]);
579                    $event->trigger();
580                    // The username does not exist and settings prevent creating new accounts.
581                    $reason = get_string('loginerror_cannotcreateaccounts', 'auth_oauth2');
582                    $errormsg = get_string('notloggedindebug', 'auth_oauth2', $reason);
583                    $SESSION->loginerrormsg = $errormsg;
584                    $client->log_out();
585                    redirect(new moodle_url('/login/index.php'));
586                }
587
588                if ($issuer->get('requireconfirmation')) {
589                    $PAGE->set_url('/auth/oauth2/confirm-account.php');
590                    $PAGE->set_context(context_system::instance());
591
592                    // Create a new (unconfirmed account) and send an email to confirm it.
593                    $user = \auth_oauth2\api::send_confirm_account_email($userinfo, $issuer);
594
595                    $this->update_picture($user);
596                    $emailconfirm = get_string('emailconfirm');
597                    $message = get_string('emailconfirmsent', '', $userinfo['email']);
598                    $this->print_confirm_required($emailconfirm, $message);
599                    exit();
600                } else {
601                    // Create a new confirmed account.
602                    $newuser = \auth_oauth2\api::create_new_confirmed_account($userinfo, $issuer);
603                    $userinfo = get_complete_user_data('id', $newuser->id);
604                    // No redirect, we will complete this login.
605                }
606            }
607        }
608
609        // We used to call authenticate_user - but that won't work if the current user has a different default authentication
610        // method. Since we now ALWAYS link a login - if we get to here we can directly allow the user in.
611        $user = (object) $userinfo;
612        complete_user_login($user);
613        $this->update_picture($user);
614        redirect($redirecturl);
615    }
616
617    /**
618     * Returns information on how the specified user can change their password.
619     * The password of the oauth2 accounts is not stored in Moodle.
620     *
621     * @param stdClass $user A user object
622     * @return string[] An array of strings with keys subject and message
623     */
624    public function get_password_change_info(stdClass $user) : array {
625        $site = get_site();
626
627        $data = new stdClass();
628        $data->firstname = $user->firstname;
629        $data->lastname  = $user->lastname;
630        $data->username  = $user->username;
631        $data->sitename  = format_string($site->fullname);
632        $data->admin     = generate_email_signoff();
633
634        $message = get_string('emailpasswordchangeinfo', 'auth_oauth2', $data);
635        $subject = get_string('emailpasswordchangeinfosubject', 'auth_oauth2', format_string($site->fullname));
636
637        return [
638            'subject' => $subject,
639            'message' => $message
640        ];
641    }
642}
643