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 factor_totp;
18
 
19
defined('MOODLE_INTERNAL') || die();
20
 
21
require_once($CFG->libdir.'/tcpdf/tcpdf_barcodes_2d.php');
22
require_once(__DIR__.'/../extlib/OTPHP/OTPInterface.php');
23
require_once(__DIR__.'/../extlib/OTPHP/TOTPInterface.php');
24
require_once(__DIR__.'/../extlib/OTPHP/ParameterTrait.php');
25
require_once(__DIR__.'/../extlib/OTPHP/OTP.php');
26
require_once(__DIR__.'/../extlib/OTPHP/TOTP.php');
27
 
28
require_once(__DIR__.'/../extlib/ParagonIE/ConstantTime/EncoderInterface.php');
29
require_once(__DIR__.'/../extlib/ParagonIE/ConstantTime/Binary.php');
30
require_once(__DIR__.'/../extlib/ParagonIE/ConstantTime/Base32.php');
31
 
1441 ariadna 32
use MoodleQuickForm;
1 efrain 33
use tool_mfa\local\factor\object_factor_base;
34
use OTPHP\TOTP;
35
use stdClass;
1441 ariadna 36
use core\clock;
37
use core\context\system;
38
use core\di;
1 efrain 39
 
40
/**
41
 * TOTP factor class.
42
 *
43
 * @package     factor_totp
44
 * @subpackage  tool_mfa
45
 * @author      Mikhail Golenkov <golenkovm@gmail.com>
46
 * @copyright   Catalyst IT
47
 * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
48
 */
49
class factor extends object_factor_base {
50
 
51
    /** @var string */
52
    const TOTP_OLD = 'old';
53
 
54
    /** @var string */
55
    const TOTP_FUTURE = 'future';
56
 
57
    /** @var string */
58
    const TOTP_USED = 'used';
59
 
60
    /** @var string */
61
    const TOTP_VALID = 'valid';
62
 
63
    /** @var string */
64
    const TOTP_INVALID = 'invalid';
65
 
66
    /** @var string Factor icon */
67
    protected $icon = 'fa-mobile-screen';
68
 
1441 ariadna 69
    /** @var clock */
70
    private readonly clock $clock;
1 efrain 71
 
72
    /**
1441 ariadna 73
     * Constructor.
74
     *
75
     * @param string $name
76
     */
77
    public function __construct(string $name) {
78
        parent::__construct($name);
79
        $this->clock = di::get(clock::class);
80
    }
81
 
82
 
83
    /**
1 efrain 84
     * Generates TOTP URI for given secret key.
85
     * Uses site name, hostname and user name to make GA account look like:
86
     * "Sitename hostname (username)".
87
     *
88
     * @param string $secret
89
     * @return string
90
     */
91
    public function generate_totp_uri(string $secret): string {
92
        global $USER, $SITE, $CFG;
93
        $host = parse_url($CFG->wwwroot, PHP_URL_HOST);
1441 ariadna 94
        $sitename = str_replace(':', '', format_string($SITE->fullname, true, ['context' => system::instance()]));
1 efrain 95
        $issuer = $sitename.' '.$host;
1441 ariadna 96
        $totp = TOTP::create($secret, clock: $this->clock);
1 efrain 97
        $totp->setLabel($USER->username);
98
        $totp->setIssuer($issuer);
99
        return $totp->getProvisioningUri();
100
    }
101
 
102
    /**
103
     * Generates HTML sting with QR code for given secret key.
104
     *
105
     * @param string $secret
106
     * @return string
107
     */
108
    public function generate_qrcode(string $secret): string {
109
        $uri = $this->generate_totp_uri($secret);
110
        $qrcode = new \TCPDF2DBarcode($uri, 'QRCODE');
111
        $image = $qrcode->getBarcodePngData(7, 7);
112
        $html = \html_writer::img('data:image/png;base64,' . base64_encode($image), '', ['width' => '150px']);
113
        return $html;
114
    }
115
 
116
    /**
117
     * TOTP state
118
     *
119
     * {@inheritDoc}
120
     */
121
    public function get_state(): string {
122
        global $USER;
123
        $userfactors = $this->get_active_user_factors($USER);
124
 
125
        // If no codes are setup then we must be neutral not unknown.
126
        if (count($userfactors) == 0) {
127
            return \tool_mfa\plugininfo\factor::STATE_NEUTRAL;
128
        }
129
 
130
        return parent::get_state();
131
    }
132
 
133
    /**
134
     * TOTP Factor implementation.
135
     *
1441 ariadna 136
     * @param MoodleQuickForm $mform
137
     * @return MoodleQuickForm $mform
1 efrain 138
     */
1441 ariadna 139
    public function setup_factor_form_definition(MoodleQuickForm $mform): MoodleQuickForm {
1 efrain 140
        $secret = $this->generate_secret_code();
141
        $mform->addElement('hidden', 'secret', $secret);
142
        $mform->setType('secret', PARAM_ALPHANUM);
143
 
144
        return $mform;
145
    }
146
 
147
    /**
148
     * TOTP Factor implementation.
149
     *
1441 ariadna 150
     * @param MoodleQuickForm $mform
151
     * @return MoodleQuickForm $mform
1 efrain 152
     */
1441 ariadna 153
    public function setup_factor_form_definition_after_data(MoodleQuickForm $mform): MoodleQuickForm {
1 efrain 154
        global $OUTPUT, $SITE, $USER;
155
 
156
        // Array of elements to allow XSS.
157
        $xssallowedelements = [];
158
 
159
        $headingstring = $mform->elementExists('replaceid') ? 'replacefactor' : 'setupfactor';
160
        $mform->addElement('html', $OUTPUT->heading(get_string($headingstring, 'factor_totp'), 2));
161
 
162
        $html = \html_writer::tag('p', get_string('setupfactor:intro', 'factor_totp'));
163
        $mform->addElement('html', $html);
164
 
165
        // Device name.
166
        $html = \html_writer::tag('p', get_string('setupfactor:instructionsdevicename', 'factor_totp'), ['class' => 'bold']);
167
        $mform->addElement('html', $html);
168
 
169
        $mform->addElement('text', 'devicename', get_string('setupfactor:devicename', 'factor_totp'), [
170
            'placeholder' => get_string('devicenameexample', 'factor_totp'),
171
            'autofocus' => 'autofocus',
172
        ]);
173
        $mform->setType('devicename', PARAM_TEXT);
174
        $mform->addRule('devicename', get_string('required'), 'required', null, 'client');
175
 
176
        $html = \html_writer::tag('p', get_string('setupfactor:devicenameinfo', 'factor_totp'));
177
        $mform->addElement('static', 'devicenameinfo', '', $html);
178
 
179
        // Scan QR code.
180
        $html = \html_writer::tag('p', get_string('setupfactor:instructionsscan', 'factor_totp'), ['class' => 'bold']);
181
        $mform->addElement('html', $html);
182
 
183
        $secretfield = $mform->getElement('secret');
184
        $secret = $secretfield->getValue();
185
        $qrcode = $this->generate_qrcode($secret);
186
 
187
        $html = \html_writer::tag('p', $qrcode);
188
        $mform->addElement('static', 'scan', '', $html);
189
 
190
        // Enter manually.
191
        $secret = wordwrap($secret, 4, ' ', true) . '</code>';
192
        $secret = \html_writer::tag('code', $secret);
193
 
1441 ariadna 194
        $sitefullname = format_string($SITE->fullname, true, ['context' => system::instance()]);
195
 
1 efrain 196
        $manualtable = new \html_table();
197
        $manualtable->id = 'manualattributes';
198
        $manualtable->attributes['class'] = 'generaltable table table-bordered table-sm w-auto';
199
        $manualtable->attributes['style'] = 'width: auto;';
200
        $manualtable->data = [
201
            [get_string('setupfactor:key', 'factor_totp'), $secret],
1441 ariadna 202
            [get_string('setupfactor:account', 'factor_totp'), "{$sitefullname} ({$USER->username})"],
1 efrain 203
            [get_string('setupfactor:mode', 'factor_totp'), get_string('setupfactor:mode:timebased', 'factor_totp')],
204
        ];
205
 
206
        $html = \html_writer::table($manualtable);
207
        // Wrap the table in a couple of divs to be controlled via bootstrap.
208
        $html = \html_writer::div($html, 'collapse', ['id' => 'collapseManualAttributes']);
209
 
210
        $togglelink = \html_writer::tag('a', get_string('setupfactor:link', 'factor_totp'), [
1441 ariadna 211
            'data-bs-toggle' => 'collapse',
212
            'data-bs-target' => '#collapseManualAttributes',
1 efrain 213
            'aria-expanded' => 'false',
214
            'aria-controls' => 'collapseManualAttributes',
215
            'href' => '#',
216
        ]);
217
 
218
        $html = $togglelink . $html;
219
        $xssallowedelements[] = $mform->addElement('static', 'enter', '', $html);
220
 
221
        // Allow XSS.
222
        if (method_exists('MoodleQuickForm_static', 'set_allow_xss')) {
223
            foreach ($xssallowedelements as $xssallowedelement) {
224
                $xssallowedelement->set_allow_xss(true);
225
            }
226
        }
227
 
228
        // Verification.
229
        $html = \html_writer::tag('p', get_string('setupfactor:instructionsverification', 'factor_totp'), ['class' => 'bold']);
230
        $mform->addElement('html', $html);
231
 
232
        $verificationfield = new \tool_mfa\local\form\verification_field(
233
            attributes: ['class' => 'tool-mfa-verification-code'],
234
            auth: false,
235
            elementlabel: get_string('setupfactor:verificationcode', 'factor_totp'),
236
        );
237
        $mform->addElement($verificationfield);
238
        $mform->setType('verificationcode', PARAM_ALPHANUM);
239
        $mform->addRule('verificationcode', get_string('required'), 'required', null, 'client');
240
 
241
        return $mform;
242
    }
243
 
244
    /**
245
     * TOTP Factor implementation.
246
     *
247
     * @param array $data
248
     * @return array
249
     */
250
    public function setup_factor_form_validation(array $data): array {
251
        $errors = [];
252
 
1441 ariadna 253
        $totp = TOTP::create($data['secret'], clock: $this->clock);
254
        if (!$totp->verify($data['verificationcode'], $this->clock->time(), 1)) {
1 efrain 255
            $errors['verificationcode'] = get_string('error:wrongverification', 'factor_totp');
256
        }
257
 
258
        return $errors;
259
    }
260
 
261
    /**
262
     * TOTP Factor implementation.
263
     *
1441 ariadna 264
     * @param MoodleQuickForm $mform
265
     * @return MoodleQuickForm $mform
1 efrain 266
     */
1441 ariadna 267
    public function login_form_definition(MoodleQuickForm $mform): MoodleQuickForm {
1 efrain 268
 
269
        $mform->disable_form_change_checker();
270
        $mform->addElement(new \tool_mfa\local\form\verification_field());
271
        $mform->setType('verificationcode', PARAM_ALPHANUM);
272
 
273
        return $mform;
274
    }
275
 
276
    /**
277
     * TOTP Factor implementation.
278
     *
279
     * @param array $data
280
     * @return array
281
     */
282
    public function login_form_validation(array $data): array {
283
        global $USER;
284
        $factors = $this->get_active_user_factors($USER);
285
        $result = ['verificationcode' => get_string('error:wrongverification', 'factor_totp')];
1441 ariadna 286
        $window = get_config('factor_totp', 'window');
1 efrain 287
 
288
        foreach ($factors as $factor) {
1441 ariadna 289
            $totp = TOTP::create($factor->secret, clock: $this->clock);
1 efrain 290
            $factorresult = $this->validate_code($data['verificationcode'], $window, $totp, $factor);
1441 ariadna 291
            $time = userdate($this->clock->time(), get_string('systimeformat', 'factor_totp'));
1 efrain 292
 
293
            switch ($factorresult) {
294
                case self::TOTP_USED:
295
                    return ['verificationcode' => get_string('error:codealreadyused', 'factor_totp')];
296
 
297
                case self::TOTP_OLD:
298
                    return ['verificationcode' => get_string('error:oldcode', 'factor_totp', $time)];
299
 
300
                case self::TOTP_FUTURE:
301
                    return ['verificationcode' => get_string('error:futurecode', 'factor_totp', $time)];
302
 
303
                case self::TOTP_VALID:
304
                    $this->update_lastverified($factor->id);
305
                    return [];
306
 
307
                default:
308
                    continue(2);
309
            }
310
        }
311
        return $result;
312
    }
313
 
314
    /**
315
     * Checks the code for reuse, clock skew, and validity.
316
     *
317
     * @param string $code the code to check.
318
     * @param int $window the window to check validity for.
319
     * @param TOTP $totp the totp object to check against.
320
     * @param stdClass $factor the factor with information required.
321
     *
322
     * @return string constant with verification state.
323
     */
324
    public function validate_code(string $code, int $window, TOTP $totp, stdClass $factor): string {
325
        // First check if this code matches the last verified timestamp.
326
        $lastverified = $this->get_lastverified($factor->id);
327
        if ($lastverified > 0 && $totp->verify($code, $lastverified, $window)) {
328
            return self::TOTP_USED;
329
        }
330
 
1441 ariadna 331
        // Check if the code is valid, returning early.
332
        if ($totp->verify($code, $this->clock->time(), $window)) {
1 efrain 333
            return self::TOTP_VALID;
334
        }
1441 ariadna 335
 
336
        // Check for clock skew in the past and future 10 periods.
337
        for ($i = 1; $i <= 10; $i++) {
338
            $pasttimestamp = $this->clock->time() - $i * $totp->getPeriod();
339
            $futuretimestamp = $this->clock->time() + $i * $totp->getPeriod();
340
 
341
            if ($totp->verify($code, $pasttimestamp, $window)) {
342
                return self::TOTP_OLD;
343
            }
344
 
345
            if ($totp->verify($code, $futuretimestamp, $window)) {
346
                return self::TOTP_FUTURE;
347
            }
348
        }
349
 
350
        // In all other cases, the code is invalid.
351
        return self::TOTP_INVALID;
1 efrain 352
    }
353
 
354
    /**
355
     * Generates cryptographically secure pseudo-random 16-digit secret code.
356
     *
357
     * @return string
358
     */
359
    public function generate_secret_code(): string {
1441 ariadna 360
        $totp = TOTP::create(clock: $this->clock);
1 efrain 361
        return substr($totp->getSecret(), 0, 16);
362
    }
363
 
364
    /**
365
     * TOTP Factor implementation.
366
     *
367
     * @param stdClass $data
368
     * @return stdClass|null the factor record, or null.
369
     */
370
    public function setup_user_factor(stdClass $data): stdClass|null {
371
        global $DB, $USER;
372
 
373
        if (!empty($data->secret)) {
374
            $row = new stdClass();
375
            $row->userid = $USER->id;
376
            $row->factor = $this->name;
377
            $row->secret = $data->secret;
378
            $row->label = $data->devicename;
1441 ariadna 379
            $row->timecreated = $this->clock->time();
1 efrain 380
            $row->createdfromip = $USER->lastip;
1441 ariadna 381
            $row->timemodified = $this->clock->time();
1 efrain 382
            $row->lastverified = 0;
383
            $row->revoked = 0;
384
 
385
            // Check if a record with this configuration already exists, warning the user accordingly.
386
            $record = $DB->get_record('tool_mfa', [
387
                'userid' => $row->userid,
388
                'secret' => $row->secret,
389
                'factor' => $row->factor,
390
            ], '*', IGNORE_MULTIPLE);
391
            if ($record) {
392
                \core\notification::warning(get_string('error:alreadyregistered', 'factor_totp'));
393
                return null;
394
            }
395
 
396
            $id = $DB->insert_record('tool_mfa', $row);
397
            $record = $DB->get_record('tool_mfa', ['id' => $id]);
398
            $this->create_event_after_factor_setup($USER);
399
 
400
            return $record;
401
        }
402
 
403
        return null;
404
    }
405
 
406
    /**
407
     * TOTP Factor implementation with replacement of existing factor.
408
     *
409
     * @param stdClass $data The new factor data.
410
     * @param int $id The id of the factor to replace.
411
     * @return stdClass|null the factor record, or null.
412
     */
413
    public function replace_user_factor(stdClass $data, int $id): stdClass|null {
414
        global $DB, $USER;
415
 
416
        $oldrecord = $DB->get_record('tool_mfa', ['id' => $id]);
417
        $newrecord = null;
418
 
419
        // Ensure we have a valid existing record before setting the new one.
420
        if ($oldrecord) {
421
            $newrecord = $this->setup_user_factor($data);
422
        }
423
        // Ensure the new record was created before revoking the old.
424
        if ($newrecord) {
425
            $this->revoke_user_factor($id);
426
        } else {
427
            \core\notification::warning(get_string('error:couldnotreplace', 'tool_mfa'));
428
            return null;
429
        }
430
        $this->create_event_after_factor_setup($USER);
431
 
432
        return $newrecord ?? null;
433
    }
434
 
435
    /**
436
     * TOTP Factor implementation.
437
     *
438
     * @param stdClass $user the user to check against.
439
     * @return array
440
     */
441
    public function get_all_user_factors($user): array {
442
        global $DB;
443
        return $DB->get_records('tool_mfa', ['userid' => $user->id, 'factor' => $this->name]);
444
    }
445
 
446
    /**
447
     * TOTP Factor implementation.
448
     *
449
     * {@inheritDoc}
450
     */
451
    public function has_revoke(): bool {
452
        return true;
453
    }
454
 
455
    /**
456
     * TOTP Factor implementation.
457
     */
458
    public function has_replace(): bool {
459
        return true;
460
    }
461
 
462
    /**
463
     * TOTP Factor implementation.
464
     *
465
     * {@inheritDoc}
466
     */
467
    public function has_setup(): bool {
468
        return true;
469
    }
470
 
471
    /**
472
     * TOTP Factor implementation
473
     *
474
     * {@inheritDoc}
475
     */
476
    public function show_setup_buttons(): bool {
477
        return true;
478
    }
479
 
480
    /**
481
     * TOTP Factor implementation.
482
     * Empty override of parent.
483
     *
484
     * {@inheritDoc}
485
     */
486
    public function post_pass_state(): void {
487
        return;
488
    }
489
 
490
    /**
491
     * TOTP Factor implementation.
492
     * TOTP cannot return fail state.
493
     *
494
     * @param stdClass $user
495
     */
496
    public function possible_states(stdClass $user): array {
497
        return [
498
            \tool_mfa\plugininfo\factor::STATE_PASS,
499
            \tool_mfa\plugininfo\factor::STATE_NEUTRAL,
500
            \tool_mfa\plugininfo\factor::STATE_UNKNOWN,
501
        ];
502
    }
503
 
504
    /**
505
     * TOTP Factor implementation.
506
     *
507
     * {@inheritDoc}
508
     */
509
    public function get_setup_string(): string {
510
        return get_string('setupfactorbutton', 'factor_totp');
511
    }
512
 
513
    /**
514
     * Gets the string for manage button on preferences page.
515
     *
516
     * @return string
517
     */
518
    public function get_manage_string(): string {
519
        return get_string('managefactorbutton', 'factor_totp');
520
    }
521
}