Proyectos de Subversion Moodle

Rev

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