Proyectos de Subversion Moodle

Rev

Rev 1 | | Comparar con el anterior | Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
namespace tool_mfa;
18
 
19
use dml_exception;
20
use tool_mfa\plugininfo\factor;
21
 
22
/**
23
 * MFA management class.
24
 *
25
 * @package     tool_mfa
26
 * @author      Peter Burnett <peterburnett@catalyst-au.net>
27
 * @copyright   Catalyst IT
28
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
29
 */
30
class manager {
31
 
32
    /** @var int */
33
    const REDIRECT = 1;
34
 
35
    /** @var int */
36
    const NO_REDIRECT = 0;
37
 
38
    /** @var int */
39
    const REDIRECT_EXCEPTION = -1;
40
 
41
    /** @var int */
42
    const REDIR_LOOP_THRESHOLD = 5;
43
 
1441 ariadna 44
    /** @var array These components and related fileareas will not redirect. */
45
    const ALLOWED_COMPONENTS = [
46
        'core_admin' => [
47
            'logocompact',
48
            'logo',
49
            'favicon',
50
        ],
51
        'tool_mfa' => [
52
            'guidance',
53
        ]
54
    ];
55
 
1 efrain 56
    /**
57
     * Displays a debug table with current factor information.
58
     *
59
     * @return void
60
     */
61
    public static function display_debug_notification(): void {
62
        global $OUTPUT, $PAGE;
63
 
64
        if (!get_config('tool_mfa', 'debugmode')) {
65
            return;
66
        }
67
        $html = $OUTPUT->heading(get_string('debugmode:heading', 'tool_mfa'), 3);
68
 
69
        $table = new \html_table();
70
        $table->head = [
71
            get_string('weight', 'tool_mfa'),
72
            get_string('factor', 'tool_mfa'),
73
            get_string('setup', 'tool_mfa'),
74
            get_string('achievedweight', 'tool_mfa'),
75
            get_string('status'),
76
        ];
77
        $table->attributes['class'] = 'admintable generaltable table table-bordered';
78
        $table->colclasses = [
1441 ariadna 79
            'text-end',
1 efrain 80
            '',
81
            '',
1441 ariadna 82
            'text-end',
1 efrain 83
            'text-center',
84
        ];
85
        $factors = factor::get_enabled_factors();
86
        $userfactors = factor::get_active_user_factor_types();
87
        $runningtotal = 0;
88
        $weighttoggle = false;
89
 
90
        foreach ($factors as $factor) {
91
            $namespace = 'factor_'.$factor->name;
92
            $name = get_string('pluginname', $namespace);
93
 
94
            // If factor is unknown, pending from here.
95
            if ($factor->get_state() == factor::STATE_UNKNOWN) {
96
                $weighttoggle = true;
97
            }
98
 
99
            // Stop adding weight if 100 achieved.
100
            if (!$weighttoggle) {
101
                $achieved = $factor->get_state() == factor::STATE_PASS ? $factor->get_weight() : 0;
102
                $achieved = '+'.$achieved;
103
                $runningtotal += $achieved;
104
            } else {
105
                $achieved = '';
106
            }
107
 
108
            // Setup.
109
            if ($factor->has_setup()) {
110
                $found = false;
111
                foreach ($userfactors as $userfactor) {
112
                    if ($userfactor->name == $factor->name) {
113
                        $found = true;
114
                    }
115
                }
116
                $setup = $found ? get_string('yes') : get_string('no');
117
            } else {
118
                $setup = get_string('na', 'tool_mfa');
119
            }
120
 
121
            // Status.
122
            $OUTPUT = $PAGE->get_renderer('tool_mfa');
123
            // If toggle has been flipped, fall to default pending badge.
124
            if ($weighttoggle) {
125
                $state = $OUTPUT->get_state_badge('');
126
            } else {
127
                $state = $OUTPUT->get_state_badge($factor->get_state());
128
            }
129
 
130
            $table->data[] = [
131
                $factor->get_weight(),
132
                $name,
133
                $setup,
134
                $achieved,
135
                $state,
136
            ];
137
 
138
            // If we just hit 100, flip toggle.
139
            if ($runningtotal >= 100) {
140
                $weighttoggle = true;
141
            }
142
        }
143
 
144
        $finalstate = self::get_status();
145
        $table->data[] = [
146
            '',
147
            '',
148
            '<b>' . get_string('overall', 'tool_mfa') . '</b>',
149
            self::get_cumulative_weight(),
150
            $OUTPUT->get_state_badge($finalstate),
151
        ];
152
 
153
        $html .= \html_writer::table($table);
154
        echo $html;
155
    }
156
 
157
    /**
158
     * Returns the total weight from all factors currently enabled for user.
159
     *
160
     * @return int
161
     */
162
    public static function get_total_weight(): int {
163
        $totalweight = 0;
164
        $factors = factor::get_active_user_factor_types();
165
 
166
        foreach ($factors as $factor) {
167
            if ($factor->get_state() == factor::STATE_PASS) {
168
                $totalweight += $factor->get_weight();
169
            }
170
        }
171
        return $totalweight;
172
    }
173
 
174
    /**
175
     * Checks that provided factorid exists and belongs to current user.
176
     *
177
     * @param int $factorid
178
     * @param object $user
179
     * @return bool
180
     * @throws \dml_exception
181
     */
182
    public static function is_factorid_valid(int $factorid, object $user): bool {
183
        global $DB;
184
        return $DB->record_exists('tool_mfa', ['userid' => $user->id, 'id' => $factorid]);
185
    }
186
 
187
    /**
188
     * Function to display to the user that they cannot login, then log them out.
189
     *
190
     * @return void
191
     */
192
    public static function cannot_login(): void {
193
        global $ME, $PAGE, $SESSION, $USER;
194
 
195
        // Determine page URL without triggering warnings from $PAGE.
196
        if (!preg_match("~(\/admin\/tool\/mfa\/auth.php)~", $ME)) {
197
            // If URL isn't set, we need to redir to auth.php.
198
            // This ensures URL and required info is correctly set.
199
            // Then we arrive back here.
200
            redirect(new \moodle_url('/admin/tool/mfa/auth.php'));
201
        }
202
 
203
        $renderer = $PAGE->get_renderer('tool_mfa');
204
 
205
        echo $renderer->header();
206
        if (get_config('tool_mfa', 'debugmode')) {
207
            self::display_debug_notification();
208
        }
209
        echo $renderer->not_enough_factors();
210
        echo $renderer->footer();
211
        // Emit an event for failure, then logout.
212
        $event = \tool_mfa\event\user_failed_mfa::user_failed_mfa_event($USER);
213
        $event->trigger();
214
 
215
        // We should set the redir flag, as this page is generated through auth.php.
216
        $SESSION->tool_mfa_has_been_redirected = true;
217
        die;
218
    }
219
 
220
    /**
221
     * Logout user.
222
     *
223
     * @return void
224
     */
225
    public static function mfa_logout(): void {
226
        $authsequence = get_enabled_auth_plugins();
227
        foreach ($authsequence as $authname) {
228
            $authplugin = get_auth_plugin($authname);
229
            $authplugin->logoutpage_hook();
230
        }
231
        require_logout();
232
    }
233
 
234
    /**
235
     * Function to get the overall status of a user's authentication.
236
     *
237
     * @return string a STATE variable from plugininfo
238
     */
239
    public static function get_status(): string {
240
        global $SESSION;
241
 
242
        // Check for any instant fail states.
243
        $factors = factor::get_active_user_factor_types();
244
        foreach ($factors as $factor) {
245
            $factor->load_locked_state();
246
 
247
            if ($factor->get_state() == factor::STATE_FAIL) {
248
                return factor::STATE_FAIL;
249
            }
250
        }
251
 
252
        $passcondition = ((isset($SESSION->tool_mfa_authenticated) && $SESSION->tool_mfa_authenticated) ||
253
            self::passed_enough_factors());
254
 
255
        // Check next factor for instant fail (fallback).
256
        if (factor::get_next_user_login_factor()->get_state() == factor::STATE_FAIL) {
257
            // We need to handle a special case here, where someone reached the fallback,
258
            // If they were able to modify their state on the error page, such as passing iprange,
259
            // We must return pass.
260
            if ($passcondition) {
261
                return factor::STATE_PASS;
262
            }
263
 
264
            return factor::STATE_FAIL;
265
        }
266
 
267
        // Now check for general passing state. If found, ensure that session var is set.
268
        if ($passcondition) {
269
            return factor::STATE_PASS;
270
        }
271
 
272
        // Else return neutral state.
273
        return factor::STATE_NEUTRAL;
274
    }
275
 
276
    /**
277
     * Function to check the overall status of a users authentication,
278
     * and perform any required actions.
279
     *
280
     * @param bool $shouldreload whether the function should reload (used for auth.php).
281
     * @return void
282
     */
283
    public static function resolve_mfa_status(bool $shouldreload = false): void {
284
        global $SESSION;
285
 
286
        $state = self::get_status();
287
        if ($state == factor::STATE_PASS) {
288
            self::set_pass_state();
289
            // Check if user even had to reach auth page.
290
            if (isset($SESSION->tool_mfa_has_been_redirected)) {
291
                if (empty($SESSION->wantsurl)) {
292
                    $wantsurl = '/';
293
                } else {
294
                    $wantsurl = $SESSION->wantsurl;
295
                }
296
                unset($SESSION->wantsurl);
297
                redirect(new \moodle_url($wantsurl));
298
            } else {
299
                // Don't touch anything, let user be on their way.
300
                return;
301
            }
302
        } else if ($state == factor::STATE_FAIL) {
303
            self::cannot_login();
304
        } else if ($shouldreload) {
305
            // Set a session variable to track whether user is where they want to be.
306
            $SESSION->tool_mfa_has_been_redirected = true;
307
            $authurl = new \moodle_url('/admin/tool/mfa/auth.php');
308
            redirect($authurl);
309
        }
310
    }
311
 
312
    /**
313
     * Checks whether user has passed enough factors to be allowed in.
314
     *
315
     * @return bool true if user has passed enough factors.
316
     */
317
    public static function passed_enough_factors(): bool {
318
 
319
        // Check for any instant fail states.
320
        $factors = factor::get_active_user_factor_types();
321
        foreach ($factors as $factor) {
322
            if ($factor->get_state() == factor::STATE_FAIL) {
323
                self::mfa_logout();
324
            }
325
        }
326
 
327
        $totalweight = self::get_cumulative_weight();
328
        if ($totalweight >= 100) {
329
            return true;
330
        }
331
 
332
        return false;
333
    }
334
 
335
    /**
336
     * Sets the session variable for pass_state, if not already set.
337
     *
338
     * @return void
339
     */
340
    public static function set_pass_state(): void {
341
        global $DB, $SESSION, $USER;
342
        if (!isset($SESSION->tool_mfa_authenticated)) {
343
            $SESSION->tool_mfa_authenticated = true;
344
            $event = \tool_mfa\event\user_passed_mfa::user_passed_mfa_event($USER);
345
            $event->trigger();
346
 
347
            // Allow plugins to callback as soon possible after user has passed MFA.
348
            $hook = new \tool_mfa\hook\after_user_passed_mfa();
349
            \core\di::get(\core\hook\manager::class)->dispatch($hook);
350
 
351
            // Add/update record in DB for users last mfa auth.
352
            self::update_pass_time();
353
 
354
            // Unset session vars during mfa auth.
355
            unset($SESSION->mfa_redir_referer);
356
            unset($SESSION->mfa_redir_count);
357
 
358
            // Unset user preferences during mfa auth.
359
            unset_user_preference('mfa_sleep_duration', $USER);
360
 
361
            try {
362
                // Clear locked user factors, they may now reauth with anything.
363
                @$DB->set_field('tool_mfa', 'lockcounter', 0, ['userid' => $USER->id]);
364
                // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
365
            } catch (\Exception $e) {
366
                // This occurs when upgrade.php hasn't been run. Nothing to do here.
367
            }
368
 
369
            // Fire post pass state factor actions.
370
            $factors = factor::get_active_user_factor_types();
371
            foreach ($factors as $factor) {
372
                $factor->post_pass_state();
373
                // Also set the states for this session to neutral if they were locked.
374
                if ($factor->get_state() == factor::STATE_LOCKED) {
375
                    $factor->set_state(factor::STATE_NEUTRAL);
376
                }
377
            }
378
 
379
            // Output notifications if any factors were reset for this user.
380
            $enabledfactors = factor::get_enabled_factors();
381
            foreach ($enabledfactors as $factor) {
382
                $pref = 'tool_mfa_reset_' . $factor->name;
383
                $factorpref = get_user_preferences($pref, false);
384
                if ($factorpref) {
385
                    $url = new \moodle_url('/admin/tool/mfa/user_preferences.php');
386
                    $link = \html_writer::link($url, get_string('preferenceslink', 'tool_mfa'));
387
                    $data = ['factor' => $factor->get_display_name(), 'url' => $link];
388
                    \core\notification::warning(get_string('factorreset', 'tool_mfa', $data));
389
                    unset_user_preference($pref);
390
                }
391
            }
392
 
393
            // Also check for a global reset.
394
            // TODO: Delete this in a few months, the reset all preference is no longer set.
395
            $allfactor = get_user_preferences('tool_mfa_reset_all', false);
396
            if ($allfactor) {
397
                $url = new \moodle_url('/admin/tool/mfa/user_preferences.php');
398
                $link = \html_writer::link($url, get_string('preferenceslink', 'tool_mfa'));
399
                \core\notification::warning(get_string('factorresetall', 'tool_mfa', $link));
400
                unset_user_preference('tool_mfa_reset_all');
401
            }
402
        }
403
    }
404
 
405
    /**
406
     * Inserts or updates user's last MFA pass time in DB.
407
     * This should only be called from set_pass_state.
408
     *
409
     * @return void
410
     */
411
    private static function update_pass_time(): void {
412
        global $DB, $USER;
413
 
414
        $exists = $DB->record_exists('tool_mfa_auth', ['userid' => $USER->id]);
415
 
416
        if ($exists) {
417
            $DB->set_field('tool_mfa_auth', 'lastverified', time(), ['userid' => $USER->id]);
418
        } else {
419
            $DB->insert_record('tool_mfa_auth', ['userid' => $USER->id, 'lastverified' => time()]);
420
        }
421
    }
422
 
423
    /**
424
     * Checks whether the user should be redirected from the provided url.
425
     *
426
     * @param string|\moodle_url $url
427
     * @param bool|null $preventredirect
428
     * @return int
429
     */
430
    public static function should_require_mfa(string|\moodle_url $url, bool|null $preventredirect): int {
431
        global $CFG, $USER, $SESSION;
432
 
433
        // If no cookies then no session so cannot do MFA.
434
        // Unit testing based on defines is not viable.
435
        if (NO_MOODLE_COOKIES && !PHPUNIT_TEST) {
436
            return self::NO_REDIRECT;
437
        }
438
 
1441 ariadna 439
        // Ensure we have a moodle_url object if a string is provided.
440
        if (is_string($url)) {
441
            $url = new \moodle_url($url);
442
        }
443
 
444
        // Check for pluginfile.php urls.
445
        $pluginfileurl = new \moodle_url('/pluginfile.php');
446
        if ($url->compare($pluginfileurl)) {
447
            // Get the slash arguments.
448
            $args = explode('/', ltrim($url->get_slashargument(), '/'));
449
 
450
            // Remove the contextid because we do not need it for this check.
451
            array_shift($args);
452
 
453
            // Get the component and filearea.
454
            $component = clean_param(array_shift($args), PARAM_COMPONENT);
455
            $filearea = clean_param(array_shift($args), PARAM_AREA);
456
 
457
            // Check allowed components.
458
            if (!array_key_exists($component, static::ALLOWED_COMPONENTS)) {
459
                return self::REDIRECT;
460
            }
461
 
462
            // Check allowed fileareas.
463
            if (!in_array($filearea, static::ALLOWED_COMPONENTS[$component])) {
464
                return self::REDIRECT;
465
            }
466
 
467
            return self::NO_REDIRECT;
468
        }
469
 
1 efrain 470
        // Remove all params before comparison.
471
        $url->remove_all_params();
472
 
473
        // Checks for upgrades pending.
474
        if (is_siteadmin()) {
475
            // We should only allow an upgrade from the frontend to complete.
476
            // After that is completed, only the settings shouldn't redirect.
477
            // Everything else should be safe to enforce MFA.
478
            if (moodle_needs_upgrading()) {
479
                return self::NO_REDIRECT;
480
            }
481
            // An upgrade isn't complete if there are settings that must be saved.
482
            $upgradesettings = new \moodle_url('/admin/upgradesettings.php');
483
            if ($url->compare($upgradesettings, URL_MATCH_BASE)) {
484
                return self::NO_REDIRECT;
485
            }
486
        }
487
 
488
        // Admin not setup.
489
        if (!empty($CFG->adminsetuppending)) {
490
            return self::NO_REDIRECT;
491
        }
492
 
493
        // Initial installation.
494
        // We get this for free from get_plugins_with_function.
495
 
496
        // Upgrade check.
497
        // We get this for free from get_plugins_with_function.
498
 
499
        // Honor prevent_redirect.
500
        if ($preventredirect) {
501
            return self::NO_REDIRECT;
502
        }
503
 
504
        // User not properly setup.
505
        if (user_not_fully_set_up($USER)) {
506
            return self::NO_REDIRECT;
507
        }
508
 
509
        // Guest access.
510
        if (isguestuser()) {
511
            return self::NO_REDIRECT;
512
        }
513
 
514
        // Forced password changes.
515
        if (get_user_preferences('auth_forcepasswordchange')) {
516
            return self::NO_REDIRECT;
517
        }
518
 
519
        // Login as.
520
        if (\core\session\manager::is_loggedinas()) {
521
            return self::NO_REDIRECT;
522
        }
523
 
524
        // Site policy.
525
        if (isset($USER->policyagreed) && !$USER->policyagreed) {
526
            $manager = new \core_privacy\local\sitepolicy\manager();
527
            $policyurl = $manager->get_redirect_url(false);
528
            if (!empty($policyurl) && $url->compare($policyurl, URL_MATCH_BASE)) {
529
                return self::NO_REDIRECT;
530
            }
531
        }
532
 
1441 ariadna 533
        // Site policies from tool_policy.
534
        $policyviewurl = new \moodle_url('/admin/tool/policy/view.php');
535
        $policyindexurl = new \moodle_url('/admin/tool/policy/index.php');
536
        if ($policyviewurl->compare($url, URL_MATCH_BASE) || $policyindexurl->compare($url, URL_MATCH_BASE)) {
537
            return self::NO_REDIRECT;
538
        }
539
 
1 efrain 540
        // WS/AJAX check.
541
        if (WS_SERVER || AJAX_SCRIPT) {
542
            if (isset($SESSION->mfa_pending) && !empty($SESSION->mfa_pending)) {
543
                // Allow AJAX and WS, but never from auth.php.
544
                return self::NO_REDIRECT;
545
            }
546
            return self::REDIRECT_EXCEPTION;
547
        }
548
 
549
        // Check factor defined safe urls.
550
        $factorurls = self::get_no_redirect_urls();
551
        foreach ($factorurls as $factorurl) {
552
            if ($factorurl->compare($url)) {
553
                return self::NO_REDIRECT;
554
            }
555
        }
556
 
557
        // Circular checks.
558
        $authurl = new \moodle_url('/admin/tool/mfa/auth.php');
559
        $authlocal = $authurl->out_as_local_url();
560
        if (isset($SESSION->mfa_redir_referer)
561
            && $SESSION->mfa_redir_referer != $authlocal) {
562
            if ($SESSION->mfa_redir_referer == get_local_referer(true)) {
563
                // Possible redirect loop.
564
                if (!isset($SESSION->mfa_redir_count)) {
565
                    $SESSION->mfa_redir_count = 1;
566
                } else {
567
                    $SESSION->mfa_redir_count++;
568
                }
569
                if ($SESSION->mfa_redir_count > self::REDIR_LOOP_THRESHOLD) {
570
                    return self::REDIRECT_EXCEPTION;
571
                }
572
            } else {
573
                // If not a match, reset counter.
574
                $SESSION->mfa_redir_count = 0;
575
            }
576
        }
577
        // Set referer after checks.
578
        $SESSION->mfa_redir_referer = get_local_referer(true);
579
 
580
        // Don't redirect if already on auth.php.
581
        if ($url->compare($authurl, URL_MATCH_BASE)) {
582
            return self::NO_REDIRECT;
583
        }
584
 
585
        return self::REDIRECT;
586
    }
587
 
588
    /**
589
     * Clears the redirect counter for infinite redirect loops. Called from auth.php when a valid load is resolved.
590
     *
591
     * @return void
592
     */
593
    public static function clear_redirect_counter(): void {
594
        global $SESSION;
595
 
596
        unset($SESSION->mfa_redir_referer);
597
        unset($SESSION->mfa_redir_count);
598
    }
599
 
600
    /**
601
     * Gets all defined factor urls that should not redirect.
602
     *
603
     * @return array
604
     */
605
    public static function get_no_redirect_urls(): array {
606
        $factors = factor::get_factors();
607
        $urls = [
608
            new \moodle_url('/login/logout.php'),
609
            new \moodle_url('/admin/tool/mfa/guide.php'),
610
        ];
611
        foreach ($factors as $factor) {
612
            $urls = array_merge($urls, $factor->get_no_redirect_urls());
613
        }
614
 
615
        // Allow forced redirection exclusions.
616
        if ($exclusions = get_config('tool_mfa', 'redir_exclusions')) {
617
            foreach (explode("\n", $exclusions) as $exclusion) {
618
                $urls[] = new \moodle_url($exclusion);
619
            }
620
        }
621
 
622
        return $urls;
623
    }
624
 
625
    /**
626
     * Sleeps for an increasing period of time.
627
     *
628
     * @return void
629
     */
630
    public static function sleep_timer(): void {
631
        global $USER;
632
 
633
        $duration = get_user_preferences('mfa_sleep_duration', null, $USER);
634
        if (!empty($duration)) {
635
            // Double current time.
636
            $duration *= 2;
637
            $duration = min(2, $duration);
638
        } else {
639
            // No duration set.
640
            $duration = 0.05;
641
        }
642
        set_user_preference('mfa_sleep_duration', $duration, $USER);
643
        sleep((int)$duration);
644
    }
645
 
646
    /**
647
     * If MFA Plugin is ready check tool_mfa_authenticated USER property and
648
     * start MFA authentication if it's not set or false.
649
     *
650
     * @param mixed $courseorid
651
     * @param mixed $autologinguest
652
     * @param mixed $cm
653
     * @param mixed $setwantsurltome
654
     * @param mixed $preventredirect
655
     * @return void
656
     */
657
    public static function require_auth($courseorid = null, $autologinguest = null, $cm = null,
658
    $setwantsurltome = null, $preventredirect = null): void {
659
        global $PAGE, $SESSION, $FULLME;
660
 
661
        // Guest user should never interact with MFA,
662
        // And $SESSION->tool_mfa_authenticated should never be set in a guest session.
663
        if (isguestuser()) {
664
            return;
665
        }
666
 
667
        if (!self::is_ready()) {
668
            // Set session var so if MFA becomes ready, you dont get locked from session.
669
            $SESSION->tool_mfa_authenticated = true;
670
            return;
671
        }
672
 
673
        if (empty($SESSION->tool_mfa_authenticated) || !$SESSION->tool_mfa_authenticated) {
674
            if ($PAGE->has_set_url()) {
675
                $cleanurl = $PAGE->url;
676
            } else {
677
                // Use $FULLME instead.
678
                $cleanurl = new \moodle_url($FULLME);
679
            }
680
            $authurl = new \moodle_url('/admin/tool/mfa/auth.php');
681
 
682
            $redir = self::should_require_mfa($cleanurl, $preventredirect);
683
 
684
            if ($redir == self::NO_REDIRECT && !$cleanurl->compare($authurl, URL_MATCH_BASE)) {
685
                // A non-MFA page that should take precedence.
686
                // This check is for any pages, such as site policy, that must occur before MFA.
687
                // This check allows AJAX and WS requests to fire on these pages without throwing an exception.
688
                $SESSION->mfa_pending = true;
689
            }
690
 
691
            if ($redir == self::REDIRECT) {
692
                if (empty($SESSION->wantsurl)) {
693
                    !empty($setwantsurltome)
694
                        ? $SESSION->wantsurl = qualified_me()
695
                        : $SESSION->wantsurl = new \moodle_url('/');
696
 
697
                    $SESSION->tool_mfa_setwantsurl = true;
698
                }
699
                // Remove pending status.
700
                // We must now auth with MFA, now that pending statuses are resolved.
701
                unset($SESSION->mfa_pending);
702
 
703
                // Call resolve_status to instantly pass if no redirect is required.
704
                self::resolve_mfa_status(true);
705
            } else if ($redir == self::REDIRECT_EXCEPTION) {
706
                if (!empty($SESSION->mfa_redir_referer)) {
707
                    throw new \moodle_exception('redirecterrordetected', 'tool_mfa',
708
                        $SESSION->mfa_redir_referer, $SESSION->mfa_redir_referer);
709
                } else {
710
                    throw new \moodle_exception('redirecterrordetected', 'error');
711
                }
712
            }
713
        }
714
    }
715
 
716
    /**
717
     * Sets config variable for given factor.
718
     *
719
     * @param array $data
720
     * @param string $factor
721
     *
722
     * @return bool true or exception
723
     * @throws dml_exception
724
     */
725
    public static function set_factor_config(array $data, string $factor): bool|dml_exception {
726
        $factorconf = get_config($factor);
727
        foreach ($data as $key => $newvalue) {
728
            if (empty($factorconf->$key)) {
729
                add_to_config_log($key, null, $newvalue, $factor);
730
                set_config($key, $newvalue, $factor);
731
            } else if ($factorconf->$key != $newvalue) {
732
                add_to_config_log($key, $factorconf->$key, $newvalue, $factor);
733
                set_config($key, $newvalue, $factor);
734
            }
735
        }
736
        return true;
737
    }
738
 
739
    /**
740
     * Checks if MFA Plugin is enabled and has enabled factor.
741
     * If plugin is disabled or there is no enabled factors,
742
     * it means there is nothing to do from user side.
743
     * Thus, login flow shouldn't be extended with MFA.
744
     *
745
     * @return bool
746
     * @throws \dml_exception
747
     */
748
    public static function is_ready(): bool {
749
        global $CFG, $USER;
750
 
751
        if (!empty($CFG->upgraderunning)) {
752
            return false;
753
        }
754
 
755
        $pluginenabled = get_config('tool_mfa', 'enabled');
756
        if (empty($pluginenabled)) {
757
            return false;
758
        }
759
 
760
        // Check if user can interact with MFA.
761
        $usercontext = \context_user::instance($USER->id);
762
        if (!has_capability('tool/mfa:mfaaccess', $usercontext)) {
763
            return false;
764
        }
765
 
766
        $enabledfactors = factor::get_enabled_factors();
767
        if (count($enabledfactors) == 0) {
768
            return false;
769
        }
770
 
771
        return true;
772
    }
773
 
774
    /**
775
     * Performs factor actions for given factor.
776
     * Change factor order and enable/disable.
777
     *
778
     * @param string $factorname
779
     * @param string $action
780
     *
781
     * @return void
782
     * @throws dml_exception
783
     */
784
    public static function do_factor_action(string $factorname, string $action): void {
785
        $order = explode(',', get_config('tool_mfa', 'factor_order'));
786
        $key = array_search($factorname, $order);
787
 
788
        switch ($action) {
789
            case 'up':
790
                if ($key >= 1) {
791
                    $fsave = $order[$key];
792
                    $order[$key] = $order[$key - 1];
793
                    $order[$key - 1] = $fsave;
794
                }
795
                break;
796
 
797
            case 'down':
798
                if ($key < (count($order) - 1)) {
799
                    $fsave = $order[$key];
800
                    $order[$key] = $order[$key + 1];
801
                    $order[$key + 1] = $fsave;
802
                }
803
                break;
804
 
805
            case 'enable':
806
                if (!$key) {
807
                    $order[] = $factorname;
808
                }
809
                break;
810
 
811
            case 'disable':
812
                if ($key) {
813
                    unset($order[$key]);
814
                }
815
                break;
816
 
817
            default:
818
                break;
819
        }
820
        self::set_factor_config(['factor_order' => implode(',', $order)], 'tool_mfa');
821
    }
822
 
823
    /**
824
     * Checks if a factor that can make a user pass can be setup.
825
     * It checks if a user will always pass regardless,
826
     * then checks if there are factors that can be setup to let a user pass.
827
     *
828
     * @return bool
829
     */
830
    public static function possible_factor_setup(): bool {
831
        global $USER;
832
 
833
        // Get all active factors.
834
        $factors = factor::get_enabled_factors();
835
 
836
        // Check if there are enough factors that a user can ONLY pass, if so, don't display the menu.
837
        $weight = 0;
838
        foreach ($factors as $factor) {
839
            $states = $factor->possible_states($USER);
840
            if (count($states) == 1 && reset($states) == factor::STATE_PASS) {
841
                $weight += $factor->get_weight();
842
                if ($weight >= 100) {
843
                    return false;
844
                }
845
            }
846
        }
847
 
848
        // Now if there is a factor that can be setup, that may return a pass state for the user, display menu.
849
        foreach ($factors as $factor) {
850
            if ($factor->has_setup()) {
851
                if (in_array(factor::STATE_PASS, $factor->possible_states($USER))) {
852
                    return true;
853
                }
854
            }
855
        }
856
 
857
        return false;
858
    }
859
 
860
    /**
861
     * Gets current user weight, up until first unknown factor.
862
     *
863
     * @return int $totalweight Total weight of all factors.
864
     */
865
    public static function get_cumulative_weight(): int {
866
        $factors = factor::get_active_user_factor_types();
867
        // Factor order is important here, so sort the factors by state.
868
        $sortedfactors = factor::sort_factors_by_state($factors, factor::STATE_PASS);
869
        $totalweight = 0;
870
        foreach ($sortedfactors as $factor) {
871
            if ($factor->get_state() == factor::STATE_PASS) {
872
                $totalweight += $factor->get_weight();
873
                // If over 100, break. Don't care about >100.
874
                if ($totalweight >= 100) {
875
                    break;
876
                }
877
            } else if ($factor->get_state() == factor::STATE_UNKNOWN) {
878
                break;
879
            }
880
        }
881
        return $totalweight;
882
    }
883
 
884
    /**
885
     * Checks whether the factor was actually used in the login process.
886
     *
887
     * @param string $factorname the name of the factor.
888
     * @return bool true if factor is pending.
889
     */
890
    public static function check_factor_pending(string $factorname): bool {
891
        $factors = factor::get_active_user_factor_types();
892
        // Setup vars.
893
        $pending = [];
894
        $totalweight = 0;
895
        $weighttoggle = false;
896
 
897
        foreach ($factors as $factor) {
898
            // If toggle is reached, put in pending and continue.
899
            if ($weighttoggle) {
900
                $pending[] = $factor->name;
901
                continue;
902
            }
903
 
904
            if ($factor->get_state() == factor::STATE_PASS) {
905
                $totalweight += $factor->get_weight();
906
                if ($totalweight >= 100) {
907
                    $weighttoggle = true;
908
                }
909
            }
910
        }
911
 
912
        // Check whether factor falls into pending category.
913
        return in_array($factorname, $pending);
914
    }
915
}