AutorÃa | Ultima modificación | Ver Log |
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle. If not, see <http://www.gnu.org/licenses/>.
namespace factor_totp;
defined('MOODLE_INTERNAL') || die();
require_once($CFG->libdir.'/tcpdf/tcpdf_barcodes_2d.php');
require_once(__DIR__.'/../extlib/OTPHP/OTPInterface.php');
require_once(__DIR__.'/../extlib/OTPHP/TOTPInterface.php');
require_once(__DIR__.'/../extlib/OTPHP/ParameterTrait.php');
require_once(__DIR__.'/../extlib/OTPHP/OTP.php');
require_once(__DIR__.'/../extlib/OTPHP/TOTP.php');
require_once(__DIR__.'/../extlib/Assert/Assertion.php');
require_once(__DIR__.'/../extlib/Assert/AssertionFailedException.php');
require_once(__DIR__.'/../extlib/Assert/InvalidArgumentException.php');
require_once(__DIR__.'/../extlib/ParagonIE/ConstantTime/EncoderInterface.php');
require_once(__DIR__.'/../extlib/ParagonIE/ConstantTime/Binary.php');
require_once(__DIR__.'/../extlib/ParagonIE/ConstantTime/Base32.php');
use tool_mfa\local\factor\object_factor_base;
use OTPHP\TOTP;
use stdClass;
/**
* TOTP factor class.
*
* @package factor_totp
* @subpackage tool_mfa
* @author Mikhail Golenkov <golenkovm@gmail.com>
* @copyright Catalyst IT
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
*/
class factor extends object_factor_base {
/** @var string */
const TOTP_OLD = 'old';
/** @var string */
const TOTP_FUTURE = 'future';
/** @var string */
const TOTP_USED = 'used';
/** @var string */
const TOTP_VALID = 'valid';
/** @var string */
const TOTP_INVALID = 'invalid';
/** @var string Factor icon */
protected $icon = 'fa-mobile-screen';
/**
* Generates TOTP URI for given secret key.
* Uses site name, hostname and user name to make GA account look like:
* "Sitename hostname (username)".
*
* @param string $secret
* @return string
*/
public function generate_totp_uri(string $secret): string {
global $USER, $SITE, $CFG;
$host = parse_url($CFG->wwwroot, PHP_URL_HOST);
$sitename = str_replace(':', '', $SITE->fullname);
$issuer = $sitename.' '.$host;
$totp = TOTP::create($secret);
$totp->setLabel($USER->username);
$totp->setIssuer($issuer);
return $totp->getProvisioningUri();
}
/**
* Generates HTML sting with QR code for given secret key.
*
* @param string $secret
* @return string
*/
public function generate_qrcode(string $secret): string {
$uri = $this->generate_totp_uri($secret);
$qrcode = new \TCPDF2DBarcode($uri, 'QRCODE');
$image = $qrcode->getBarcodePngData(7, 7);
$html = \html_writer::img('data:image/png;base64,' . base64_encode($image), '', ['width' => '150px']);
return $html;
}
/**
* TOTP state
*
* {@inheritDoc}
*/
public function get_state(): string {
global $USER;
$userfactors = $this->get_active_user_factors($USER);
// If no codes are setup then we must be neutral not unknown.
if (count($userfactors) == 0) {
return \tool_mfa\plugininfo\factor::STATE_NEUTRAL;
}
return parent::get_state();
}
/**
* TOTP Factor implementation.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
*/
public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
$secret = $this->generate_secret_code();
$mform->addElement('hidden', 'secret', $secret);
$mform->setType('secret', PARAM_ALPHANUM);
return $mform;
}
/**
* TOTP Factor implementation.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
*/
public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm {
global $OUTPUT, $SITE, $USER;
// Array of elements to allow XSS.
$xssallowedelements = [];
$headingstring = $mform->elementExists('replaceid') ? 'replacefactor' : 'setupfactor';
$mform->addElement('html', $OUTPUT->heading(get_string($headingstring, 'factor_totp'), 2));
$html = \html_writer::tag('p', get_string('setupfactor:intro', 'factor_totp'));
$mform->addElement('html', $html);
// Device name.
$html = \html_writer::tag('p', get_string('setupfactor:instructionsdevicename', 'factor_totp'), ['class' => 'bold']);
$mform->addElement('html', $html);
$mform->addElement('text', 'devicename', get_string('setupfactor:devicename', 'factor_totp'), [
'placeholder' => get_string('devicenameexample', 'factor_totp'),
'autofocus' => 'autofocus',
]);
$mform->setType('devicename', PARAM_TEXT);
$mform->addRule('devicename', get_string('required'), 'required', null, 'client');
$html = \html_writer::tag('p', get_string('setupfactor:devicenameinfo', 'factor_totp'));
$mform->addElement('static', 'devicenameinfo', '', $html);
// Scan QR code.
$html = \html_writer::tag('p', get_string('setupfactor:instructionsscan', 'factor_totp'), ['class' => 'bold']);
$mform->addElement('html', $html);
$secretfield = $mform->getElement('secret');
$secret = $secretfield->getValue();
$qrcode = $this->generate_qrcode($secret);
$html = \html_writer::tag('p', $qrcode);
$mform->addElement('static', 'scan', '', $html);
// Enter manually.
$secret = wordwrap($secret, 4, ' ', true) . '</code>';
$secret = \html_writer::tag('code', $secret);
$manualtable = new \html_table();
$manualtable->id = 'manualattributes';
$manualtable->attributes['class'] = 'generaltable table table-bordered table-sm w-auto';
$manualtable->attributes['style'] = 'width: auto;';
$manualtable->data = [
[get_string('setupfactor:key', 'factor_totp'), $secret],
[get_string('setupfactor:account', 'factor_totp'), "$SITE->fullname ($USER->username)"],
[get_string('setupfactor:mode', 'factor_totp'), get_string('setupfactor:mode:timebased', 'factor_totp')],
];
$html = \html_writer::table($manualtable);
// Wrap the table in a couple of divs to be controlled via bootstrap.
$html = \html_writer::div($html, 'collapse', ['id' => 'collapseManualAttributes']);
$togglelink = \html_writer::tag('a', get_string('setupfactor:link', 'factor_totp'), [
'data-toggle' => 'collapse',
'data-target' => '#collapseManualAttributes',
'aria-expanded' => 'false',
'aria-controls' => 'collapseManualAttributes',
'href' => '#',
]);
$html = $togglelink . $html;
$xssallowedelements[] = $mform->addElement('static', 'enter', '', $html);
// Allow XSS.
if (method_exists('MoodleQuickForm_static', 'set_allow_xss')) {
foreach ($xssallowedelements as $xssallowedelement) {
$xssallowedelement->set_allow_xss(true);
}
}
// Verification.
$html = \html_writer::tag('p', get_string('setupfactor:instructionsverification', 'factor_totp'), ['class' => 'bold']);
$mform->addElement('html', $html);
$verificationfield = new \tool_mfa\local\form\verification_field(
attributes: ['class' => 'tool-mfa-verification-code'],
auth: false,
elementlabel: get_string('setupfactor:verificationcode', 'factor_totp'),
);
$mform->addElement($verificationfield);
$mform->setType('verificationcode', PARAM_ALPHANUM);
$mform->addRule('verificationcode', get_string('required'), 'required', null, 'client');
return $mform;
}
/**
* TOTP Factor implementation.
*
* @param array $data
* @return array
*/
public function setup_factor_form_validation(array $data): array {
$errors = [];
$totp = TOTP::create($data['secret']);
if (!$totp->verify($data['verificationcode'], time(), 1)) {
$errors['verificationcode'] = get_string('error:wrongverification', 'factor_totp');
}
return $errors;
}
/**
* TOTP Factor implementation.
*
* @param \MoodleQuickForm $mform
* @return \MoodleQuickForm $mform
*/
public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
$mform->disable_form_change_checker();
$mform->addElement(new \tool_mfa\local\form\verification_field());
$mform->setType('verificationcode', PARAM_ALPHANUM);
return $mform;
}
/**
* TOTP Factor implementation.
*
* @param array $data
* @return array
*/
public function login_form_validation(array $data): array {
global $USER;
$factors = $this->get_active_user_factors($USER);
$result = ['verificationcode' => get_string('error:wrongverification', 'factor_totp')];
$windowconfig = get_config('factor_totp', 'window');
foreach ($factors as $factor) {
$totp = TOTP::create($factor->secret);
// Convert seconds to windows.
$window = (int) floor($windowconfig / $totp->getPeriod());
$factorresult = $this->validate_code($data['verificationcode'], $window, $totp, $factor);
$time = userdate(time(), get_string('systimeformat', 'factor_totp'));
switch ($factorresult) {
case self::TOTP_USED:
return ['verificationcode' => get_string('error:codealreadyused', 'factor_totp')];
case self::TOTP_OLD:
return ['verificationcode' => get_string('error:oldcode', 'factor_totp', $time)];
case self::TOTP_FUTURE:
return ['verificationcode' => get_string('error:futurecode', 'factor_totp', $time)];
case self::TOTP_VALID:
$this->update_lastverified($factor->id);
return [];
default:
continue(2);
}
}
return $result;
}
/**
* Checks the code for reuse, clock skew, and validity.
*
* @param string $code the code to check.
* @param int $window the window to check validity for.
* @param TOTP $totp the totp object to check against.
* @param stdClass $factor the factor with information required.
*
* @return string constant with verification state.
*/
public function validate_code(string $code, int $window, TOTP $totp, stdClass $factor): string {
// First check if this code matches the last verified timestamp.
$lastverified = $this->get_lastverified($factor->id);
if ($lastverified > 0 && $totp->verify($code, $lastverified, $window)) {
return self::TOTP_USED;
}
// The window in which to check for clock skew, 5 increments past valid window.
$skewwindow = $window + 5;
$pasttimestamp = time() - ($skewwindow * $totp->getPeriod());
$futuretimestamp = time() + ($skewwindow * $totp->getPeriod());
if ($totp->verify($code, time(), $window)) {
return self::TOTP_VALID;
} else if ($totp->verify($code, $pasttimestamp, $skewwindow)) {
// Check for clock skew in the past 10 periods.
return self::TOTP_OLD;
} else if ($totp->verify($code, $futuretimestamp, $skewwindow)) {
// Check for clock skew in the future 10 periods.
return self::TOTP_FUTURE;
} else {
// In all other cases, code is invalid.
return self::TOTP_INVALID;
}
}
/**
* Generates cryptographically secure pseudo-random 16-digit secret code.
*
* @return string
*/
public function generate_secret_code(): string {
$totp = TOTP::create();
return substr($totp->getSecret(), 0, 16);
}
/**
* TOTP Factor implementation.
*
* @param stdClass $data
* @return stdClass|null the factor record, or null.
*/
public function setup_user_factor(stdClass $data): stdClass|null {
global $DB, $USER;
if (!empty($data->secret)) {
$row = new stdClass();
$row->userid = $USER->id;
$row->factor = $this->name;
$row->secret = $data->secret;
$row->label = $data->devicename;
$row->timecreated = time();
$row->createdfromip = $USER->lastip;
$row->timemodified = time();
$row->lastverified = 0;
$row->revoked = 0;
// Check if a record with this configuration already exists, warning the user accordingly.
$record = $DB->get_record('tool_mfa', [
'userid' => $row->userid,
'secret' => $row->secret,
'factor' => $row->factor,
], '*', IGNORE_MULTIPLE);
if ($record) {
\core\notification::warning(get_string('error:alreadyregistered', 'factor_totp'));
return null;
}
$id = $DB->insert_record('tool_mfa', $row);
$record = $DB->get_record('tool_mfa', ['id' => $id]);
$this->create_event_after_factor_setup($USER);
return $record;
}
return null;
}
/**
* TOTP Factor implementation with replacement of existing factor.
*
* @param stdClass $data The new factor data.
* @param int $id The id of the factor to replace.
* @return stdClass|null the factor record, or null.
*/
public function replace_user_factor(stdClass $data, int $id): stdClass|null {
global $DB, $USER;
$oldrecord = $DB->get_record('tool_mfa', ['id' => $id]);
$newrecord = null;
// Ensure we have a valid existing record before setting the new one.
if ($oldrecord) {
$newrecord = $this->setup_user_factor($data);
}
// Ensure the new record was created before revoking the old.
if ($newrecord) {
$this->revoke_user_factor($id);
} else {
\core\notification::warning(get_string('error:couldnotreplace', 'tool_mfa'));
return null;
}
$this->create_event_after_factor_setup($USER);
return $newrecord ?? null;
}
/**
* TOTP Factor implementation.
*
* @param stdClass $user the user to check against.
* @return array
*/
public function get_all_user_factors($user): array {
global $DB;
return $DB->get_records('tool_mfa', ['userid' => $user->id, 'factor' => $this->name]);
}
/**
* TOTP Factor implementation.
*
* {@inheritDoc}
*/
public function has_revoke(): bool {
return true;
}
/**
* TOTP Factor implementation.
*/
public function has_replace(): bool {
return true;
}
/**
* TOTP Factor implementation.
*
* {@inheritDoc}
*/
public function has_setup(): bool {
return true;
}
/**
* TOTP Factor implementation
*
* {@inheritDoc}
*/
public function show_setup_buttons(): bool {
return true;
}
/**
* TOTP Factor implementation.
* Empty override of parent.
*
* {@inheritDoc}
*/
public function post_pass_state(): void {
return;
}
/**
* TOTP Factor implementation.
* TOTP cannot return fail state.
*
* @param stdClass $user
*/
public function possible_states(stdClass $user): array {
return [
\tool_mfa\plugininfo\factor::STATE_PASS,
\tool_mfa\plugininfo\factor::STATE_NEUTRAL,
\tool_mfa\plugininfo\factor::STATE_UNKNOWN,
];
}
/**
* TOTP Factor implementation.
*
* {@inheritDoc}
*/
public function get_setup_string(): string {
return get_string('setupfactorbutton', 'factor_totp');
}
/**
* Gets the string for manage button on preferences page.
*
* @return string
*/
public function get_manage_string(): string {
return get_string('managefactorbutton', 'factor_totp');
}
}