Merge branch 'MDL-79678_upgrade-otphp' of https://github.com/ziegenberg/moodle
This commit is contained in:
@@ -25,16 +25,16 @@ 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 MoodleQuickForm;
|
||||
use tool_mfa\local\factor\object_factor_base;
|
||||
use OTPHP\TOTP;
|
||||
use stdClass;
|
||||
use core\clock;
|
||||
use core\di;
|
||||
|
||||
/**
|
||||
* TOTP factor class.
|
||||
@@ -65,6 +65,19 @@ class factor extends object_factor_base {
|
||||
/** @var string Factor icon */
|
||||
protected $icon = 'fa-mobile-screen';
|
||||
|
||||
/** @var clock */
|
||||
private readonly clock $clock;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $name
|
||||
*/
|
||||
public function __construct(string $name) {
|
||||
parent::__construct($name);
|
||||
$this->clock = di::get(clock::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generates TOTP URI for given secret key.
|
||||
@@ -79,7 +92,7 @@ class factor extends object_factor_base {
|
||||
$host = parse_url($CFG->wwwroot, PHP_URL_HOST);
|
||||
$sitename = str_replace(':', '', $SITE->fullname);
|
||||
$issuer = $sitename.' '.$host;
|
||||
$totp = TOTP::create($secret);
|
||||
$totp = TOTP::create($secret, clock: $this->clock);
|
||||
$totp->setLabel($USER->username);
|
||||
$totp->setIssuer($issuer);
|
||||
return $totp->getProvisioningUri();
|
||||
@@ -119,10 +132,10 @@ class factor extends object_factor_base {
|
||||
/**
|
||||
* TOTP Factor implementation.
|
||||
*
|
||||
* @param \MoodleQuickForm $mform
|
||||
* @return \MoodleQuickForm $mform
|
||||
* @param MoodleQuickForm $mform
|
||||
* @return MoodleQuickForm $mform
|
||||
*/
|
||||
public function setup_factor_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
|
||||
public function setup_factor_form_definition(MoodleQuickForm $mform): MoodleQuickForm {
|
||||
$secret = $this->generate_secret_code();
|
||||
$mform->addElement('hidden', 'secret', $secret);
|
||||
$mform->setType('secret', PARAM_ALPHANUM);
|
||||
@@ -133,10 +146,10 @@ class factor extends object_factor_base {
|
||||
/**
|
||||
* TOTP Factor implementation.
|
||||
*
|
||||
* @param \MoodleQuickForm $mform
|
||||
* @return \MoodleQuickForm $mform
|
||||
* @param MoodleQuickForm $mform
|
||||
* @return MoodleQuickForm $mform
|
||||
*/
|
||||
public function setup_factor_form_definition_after_data(\MoodleQuickForm $mform): \MoodleQuickForm {
|
||||
public function setup_factor_form_definition_after_data(MoodleQuickForm $mform): MoodleQuickForm {
|
||||
global $OUTPUT, $SITE, $USER;
|
||||
|
||||
// Array of elements to allow XSS.
|
||||
@@ -234,8 +247,8 @@ class factor extends object_factor_base {
|
||||
public function setup_factor_form_validation(array $data): array {
|
||||
$errors = [];
|
||||
|
||||
$totp = TOTP::create($data['secret']);
|
||||
if (!$totp->verify($data['verificationcode'], time(), 1)) {
|
||||
$totp = TOTP::create($data['secret'], clock: $this->clock);
|
||||
if (!$totp->verify($data['verificationcode'], $this->clock->time(), 1)) {
|
||||
$errors['verificationcode'] = get_string('error:wrongverification', 'factor_totp');
|
||||
}
|
||||
|
||||
@@ -245,10 +258,10 @@ class factor extends object_factor_base {
|
||||
/**
|
||||
* TOTP Factor implementation.
|
||||
*
|
||||
* @param \MoodleQuickForm $mform
|
||||
* @return \MoodleQuickForm $mform
|
||||
* @param MoodleQuickForm $mform
|
||||
* @return MoodleQuickForm $mform
|
||||
*/
|
||||
public function login_form_definition(\MoodleQuickForm $mform): \MoodleQuickForm {
|
||||
public function login_form_definition(MoodleQuickForm $mform): MoodleQuickForm {
|
||||
|
||||
$mform->disable_form_change_checker();
|
||||
$mform->addElement(new \tool_mfa\local\form\verification_field());
|
||||
@@ -267,14 +280,12 @@ class factor extends object_factor_base {
|
||||
global $USER;
|
||||
$factors = $this->get_active_user_factors($USER);
|
||||
$result = ['verificationcode' => get_string('error:wrongverification', 'factor_totp')];
|
||||
$windowconfig = get_config('factor_totp', 'window');
|
||||
$window = get_config('factor_totp', 'window');
|
||||
|
||||
foreach ($factors as $factor) {
|
||||
$totp = TOTP::create($factor->secret);
|
||||
// Convert seconds to windows.
|
||||
$window = (int) floor($windowconfig / $totp->getPeriod());
|
||||
$totp = TOTP::create($factor->secret, clock: $this->clock);
|
||||
$factorresult = $this->validate_code($data['verificationcode'], $window, $totp, $factor);
|
||||
$time = userdate(time(), get_string('systimeformat', 'factor_totp'));
|
||||
$time = userdate($this->clock->time(), get_string('systimeformat', 'factor_totp'));
|
||||
|
||||
switch ($factorresult) {
|
||||
case self::TOTP_USED:
|
||||
@@ -314,23 +325,27 @@ class factor extends object_factor_base {
|
||||
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)) {
|
||||
// Check if the code is valid, returning early.
|
||||
if ($totp->verify($code, $this->clock->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;
|
||||
}
|
||||
|
||||
// Check for clock skew in the past and future 10 periods.
|
||||
for ($i = 1; $i <= 10; $i++) {
|
||||
$pasttimestamp = $this->clock->time() - $i * $totp->getPeriod();
|
||||
$futuretimestamp = $this->clock->time() + $i * $totp->getPeriod();
|
||||
|
||||
if ($totp->verify($code, $pasttimestamp, $window)) {
|
||||
return self::TOTP_OLD;
|
||||
}
|
||||
|
||||
if ($totp->verify($code, $futuretimestamp, $window)) {
|
||||
return self::TOTP_FUTURE;
|
||||
}
|
||||
}
|
||||
|
||||
// In all other cases, the code is invalid.
|
||||
return self::TOTP_INVALID;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -339,7 +354,7 @@ class factor extends object_factor_base {
|
||||
* @return string
|
||||
*/
|
||||
public function generate_secret_code(): string {
|
||||
$totp = TOTP::create();
|
||||
$totp = TOTP::create(clock: $this->clock);
|
||||
return substr($totp->getSecret(), 0, 16);
|
||||
}
|
||||
|
||||
@@ -358,9 +373,9 @@ class factor extends object_factor_base {
|
||||
$row->factor = $this->name;
|
||||
$row->secret = $data->secret;
|
||||
$row->label = $data->devicename;
|
||||
$row->timecreated = time();
|
||||
$row->timecreated = $this->clock->time();
|
||||
$row->createdfromip = $USER->lastip;
|
||||
$row->timemodified = time();
|
||||
$row->timemodified = $this->clock->time();
|
||||
$row->lastverified = 0;
|
||||
$row->revoked = 0;
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?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/>.
|
||||
|
||||
/**
|
||||
* factor_totp upgrade library.
|
||||
*
|
||||
* @package factor_totp
|
||||
* @copyright 2024 Daniel Ziegenberg <[email protected]>
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
|
||||
/**
|
||||
* Factor totp upgrade helper function
|
||||
*
|
||||
* @param int $oldversion
|
||||
*/
|
||||
function xmldb_factor_totp_upgrade($oldversion): bool {
|
||||
if ($oldversion < 2024081600) {
|
||||
|
||||
$window = get_config('factor_totp', 'window');
|
||||
if ($window && $window >= 30) {
|
||||
set_config('window', 29, 'factor_totp');
|
||||
}
|
||||
|
||||
// Savepoint reached.
|
||||
upgrade_plugin_savepoint(true, 2024081600, 'factor', 'auth');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*
|
||||
* LICENSE
|
||||
*
|
||||
* This source file is subject to the MIT license that is bundled
|
||||
* with this package in the file LICENSE.txt.
|
||||
* If you did not receive a copy of the license and are unable to
|
||||
* obtain it through the world-wide-web, please send an email
|
||||
* to [email protected] so I can send you a copy immediately.
|
||||
*/
|
||||
|
||||
namespace Assert;
|
||||
|
||||
use Throwable;
|
||||
|
||||
interface AssertionFailedException extends Throwable
|
||||
{
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
public function getPropertyPath();
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*/
|
||||
public function getValue();
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getConstraints(): array;
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Assert
|
||||
*
|
||||
* LICENSE
|
||||
*
|
||||
* This source file is subject to the MIT license that is bundled
|
||||
* with this package in the file LICENSE.txt.
|
||||
* If you did not receive a copy of the license and are unable to
|
||||
* obtain it through the world-wide-web, please send an email
|
||||
* to [email protected] so I can send you a copy immediately.
|
||||
*/
|
||||
|
||||
namespace Assert;
|
||||
|
||||
class InvalidArgumentException extends \InvalidArgumentException implements AssertionFailedException
|
||||
{
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
private $propertyPath;
|
||||
|
||||
/**
|
||||
* @var mixed
|
||||
*/
|
||||
private $value;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
private $constraints;
|
||||
|
||||
public function __construct($message, $code, string $propertyPath = null, $value = null, array $constraints = [])
|
||||
{
|
||||
parent::__construct($message, $code);
|
||||
|
||||
$this->propertyPath = $propertyPath;
|
||||
$this->value = $value;
|
||||
$this->constraints = $constraints;
|
||||
}
|
||||
|
||||
/**
|
||||
* User controlled way to define a sub-property causing
|
||||
* the failure of a currently asserted objects.
|
||||
*
|
||||
* Useful to transport information about the nature of the error
|
||||
* back to higher layers.
|
||||
*
|
||||
* @return string|null
|
||||
*/
|
||||
public function getPropertyPath()
|
||||
{
|
||||
return $this->propertyPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value that caused the assertion to fail.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getValue()
|
||||
{
|
||||
return $this->value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the constraints that applied to the failed assertion.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function getConstraints(): array
|
||||
{
|
||||
return $this->constraints;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
Copyright (c) 2011-2013, Benjamin Eberlei
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
- Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
- Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "beberlei/assert",
|
||||
"description": "Thin assertion library for input validation in business models.",
|
||||
"authors": [
|
||||
{"name": "Benjamin Eberlei", "email": "[email protected]"}
|
||||
],
|
||||
"license": "BSD-2-Clause",
|
||||
"keywords": ["assert", "assertion", "validation"],
|
||||
"require": {
|
||||
"ext-mbstring": "*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"Assert": "lib/"
|
||||
},
|
||||
"files": ["lib/Assert/functions.php"]
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.0.x-dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
Assert 2.1
|
||||
--------------
|
||||
https://github.com/beberlei/assert/releases/tag/v2.1
|
||||
|
||||
Instructions to import WebAuthn into Moodle:
|
||||
|
||||
1. Download the latest release from https://github.com/beberlei/assert/releases/tag/vx.x
|
||||
(choose "Source code")
|
||||
2. Unzip the source code
|
||||
3. Copy the following files from assert-x.x/lib/Assert into admin/tool/mfa/factor/totp/extlib/Assert:
|
||||
1. Assertion.php
|
||||
2. AssertionFailedException.php
|
||||
3. InvalidArgumentException.php
|
||||
|
||||
4. Copy the following files from assert-x.x into admin/tool/mfa/factor/totp/extlib/Assert:
|
||||
1. LICENSE
|
||||
2. composer.json
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OTPHP;
|
||||
|
||||
use DateTimeImmutable;
|
||||
use Psr\Clock\ClockInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class InternalClock implements ClockInterface
|
||||
{
|
||||
public function now(): DateTimeImmutable
|
||||
{
|
||||
return new DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
@@ -2,42 +2,33 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2014-2018 Spomky-Labs
|
||||
*
|
||||
* This software may be modified and distributed under the terms
|
||||
* of the MIT license. See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace OTPHP;
|
||||
|
||||
use Assert\Assertion;
|
||||
use Exception;
|
||||
use InvalidArgumentException;
|
||||
use ParagonIE\ConstantTime\Base32;
|
||||
use RuntimeException;
|
||||
use function assert;
|
||||
use function chr;
|
||||
use function count;
|
||||
use function is_string;
|
||||
use const STR_PAD_LEFT;
|
||||
|
||||
abstract class OTP implements OTPInterface
|
||||
{
|
||||
use ParameterTrait;
|
||||
|
||||
/**
|
||||
* OTP constructor.
|
||||
*
|
||||
* @param string|null $secret
|
||||
* @param string $digest
|
||||
* @param int $digits
|
||||
*/
|
||||
protected function __construct($secret, string $digest, int $digits)
|
||||
{
|
||||
$this->setSecret($secret);
|
||||
$this->setDigest($digest);
|
||||
$this->setDigits($digits);
|
||||
}
|
||||
private const DEFAULT_SECRET_SIZE = 64;
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* @param non-empty-string $secret
|
||||
*/
|
||||
public function getQrCodeUri(string $uri = 'https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl={PROVISIONING_URI}', string $placeholder = '{PROVISIONING_URI}'): string
|
||||
protected function __construct(string $secret)
|
||||
{
|
||||
$this->setSecret($secret);
|
||||
}
|
||||
|
||||
public function getQrCodeUri(string $uri, string $placeholder): string
|
||||
{
|
||||
$provisioning_uri = urlencode($this->getProvisioningUri());
|
||||
|
||||
@@ -45,38 +36,52 @@ abstract class OTP implements OTPInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $input
|
||||
* @param 0|positive-int $input
|
||||
*/
|
||||
public function at(int $input): string
|
||||
{
|
||||
return $this->generateOTP($input);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return non-empty-string
|
||||
*/
|
||||
final protected static function generateSecret(): string
|
||||
{
|
||||
return Base32::encodeUpper(random_bytes(self::DEFAULT_SECRET_SIZE));
|
||||
}
|
||||
|
||||
/**
|
||||
* The OTP at the specified input.
|
||||
*
|
||||
* @return string The OTP at the specified input
|
||||
* @param 0|positive-int $input
|
||||
*
|
||||
* @return non-empty-string
|
||||
*/
|
||||
protected function generateOTP(int $input): string
|
||||
{
|
||||
$hash = hash_hmac($this->getDigest(), $this->intToByteString($input), $this->getDecodedSecret());
|
||||
$hmac = [];
|
||||
foreach (str_split($hash, 2) as $hex) {
|
||||
$hmac[] = hexdec($hex);
|
||||
}
|
||||
$offset = $hmac[count($hmac) - 1] & 0xF;
|
||||
$code = ($hmac[$offset + 0] & 0x7F) << 24 | ($hmac[$offset + 1] & 0xFF) << 16 | ($hmac[$offset + 2] & 0xFF) << 8 | ($hmac[$offset + 3] & 0xFF);
|
||||
$otp = $code % pow(10, $this->getDigits());
|
||||
$hash = hash_hmac($this->getDigest(), $this->intToByteString($input), $this->getDecodedSecret(), true);
|
||||
$unpacked = unpack('C*', $hash);
|
||||
$unpacked !== false || throw new InvalidArgumentException('Invalid data.');
|
||||
$hmac = array_values($unpacked);
|
||||
|
||||
$offset = ($hmac[count($hmac) - 1] & 0xF);
|
||||
$code = ($hmac[$offset] & 0x7F) << 24 | ($hmac[$offset + 1] & 0xFF) << 16 | ($hmac[$offset + 2] & 0xFF) << 8 | ($hmac[$offset + 3] & 0xFF);
|
||||
$otp = $code % (10 ** $this->getDigits());
|
||||
|
||||
return str_pad((string) $otp, $this->getDigits(), '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* @param array<non-empty-string, mixed> $options
|
||||
*/
|
||||
public function at(int $timestamp): string
|
||||
protected function filterOptions(array &$options): void
|
||||
{
|
||||
return $this->generateOTP($timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $options
|
||||
*/
|
||||
protected function filterOptions(array &$options)
|
||||
{
|
||||
foreach (['algorithm' => 'sha1', 'period' => 30, 'digits' => 6] as $key => $default) {
|
||||
foreach ([
|
||||
'algorithm' => 'sha1',
|
||||
'period' => 30,
|
||||
'digits' => 6,
|
||||
] as $key => $default) {
|
||||
if (isset($options[$key]) && $default === $options[$key]) {
|
||||
unset($options[$key]);
|
||||
}
|
||||
@@ -86,61 +91,60 @@ abstract class OTP implements OTPInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $type
|
||||
* @param array $options
|
||||
* @param non-empty-string $type
|
||||
* @param array<non-empty-string, mixed> $options
|
||||
*
|
||||
* @return string
|
||||
* @return non-empty-string
|
||||
*/
|
||||
protected function generateURI(string $type, array $options): string
|
||||
{
|
||||
$label = $this->getLabel();
|
||||
Assertion::string($label, 'The label is not set.');
|
||||
Assertion::false($this->hasColon($label), 'Label must not contain a colon.');
|
||||
$options = array_merge($options, $this->getParameters());
|
||||
is_string($label) || throw new InvalidArgumentException('The label is not set.');
|
||||
$this->hasColon($label) === false || throw new InvalidArgumentException('Label must not contain a colon.');
|
||||
$options = [...$options, ...$this->getParameters()];
|
||||
$this->filterOptions($options);
|
||||
$params = str_replace(['+', '%7E'], ['%20', '~'], http_build_query($options, '', '&'));
|
||||
|
||||
return sprintf('otpauth://%s/%s?%s', $type, rawurlencode((null !== $this->getIssuer() ? $this->getIssuer().':' : '').$label), $params);
|
||||
return sprintf(
|
||||
'otpauth://%s/%s?%s',
|
||||
$type,
|
||||
rawurlencode(($this->getIssuer() !== null ? $this->getIssuer() . ':' : '') . $label),
|
||||
$params
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
private function getDecodedSecret(): string
|
||||
{
|
||||
try {
|
||||
$secret = Base32::decodeUpper($this->getSecret());
|
||||
} catch (\Exception $e) {
|
||||
throw new \RuntimeException('Unable to decode the secret. Is it correctly base32 encoded?');
|
||||
}
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $int
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function intToByteString(int $int): string
|
||||
{
|
||||
$result = [];
|
||||
while (0 !== $int) {
|
||||
$result[] = chr($int & 0xFF);
|
||||
$int >>= 8;
|
||||
}
|
||||
|
||||
return str_pad(implode(array_reverse($result)), 8, "\000", STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $safe
|
||||
* @param string $user
|
||||
*
|
||||
* @return bool
|
||||
* @param non-empty-string $safe
|
||||
* @param non-empty-string $user
|
||||
*/
|
||||
protected function compareOTP(string $safe, string $user): bool
|
||||
{
|
||||
return hash_equals($safe, $user);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return non-empty-string
|
||||
*/
|
||||
private function getDecodedSecret(): string
|
||||
{
|
||||
try {
|
||||
$decoded = Base32::decodeUpper($this->getSecret());
|
||||
} catch (Exception) {
|
||||
throw new RuntimeException('Unable to decode the secret. Is it correctly base32 encoded?');
|
||||
}
|
||||
assert($decoded !== '');
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function intToByteString(int $int): string
|
||||
{
|
||||
$result = [];
|
||||
while ($int !== 0) {
|
||||
$result[] = chr($int & 0xFF);
|
||||
$int >>= 8;
|
||||
}
|
||||
|
||||
return str_pad(implode('', array_reverse($result)), 8, "\000", STR_PAD_LEFT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,132 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2014-2018 Spomky-Labs
|
||||
*
|
||||
* This software may be modified and distributed under the terms
|
||||
* of the MIT license. See the LICENSE file for details.
|
||||
*/
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OTPHP;
|
||||
|
||||
interface OTPInterface
|
||||
{
|
||||
/**
|
||||
* @param int $timestamp
|
||||
*
|
||||
* @return string Return the OTP at the specified timestamp
|
||||
*/
|
||||
public function at(int $timestamp): string;
|
||||
public const DEFAULT_DIGITS = 6;
|
||||
|
||||
public const DEFAULT_DIGEST = 'sha1';
|
||||
|
||||
/**
|
||||
* Verify that the OTP is valid with the specified input.
|
||||
* If no input is provided, the input is set to a default value or false is returned.
|
||||
* Create a OTP object from an existing secret.
|
||||
*
|
||||
* @param string $otp
|
||||
* @param int|null $input
|
||||
* @param int|null $window
|
||||
*
|
||||
* @return bool
|
||||
* @param non-empty-string $secret
|
||||
*/
|
||||
public function verify(string $otp, $input = null, $window = null): bool;
|
||||
public static function createFromSecret(string $secret): self;
|
||||
|
||||
/**
|
||||
* @return string The secret of the OTP
|
||||
* Create a new OTP object. A random 64 bytes secret will be generated.
|
||||
*/
|
||||
public static function generate(): self;
|
||||
|
||||
/**
|
||||
* @param non-empty-string $secret
|
||||
*/
|
||||
public function setSecret(string $secret): void;
|
||||
|
||||
public function setDigits(int $digits): void;
|
||||
|
||||
/**
|
||||
* @param non-empty-string $digest
|
||||
*/
|
||||
public function setDigest(string $digest): void;
|
||||
|
||||
/**
|
||||
* Generate the OTP at the specified input.
|
||||
*
|
||||
* @param 0|positive-int $input
|
||||
*
|
||||
* @return non-empty-string Return the OTP at the specified timestamp
|
||||
*/
|
||||
public function at(int $input): string;
|
||||
|
||||
/**
|
||||
* Verify that the OTP is valid with the specified input. If no input is provided, the input is set to a default
|
||||
* value or false is returned.
|
||||
*
|
||||
* @param non-empty-string $otp
|
||||
* @param null|0|positive-int $input
|
||||
* @param null|0|positive-int $window
|
||||
*/
|
||||
public function verify(string $otp, null|int $input = null, null|int $window = null): bool;
|
||||
|
||||
/**
|
||||
* @return non-empty-string The secret of the OTP
|
||||
*/
|
||||
public function getSecret(): string;
|
||||
|
||||
/**
|
||||
* @param string $label The label of the OTP
|
||||
* @param non-empty-string $label The label of the OTP
|
||||
*/
|
||||
public function setLabel(string $label);
|
||||
public function setLabel(string $label): void;
|
||||
|
||||
/**
|
||||
* @return string|null The label of the OTP
|
||||
* @return non-empty-string|null The label of the OTP
|
||||
*/
|
||||
public function getLabel();
|
||||
public function getLabel(): null|string;
|
||||
|
||||
/**
|
||||
* @return string|null The issuer
|
||||
* @return non-empty-string|null The issuer
|
||||
*/
|
||||
public function getIssuer();
|
||||
public function getIssuer(): ?string;
|
||||
|
||||
/**
|
||||
* @param string $issuer
|
||||
*
|
||||
* @throws \InvalidArgumentException
|
||||
* @param non-empty-string $issuer
|
||||
*/
|
||||
public function setIssuer(string $issuer);
|
||||
public function setIssuer(string $issuer): void;
|
||||
|
||||
/**
|
||||
* @return bool If true, the issuer will be added as a parameter in the provisioning URI
|
||||
*/
|
||||
public function isIssuerIncludedAsParameter(): bool;
|
||||
|
||||
/**
|
||||
* @param bool $issuer_included_as_parameter
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter);
|
||||
public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter): void;
|
||||
|
||||
/**
|
||||
* @return int Number of digits in the OTP
|
||||
* @return positive-int Number of digits in the OTP
|
||||
*/
|
||||
public function getDigits(): int;
|
||||
|
||||
/**
|
||||
* @return string Digest algorithm used to calculate the OTP. Possible values are 'md5', 'sha1', 'sha256' and 'sha512'
|
||||
* @return non-empty-string Digest algorithm used to calculate the OTP. Possible values are 'md5', 'sha1', 'sha256' and 'sha512'
|
||||
*/
|
||||
public function getDigest(): string;
|
||||
|
||||
/**
|
||||
* @param string $parameter
|
||||
*
|
||||
* @return null|mixed
|
||||
* @param non-empty-string $parameter
|
||||
*/
|
||||
public function getParameter(string $parameter);
|
||||
public function getParameter(string $parameter): mixed;
|
||||
|
||||
/**
|
||||
* @param string $parameter
|
||||
*
|
||||
* @return bool
|
||||
* @param non-empty-string $parameter
|
||||
*/
|
||||
public function hasParameter(string $parameter): bool;
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @return array<non-empty-string, mixed>
|
||||
*/
|
||||
public function getParameters(): array;
|
||||
|
||||
/**
|
||||
* @param string $parameter
|
||||
* @param mixed $value
|
||||
*
|
||||
* @return $this
|
||||
* @param non-empty-string $parameter
|
||||
*/
|
||||
public function setParameter(string $parameter, $value);
|
||||
public function setParameter(string $parameter, mixed $value): void;
|
||||
|
||||
/**
|
||||
* @return string Get the provisioning URI
|
||||
* Get the provisioning URI.
|
||||
*
|
||||
* @return non-empty-string
|
||||
*/
|
||||
public function getProvisioningUri(): string;
|
||||
|
||||
/**
|
||||
* @param string $uri The Uri of the QRCode generator with all parameters. By default the Googgle Chart API is used. This Uri MUST contain a placeholder that will be replaced by the method.
|
||||
* @param string $placeholder The placeholder to be replaced in the QR Code generator URI. Default value is {PROVISIONING_URI}.
|
||||
* Get the provisioning URI.
|
||||
*
|
||||
* @return string Get the provisioning URI
|
||||
* @param non-empty-string $uri The Uri of the QRCode generator with all parameters. This Uri MUST contain a placeholder that will be replaced by the method.
|
||||
* @param non-empty-string $placeholder the placeholder to be replaced in the QR Code generator URI
|
||||
*/
|
||||
public function getQrCodeUri(string $uri = 'https://chart.googleapis.com/chart?chs=200x200&chld=M|0&cht=qr&chl={PROVISIONING_URI}', string $placeholder = '{PROVISIONING_URI}'): string;
|
||||
public function getQrCodeUri(string $uri, string $placeholder): string;
|
||||
}
|
||||
|
||||
@@ -2,228 +2,181 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2014-2018 Spomky-Labs
|
||||
*
|
||||
* This software may be modified and distributed under the terms
|
||||
* of the MIT license. See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace OTPHP;
|
||||
|
||||
use Assert\Assertion;
|
||||
use ParagonIE\ConstantTime\Base32;
|
||||
use InvalidArgumentException;
|
||||
use function array_key_exists;
|
||||
use function assert;
|
||||
use function in_array;
|
||||
use function is_int;
|
||||
use function is_string;
|
||||
|
||||
trait ParameterTrait
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
* @var array<non-empty-string, mixed>
|
||||
*/
|
||||
private $parameters = [];
|
||||
private array $parameters = [];
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
* @var non-empty-string|null
|
||||
*/
|
||||
private $issuer = null;
|
||||
private null|string $issuer = null;
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
* @var non-empty-string|null
|
||||
*/
|
||||
private $label = null;
|
||||
private null|string $label = null;
|
||||
|
||||
private bool $issuer_included_as_parameter = true;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $issuer_included_as_parameter = true;
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @return array<non-empty-string, mixed>
|
||||
*/
|
||||
public function getParameters(): array
|
||||
{
|
||||
$parameters = $this->parameters;
|
||||
|
||||
if (null !== $this->getIssuer() && $this->isIssuerIncludedAsParameter() === true) {
|
||||
if ($this->getIssuer() !== null && $this->isIssuerIncludedAsParameter() === true) {
|
||||
$parameters['issuer'] = $this->getIssuer();
|
||||
}
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getSecret(): string
|
||||
{
|
||||
return $this->getParameter('secret');
|
||||
$value = $this->getParameter('secret');
|
||||
(is_string($value) && $value !== '') || throw new InvalidArgumentException('Invalid "secret" parameter.');
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $secret
|
||||
*/
|
||||
private function setSecret($secret)
|
||||
{
|
||||
$this->setParameter('secret', $secret);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
public function getLabel()
|
||||
public function getLabel(): null|string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $label
|
||||
*/
|
||||
public function setLabel(string $label)
|
||||
public function setLabel(string $label): void
|
||||
{
|
||||
$this->setParameter('label', $label);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|null
|
||||
*/
|
||||
public function getIssuer()
|
||||
public function getIssuer(): null|string
|
||||
{
|
||||
return $this->issuer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $issuer
|
||||
*/
|
||||
public function setIssuer(string $issuer)
|
||||
public function setIssuer(string $issuer): void
|
||||
{
|
||||
$this->setParameter('issuer', $issuer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isIssuerIncludedAsParameter(): bool
|
||||
{
|
||||
return $this->issuer_included_as_parameter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool $issuer_included_as_parameter
|
||||
*/
|
||||
public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter)
|
||||
public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter): void
|
||||
{
|
||||
$this->issuer_included_as_parameter = $issuer_included_as_parameter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getDigits(): int
|
||||
{
|
||||
return $this->getParameter('digits');
|
||||
$value = $this->getParameter('digits');
|
||||
(is_int($value) && $value > 0) || throw new InvalidArgumentException('Invalid "digits" parameter.');
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $digits
|
||||
*/
|
||||
private function setDigits(int $digits)
|
||||
{
|
||||
$this->setParameter('digits', $digits);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getDigest(): string
|
||||
{
|
||||
return $this->getParameter('algorithm');
|
||||
$value = $this->getParameter('algorithm');
|
||||
(is_string($value) && $value !== '') || throw new InvalidArgumentException('Invalid "algorithm" parameter.');
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $digest
|
||||
*/
|
||||
private function setDigest(string $digest)
|
||||
{
|
||||
$this->setParameter('algorithm', $digest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $parameter
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasParameter(string $parameter): bool
|
||||
{
|
||||
return array_key_exists($parameter, $this->parameters);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $parameter
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function getParameter(string $parameter)
|
||||
public function getParameter(string $parameter): mixed
|
||||
{
|
||||
if ($this->hasParameter($parameter)) {
|
||||
return $this->getParameters()[$parameter];
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException(sprintf('Parameter "%s" does not exist', $parameter));
|
||||
throw new InvalidArgumentException(sprintf('Parameter "%s" does not exist', $parameter));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $parameter
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function setParameter(string $parameter, $value)
|
||||
public function setParameter(string $parameter, mixed $value): void
|
||||
{
|
||||
$map = $this->getParameterMap();
|
||||
|
||||
if (true === array_key_exists($parameter, $map)) {
|
||||
if (array_key_exists($parameter, $map) === true) {
|
||||
$callback = $map[$parameter];
|
||||
$value = $callback($value);
|
||||
}
|
||||
|
||||
if (property_exists($this, $parameter)) {
|
||||
$this->$parameter = $value;
|
||||
$this->{$parameter} = $value;
|
||||
} else {
|
||||
$this->parameters[$parameter] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
public function setSecret(string $secret): void
|
||||
{
|
||||
$this->setParameter('secret', $secret);
|
||||
}
|
||||
|
||||
public function setDigits(int $digits): void
|
||||
{
|
||||
$this->setParameter('digits', $digits);
|
||||
}
|
||||
|
||||
public function setDigest(string $digest): void
|
||||
{
|
||||
$this->setParameter('algorithm', $digest);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @return array<non-empty-string, callable>
|
||||
*/
|
||||
protected function getParameterMap(): array
|
||||
{
|
||||
return [
|
||||
'label' => function ($value) {
|
||||
Assertion::false($this->hasColon($value), 'Label must not contain a colon.');
|
||||
'label' => function (string $value): string {
|
||||
assert($value !== '');
|
||||
$this->hasColon($value) === false || throw new InvalidArgumentException(
|
||||
'Label must not contain a colon.'
|
||||
);
|
||||
|
||||
return $value;
|
||||
},
|
||||
'secret' => function ($value) {
|
||||
if (null === $value) {
|
||||
$value = Base32::encodeUpper(random_bytes(64));
|
||||
}
|
||||
$value = trim(strtoupper($value), '=');
|
||||
'secret' => static fn (string $value): string => mb_strtoupper(trim($value, '=')),
|
||||
'algorithm' => static function (string $value): string {
|
||||
$value = mb_strtolower($value);
|
||||
in_array($value, hash_algos(), true) || throw new InvalidArgumentException(sprintf(
|
||||
'The "%s" digest is not supported.',
|
||||
$value
|
||||
));
|
||||
|
||||
return $value;
|
||||
},
|
||||
'algorithm' => function ($value) {
|
||||
$value = strtolower($value);
|
||||
Assertion::inArray($value, hash_algos(), sprintf('The "%s" digest is not supported.', $value));
|
||||
|
||||
return $value;
|
||||
},
|
||||
'digits' => function ($value) {
|
||||
Assertion::greaterThan($value, 0, 'Digits must be at least 1.');
|
||||
'digits' => static function ($value): int {
|
||||
$value > 0 || throw new InvalidArgumentException('Digits must be at least 1.');
|
||||
|
||||
return (int) $value;
|
||||
},
|
||||
'issuer' => function ($value) {
|
||||
Assertion::false($this->hasColon($value), 'Issuer must not contain a colon.');
|
||||
'issuer' => function (string $value): string {
|
||||
assert($value !== '');
|
||||
$this->hasColon($value) === false || throw new InvalidArgumentException(
|
||||
'Issuer must not contain a colon.'
|
||||
);
|
||||
|
||||
return $value;
|
||||
},
|
||||
@@ -231,15 +184,13 @@ trait ParameterTrait
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $value
|
||||
*
|
||||
* @return bool
|
||||
* @param non-empty-string $value
|
||||
*/
|
||||
private function hasColon($value)
|
||||
private function hasColon(string $value): bool
|
||||
{
|
||||
$colons = [':', '%3A', '%3a'];
|
||||
foreach ($colons as $colon) {
|
||||
if (false !== strpos($value, $colon)) {
|
||||
if (str_contains($value, $colon)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,216 +2,214 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2014-2018 Spomky-Labs
|
||||
*
|
||||
* This software may be modified and distributed under the terms
|
||||
* of the MIT license. See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace OTPHP;
|
||||
|
||||
use Assert\Assertion;
|
||||
use InvalidArgumentException;
|
||||
use Psr\Clock\ClockInterface;
|
||||
use function assert;
|
||||
use function is_int;
|
||||
|
||||
/**
|
||||
* @see \OTPHP\Test\TOTPTest
|
||||
*/
|
||||
final class TOTP extends OTP implements TOTPInterface
|
||||
{
|
||||
/**
|
||||
* TOTP constructor.
|
||||
*
|
||||
* @param string|null $secret
|
||||
* @param int $period
|
||||
* @param string $digest
|
||||
* @param int $digits
|
||||
* @param int $epoch
|
||||
*/
|
||||
protected function __construct($secret, int $period, string $digest, int $digits, int $epoch = 0)
|
||||
private readonly ClockInterface $clock;
|
||||
|
||||
public function __construct(string $secret, ?ClockInterface $clock = null)
|
||||
{
|
||||
parent::__construct($secret, $digest, $digits);
|
||||
$this->setPeriod($period);
|
||||
$this->setEpoch($epoch);
|
||||
parent::__construct($secret);
|
||||
if ($clock === null) {
|
||||
trigger_deprecation(
|
||||
'spomky-labs/otphp',
|
||||
'11.3.0',
|
||||
'The parameter "$clock" will become mandatory in 12.0.0. Please set a valid PSR Clock implementation instead of "null".'
|
||||
);
|
||||
$clock = new InternalClock();
|
||||
}
|
||||
|
||||
$this->clock = $clock;
|
||||
}
|
||||
|
||||
/**
|
||||
* TOTP constructor.
|
||||
*
|
||||
* @param string|null $secret
|
||||
* @param int $period
|
||||
* @param string $digest
|
||||
* @param int $digits
|
||||
* @param int $epoch
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
public static function create($secret = null, int $period = 30, string $digest = 'sha1', int $digits = 6, int $epoch = 0): self
|
||||
{
|
||||
return new self($secret, $period, $digest, $digits, $epoch);
|
||||
public static function create(
|
||||
null|string $secret = null,
|
||||
int $period = self::DEFAULT_PERIOD,
|
||||
string $digest = self::DEFAULT_DIGEST,
|
||||
int $digits = self::DEFAULT_DIGITS,
|
||||
int $epoch = self::DEFAULT_EPOCH,
|
||||
?ClockInterface $clock = null
|
||||
): self {
|
||||
$totp = $secret !== null
|
||||
? self::createFromSecret($secret, $clock)
|
||||
: self::generate($clock)
|
||||
;
|
||||
$totp->setPeriod($period);
|
||||
$totp->setDigest($digest);
|
||||
$totp->setDigits($digits);
|
||||
$totp->setEpoch($epoch);
|
||||
|
||||
return $totp;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $period
|
||||
*/
|
||||
protected function setPeriod(int $period)
|
||||
public static function createFromSecret(string $secret, ?ClockInterface $clock = null): self
|
||||
{
|
||||
$this->setParameter('period', $period);
|
||||
$totp = new self($secret, $clock);
|
||||
$totp->setPeriod(self::DEFAULT_PERIOD);
|
||||
$totp->setDigest(self::DEFAULT_DIGEST);
|
||||
$totp->setDigits(self::DEFAULT_DIGITS);
|
||||
$totp->setEpoch(self::DEFAULT_EPOCH);
|
||||
|
||||
return $totp;
|
||||
}
|
||||
|
||||
public static function generate(?ClockInterface $clock = null): self
|
||||
{
|
||||
return self::createFromSecret(self::generateSecret(), $clock);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getPeriod(): int
|
||||
{
|
||||
return $this->getParameter('period');
|
||||
$value = $this->getParameter('period');
|
||||
(is_int($value) && $value > 0) || throw new InvalidArgumentException('Invalid "period" parameter.');
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $epoch
|
||||
*/
|
||||
private function setEpoch(int $epoch)
|
||||
{
|
||||
$this->setParameter('epoch', $epoch);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getEpoch(): int
|
||||
{
|
||||
return $this->getParameter('epoch');
|
||||
$value = $this->getParameter('epoch');
|
||||
(is_int($value) && $value >= 0) || throw new InvalidArgumentException('Invalid "epoch" parameter.');
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function at(int $timestamp): string
|
||||
public function expiresIn(): int
|
||||
{
|
||||
return $this->generateOTP($this->timecode($timestamp));
|
||||
$period = $this->getPeriod();
|
||||
|
||||
return $period - ($this->clock->now()->getTimestamp() % $this->getPeriod());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* The OTP at the specified input.
|
||||
*
|
||||
* @param 0|positive-int $input
|
||||
*/
|
||||
public function at(int $input): string
|
||||
{
|
||||
return $this->generateOTP($this->timecode($input));
|
||||
}
|
||||
|
||||
public function now(): string
|
||||
{
|
||||
return $this->at(time());
|
||||
$timestamp = $this->clock->now()
|
||||
->getTimestamp();
|
||||
assert($timestamp >= 0, 'The timestamp must return a positive integer.');
|
||||
|
||||
return $this->at($timestamp);
|
||||
}
|
||||
|
||||
/**
|
||||
* If no timestamp is provided, the OTP is verified at the actual timestamp
|
||||
* {@inheritdoc}
|
||||
* If no timestamp is provided, the OTP is verified at the actual timestamp. When used, the leeway parameter will
|
||||
* allow time drift. The passed value is in seconds.
|
||||
*
|
||||
* @param 0|positive-int $timestamp
|
||||
* @param null|0|positive-int $leeway
|
||||
*/
|
||||
public function verify(string $otp, $timestamp = null, $window = null): bool
|
||||
public function verify(string $otp, null|int $timestamp = null, null|int $leeway = null): bool
|
||||
{
|
||||
$timestamp = $this->getTimestamp($timestamp);
|
||||
$timestamp ??= $this->clock->now()
|
||||
->getTimestamp();
|
||||
$timestamp >= 0 || throw new InvalidArgumentException('Timestamp must be at least 0.');
|
||||
|
||||
if (null === $window) {
|
||||
if ($leeway === null) {
|
||||
return $this->compareOTP($this->at($timestamp), $otp);
|
||||
}
|
||||
|
||||
return $this->verifyOtpWithWindow($otp, $timestamp, $window);
|
||||
$leeway = abs($leeway);
|
||||
$leeway < $this->getPeriod() || throw new InvalidArgumentException(
|
||||
'The leeway must be lower than the TOTP period'
|
||||
);
|
||||
$timestampMinusLeeway = $timestamp - $leeway;
|
||||
$timestampMinusLeeway >= 0 || throw new InvalidArgumentException(
|
||||
'The timestamp must be greater than or equal to the leeway.'
|
||||
);
|
||||
|
||||
return $this->compareOTP($this->at($timestampMinusLeeway), $otp)
|
||||
|| $this->compareOTP($this->at($timestamp), $otp)
|
||||
|| $this->compareOTP($this->at($timestamp + $leeway), $otp);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $otp
|
||||
* @param int $timestamp
|
||||
* @param int $window
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function verifyOtpWithWindow(string $otp, int $timestamp, int $window): bool
|
||||
{
|
||||
$window = abs($window);
|
||||
|
||||
for ($i = 0; $i <= $window; $i++) {
|
||||
$next = (int) $i * $this->getPeriod() + $timestamp;
|
||||
$previous = (int) -$i * $this->getPeriod() + $timestamp;
|
||||
$valid = $this->compareOTP($this->at($next), $otp) ||
|
||||
$this->compareOTP($this->at($previous), $otp);
|
||||
|
||||
if ($valid) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int|null $timestamp
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function getTimestamp($timestamp): int
|
||||
{
|
||||
$timestamp = $timestamp ?? time();
|
||||
Assertion::greaterOrEqualThan($timestamp, 0, 'Timestamp must be at least 0.');
|
||||
|
||||
return (int) $timestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function getProvisioningUri(): string
|
||||
{
|
||||
$params = [];
|
||||
if (30 !== $this->getPeriod()) {
|
||||
if ($this->getPeriod() !== 30) {
|
||||
$params['period'] = $this->getPeriod();
|
||||
}
|
||||
|
||||
if (0 !== $this->getEpoch()) {
|
||||
if ($this->getEpoch() !== 0) {
|
||||
$params['epoch'] = $this->getEpoch();
|
||||
}
|
||||
|
||||
return $this->generateURI('totp', $params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $timestamp
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
private function timecode(int $timestamp): int
|
||||
public function setPeriod(int $period): void
|
||||
{
|
||||
return (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod());
|
||||
$this->setParameter('period', $period);
|
||||
}
|
||||
|
||||
public function setEpoch(int $epoch): void
|
||||
{
|
||||
$this->setParameter('epoch', $epoch);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* @return array<non-empty-string, callable>
|
||||
*/
|
||||
protected function getParameterMap(): array
|
||||
{
|
||||
$v = array_merge(
|
||||
parent::getParameterMap(),
|
||||
[
|
||||
'period' => function ($value) {
|
||||
Assertion::greaterThan((int) $value, 0, 'Period must be at least 1.');
|
||||
return [
|
||||
...parent::getParameterMap(),
|
||||
'period' => static function ($value): int {
|
||||
(int) $value > 0 || throw new InvalidArgumentException('Period must be at least 1.');
|
||||
|
||||
return (int) $value;
|
||||
},
|
||||
'epoch' => function ($value) {
|
||||
Assertion::greaterOrEqualThan((int) $value, 0, 'Epoch must be greater than or equal to 0.');
|
||||
return (int) $value;
|
||||
},
|
||||
'epoch' => static function ($value): int {
|
||||
(int) $value >= 0 || throw new InvalidArgumentException(
|
||||
'Epoch must be greater than or equal to 0.'
|
||||
);
|
||||
|
||||
return (int) $value;
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
return $v;
|
||||
return (int) $value;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
* @param array<non-empty-string, mixed> $options
|
||||
*/
|
||||
protected function filterOptions(array &$options)
|
||||
protected function filterOptions(array &$options): void
|
||||
{
|
||||
parent::filterOptions($options);
|
||||
|
||||
if (isset($options['epoch']) && 0 === $options['epoch']) {
|
||||
if (isset($options['epoch']) && $options['epoch'] === 0) {
|
||||
unset($options['epoch']);
|
||||
}
|
||||
|
||||
ksort($options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param 0|positive-int $timestamp
|
||||
*
|
||||
* @return 0|positive-int
|
||||
*/
|
||||
private function timecode(int $timestamp): int
|
||||
{
|
||||
$timecode = (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod());
|
||||
assert($timecode >= 0);
|
||||
|
||||
return $timecode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,26 +2,50 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* The MIT License (MIT)
|
||||
*
|
||||
* Copyright (c) 2014-2018 Spomky-Labs
|
||||
*
|
||||
* This software may be modified and distributed under the terms
|
||||
* of the MIT license. See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
namespace OTPHP;
|
||||
|
||||
interface TOTPInterface extends OTPInterface
|
||||
{
|
||||
public const DEFAULT_PERIOD = 30;
|
||||
|
||||
public const DEFAULT_EPOCH = 0;
|
||||
|
||||
/**
|
||||
* @return string Return the TOTP at the current time
|
||||
* Create a new TOTP object.
|
||||
*
|
||||
* If the secret is null, a random 64 bytes secret will be generated.
|
||||
*
|
||||
* @param null|non-empty-string $secret
|
||||
* @param positive-int $period
|
||||
* @param non-empty-string $digest
|
||||
* @param positive-int $digits
|
||||
*
|
||||
* @deprecated Deprecated since v11.1, use ::createFromSecret or ::generate instead
|
||||
*/
|
||||
public static function create(
|
||||
null|string $secret = null,
|
||||
int $period = self::DEFAULT_PERIOD,
|
||||
string $digest = self::DEFAULT_DIGEST,
|
||||
int $digits = self::DEFAULT_DIGITS
|
||||
): self;
|
||||
|
||||
public function setPeriod(int $period): void;
|
||||
|
||||
public function setEpoch(int $epoch): void;
|
||||
|
||||
/**
|
||||
* Return the TOTP at the current time.
|
||||
*
|
||||
* @return non-empty-string
|
||||
*/
|
||||
public function now(): string;
|
||||
|
||||
/**
|
||||
* @return int Get the period of time for OTP generation (a non-null positive integer, in second)
|
||||
* Get the period of time for OTP generation (a non-null positive integer, in second).
|
||||
*/
|
||||
public function getPeriod(): int;
|
||||
|
||||
public function expiresIn(): int;
|
||||
|
||||
public function getEpoch(): int;
|
||||
}
|
||||
|
||||
@@ -16,15 +16,25 @@
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"php": "^7.1",
|
||||
"paragonie/constant_time_encoding": "^2.0",
|
||||
"beberlei/assert": "^2.4"
|
||||
"php": ">=8.1",
|
||||
"ext-mbstring": "*",
|
||||
"paragonie/constant_time_encoding": "^2.0 || ^3.0",
|
||||
"psr/clock": "^1.0",
|
||||
"symfony/deprecation-contracts": "^3.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^6.0",
|
||||
"satooshi/php-coveralls": "^1.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ekino/phpstan-banned-code": "^1.0",
|
||||
"infection/infection": "^0.26|^0.27|^0.28|^0.29",
|
||||
"php-parallel-lint/php-parallel-lint": "^1.3",
|
||||
"phpstan/phpstan": "^1.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.0",
|
||||
"phpunit/phpunit": "^9.5.26|^10.0|^11.0",
|
||||
"qossmic/deptrac-shim": "^1.0",
|
||||
"rector/rector": "^1.0",
|
||||
"symfony/phpunit-bridge": "^6.1|^7.0",
|
||||
"symplify/easy-coding-standard": "^12.0"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": { "OTPHP\\": "src/" }
|
||||
@@ -32,9 +42,18 @@
|
||||
"autoload-dev": {
|
||||
"psr-4": { "OTPHP\\Test\\": "tests/" }
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "9.0.x-dev"
|
||||
}
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"phpstan/extension-installer": true,
|
||||
"infection/extension-installer": true,
|
||||
"composer/package-versions-deprecated": true,
|
||||
"symfony/flex": true,
|
||||
"symfony/runtime": true
|
||||
},
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": {
|
||||
"*": "dist"
|
||||
},
|
||||
"sort-packages": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
OTPHP 9.1.1
|
||||
OTPHP
|
||||
--------------
|
||||
https://github.com/Spomky-Labs/otphp/releases/tag/v9.1.1
|
||||
|
||||
Instructions to import WebAuthn into Moodle:
|
||||
Instructions to import OTPHP into Moodle:
|
||||
|
||||
1. Download the latest release from https://github.com/Spomky-Labs/otphp/releases/tag/vx.x.x
|
||||
(choose "Source code")
|
||||
2. Unzip the source code
|
||||
3. Copy the following files from otphp-x.x/lib/OTPHP into admin/tool/mfa/factor/totp/extlib/OTPHP:
|
||||
1. OTP.php
|
||||
2. OTPInterface.php
|
||||
3. ParameterTrait.php
|
||||
4. TOTP.php
|
||||
5. TOTPInterface.php
|
||||
3. Copy the following files from otphp-x.x/src into admin/tool/mfa/factor/totp/extlib/OTPHP:
|
||||
1. InternalClock.php
|
||||
2. OTP.php
|
||||
3. OTPInterface.php
|
||||
4. ParameterTrait.php
|
||||
5. TOTP.php
|
||||
6. TOTPInterface.php
|
||||
|
||||
4. Copy the following files from otphp-x.x into admin/tool/mfa/factor/totp/extlib/OTPHP:
|
||||
1. LICENSE
|
||||
|
||||
@@ -54,8 +54,11 @@ $string['revokefactorconfirmation'] = 'Remove \'{$a}\' authenticator app?';
|
||||
$string['settings:totplink'] = 'Show mobile app setup link';
|
||||
$string['settings:totplink_help'] = 'If enabled the user will see a 3rd setup option with a direct otpauth:// link';
|
||||
$string['settings:window'] = 'TOTP verification window';
|
||||
$string['settings:window_help'] = 'How long each code is valid for. You can set this to a higher value as a workaround if your users device clocks are often slightly wrong.
|
||||
Rounded down to the nearest 30 seconds, which is the time between new generated codes.';
|
||||
$string['settings:window_help'] = 'The window of TOTP acts as time drift and specifies how long each code is valid for.
|
||||
The period, which is the time between newly generated codes, is 30 seconds.
|
||||
If the window is 15 (the default) and the current timestamp is 147682209, the OTP tested are within 147682194 (147682209 - 15), 147682209 and 147682224 (147682209 + 15).
|
||||
The window shall be lower than 30. Therefore, this test includes the previous OTP but not the next one.
|
||||
You can set this to a higher value (up to 29) as a workaround if your user\'s device clocks are often slightly wrong.';
|
||||
$string['setupfactor'] = 'Set up authenticator app';
|
||||
$string['setupfactorbutton'] = 'Set up';
|
||||
$string['setupfactor:account'] = 'Account:';
|
||||
|
||||
@@ -38,9 +38,11 @@ $settings->add(new admin_setting_configtext('factor_totp/weight',
|
||||
new lang_string('settings:weight', 'tool_mfa'),
|
||||
new lang_string('settings:weight_help', 'tool_mfa'), 100, PARAM_INT));
|
||||
|
||||
$settings->add(new admin_setting_configduration('factor_totp/window',
|
||||
$window = new admin_setting_configduration('factor_totp/window',
|
||||
new lang_string('settings:window', 'factor_totp'),
|
||||
new lang_string('settings:window_help', 'factor_totp'), 30));
|
||||
new lang_string('settings:window_help', 'factor_totp'), 15);
|
||||
$window->set_max_duration(29);
|
||||
$settings->add($window);
|
||||
|
||||
$settings->add(new admin_setting_configcheckbox('factor_totp/totplink',
|
||||
new lang_string('settings:totplink', 'factor_totp'),
|
||||
|
||||
@@ -21,12 +21,10 @@ defined('MOODLE_INTERNAL') || die();
|
||||
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/InternalClock.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');
|
||||
@@ -40,7 +38,7 @@ require_once(__DIR__.'/../extlib/ParagonIE/ConstantTime/Base32.php');
|
||||
* @copyright Catalyst IT
|
||||
* @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
|
||||
*/
|
||||
class factor_test extends \advanced_testcase {
|
||||
final class factor_test extends \advanced_testcase {
|
||||
|
||||
/**
|
||||
* Test code validation of the TOTP factor
|
||||
@@ -48,12 +46,14 @@ class factor_test extends \advanced_testcase {
|
||||
public function test_validate_code(): void {
|
||||
global $DB;
|
||||
|
||||
$clock = $this->mock_clock_with_frozen(10000000);
|
||||
|
||||
$this->resetAfterTest(true);
|
||||
$user = $this->getDataGenerator()->create_user();
|
||||
$this->setUser($user);
|
||||
// Setup test staples.
|
||||
$totp = \OTPHP\TOTP::create('fakekey');
|
||||
$window = 10;
|
||||
$totp = \OTPHP\TOTP::create('fakekey', clock: $clock);
|
||||
$window = 29;
|
||||
|
||||
set_config('enabled', 1, 'factor_totp');
|
||||
$totpfactor = \tool_mfa\plugininfo\factor::get_factor('totp');
|
||||
@@ -64,50 +64,44 @@ class factor_test extends \advanced_testcase {
|
||||
$factorinstance = $totpfactor->setup_user_factor((object) $totpdata);
|
||||
|
||||
// First check that a valid code is actually valid.
|
||||
$code = $totp->at(time());
|
||||
$code = $totp->at($clock->time());
|
||||
// Manually set timeverified of factor.
|
||||
$DB->set_field('tool_mfa', 'lastverified', time() - WEEKSECS, ['id' => $factorinstance->id]);
|
||||
$DB->set_field('tool_mfa', 'lastverified', $clock->time() - WEEKSECS, ['id' => $factorinstance->id]);
|
||||
$result = $totpfactor->validate_code($code, $window, $totp, $factorinstance);
|
||||
$this->assertEquals($totpfactor::TOTP_VALID, $result);
|
||||
|
||||
// Now update timeverified to 2 mins ago, and check codes within window are blocked.
|
||||
$code = $totp->at(time() - (2 * MINSECS));
|
||||
$DB->set_field('tool_mfa', 'lastverified', time() - (2 * MINSECS), ['id' => $factorinstance->id]);
|
||||
// Now update timeverified to 20 seconds ago, and check codes within window is blocked.
|
||||
$code = $totp->at($clock->time() - (20));
|
||||
$DB->set_field('tool_mfa', 'lastverified', $clock->time() - (20), ['id' => $factorinstance->id]);
|
||||
$result = $totpfactor->validate_code($code, $window, $totp, $factorinstance);
|
||||
$this->assertEquals($totpfactor::TOTP_USED, $result);
|
||||
|
||||
// Now update timeverified to 2 mins ago, and check codes within window are blocked.
|
||||
$code = $totp->at(time());
|
||||
$DB->set_field('tool_mfa', 'lastverified', time() - (2 * MINSECS), ['id' => $factorinstance->id]);
|
||||
$result = $totpfactor->validate_code($code, $window, $totp, $factorinstance);
|
||||
$this->assertEquals($totpfactor::TOTP_USED, $result);
|
||||
|
||||
// Now update timeverified to 2 mins ago, and check codes within window are blocked.
|
||||
$code = $totp->at(time() - (4 * MINSECS));
|
||||
$DB->set_field('tool_mfa', 'lastverified', time() - (2 * MINSECS), ['id' => $factorinstance->id]);
|
||||
// Now update timeverified to 20 seconds ago, and check code from current increment within window is blocked.
|
||||
$code = $totp->at($clock->time());
|
||||
$DB->set_field('tool_mfa', 'lastverified', $clock->time() - (20), ['id' => $factorinstance->id]);
|
||||
$result = $totpfactor->validate_code($code, $window, $totp, $factorinstance);
|
||||
$this->assertEquals($totpfactor::TOTP_USED, $result);
|
||||
|
||||
// Now check future codes.
|
||||
$window = 1;
|
||||
$code = $totp->at(time() + (2 * MINSECS));
|
||||
$DB->set_field('tool_mfa', 'lastverified', time() - WEEKSECS, ['id' => $factorinstance->id]);
|
||||
$code = $totp->at($clock->time() + (2 * MINSECS));
|
||||
$DB->set_field('tool_mfa', 'lastverified', $clock->time() - WEEKSECS, ['id' => $factorinstance->id]);
|
||||
$result = $totpfactor->validate_code($code, $window, $totp, $factorinstance);
|
||||
$this->assertEquals($totpfactor::TOTP_FUTURE, $result);
|
||||
|
||||
// Codes in far future are invalid.
|
||||
$code = $totp->at(time() + (20 * MINSECS));
|
||||
$code = $totp->at($clock->time() + (20 * MINSECS));
|
||||
$result = $totpfactor->validate_code($code, $window, $totp, $factorinstance);
|
||||
$this->assertEquals($totpfactor::TOTP_INVALID, $result);
|
||||
|
||||
// Do the same for past codes.
|
||||
$window = 1;
|
||||
$code = $totp->at(time() - (2 * MINSECS));
|
||||
$code = $totp->at($clock->time() - (2 * MINSECS));
|
||||
$result = $totpfactor->validate_code($code, $window, $totp, $factorinstance);
|
||||
$this->assertEquals($totpfactor::TOTP_OLD, $result);
|
||||
|
||||
// Codes in far future are invalid.
|
||||
$code = $totp->at(time() - (20 * MINSECS));
|
||||
$code = $totp->at($clock->time() - (20 * MINSECS));
|
||||
$result = $totpfactor->validate_code($code, $window, $totp, $factorinstance);
|
||||
$this->assertEquals($totpfactor::TOTP_INVALID, $result);
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
<?xml version="1.0"?>
|
||||
<libraries>
|
||||
<library>
|
||||
<location>extlib/Assert</location>
|
||||
<name>Assert</name>
|
||||
<version>2.1</version>
|
||||
<license>MIT</license>
|
||||
<repository>https://github.com/beberlei/assert</repository>
|
||||
</library>
|
||||
<library>
|
||||
<location>extlib/OTPHP</location>
|
||||
<name>OTPHP</name>
|
||||
<version>9.1.1</version>
|
||||
<version>11.3.0</version>
|
||||
<license>MIT</license>
|
||||
<repository>https://github.com/Spomky-Labs/otphp</repository>
|
||||
</library>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
defined('MOODLE_INTERNAL') || die();
|
||||
|
||||
$plugin->version = 2024042200; // The current plugin version (Date: YYYYMMDDXX).
|
||||
$plugin->requires = 2024041600; // Requires this Moodle version.
|
||||
$plugin->version = 2024081600; // The current plugin version (Date: YYYYMMDDXX).
|
||||
$plugin->requires = 2024041600; // Requires this Moodle version.
|
||||
$plugin->component = 'factor_totp'; // Full name of the plugin (used for diagnostics).
|
||||
$plugin->maturity = MATURITY_STABLE;
|
||||
|
||||
Reference in New Issue
Block a user