aboutsummaryrefslogtreecommitdiffstats
path: root/vendor/spomky-labs/otphp/src
diff options
context:
space:
mode:
Diffstat (limited to 'vendor/spomky-labs/otphp/src')
-rw-r--r--vendor/spomky-labs/otphp/src/Factory.php85
-rw-r--r--vendor/spomky-labs/otphp/src/FactoryInterface.php14
-rw-r--r--vendor/spomky-labs/otphp/src/HOTP.php124
-rw-r--r--vendor/spomky-labs/otphp/src/HOTPInterface.php34
-rw-r--r--vendor/spomky-labs/otphp/src/OTP.php129
-rw-r--r--vendor/spomky-labs/otphp/src/OTPInterface.php110
-rw-r--r--vendor/spomky-labs/otphp/src/ParameterTrait.php188
-rw-r--r--vendor/spomky-labs/otphp/src/TOTP.php172
-rw-r--r--vendor/spomky-labs/otphp/src/TOTPInterface.php47
-rw-r--r--vendor/spomky-labs/otphp/src/Url.php88
10 files changed, 991 insertions, 0 deletions
diff --git a/vendor/spomky-labs/otphp/src/Factory.php b/vendor/spomky-labs/otphp/src/Factory.php
new file mode 100644
index 000000000..d5c60cc34
--- /dev/null
+++ b/vendor/spomky-labs/otphp/src/Factory.php
@@ -0,0 +1,85 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+use function count;
+use InvalidArgumentException;
+use Throwable;
+
+/**
+ * This class is used to load OTP object from a provisioning Uri.
+ *
+ * @see \OTPHP\Test\FactoryTest
+ */
+final class Factory implements FactoryInterface
+{
+ public static function loadFromProvisioningUri(string $uri): OTPInterface
+ {
+ try {
+ $parsed_url = Url::fromString($uri);
+ $parsed_url->getScheme() === 'otpauth' || throw new InvalidArgumentException('Invalid scheme.');
+ } catch (Throwable $throwable) {
+ throw new InvalidArgumentException('Not a valid OTP provisioning URI', $throwable->getCode(), $throwable);
+ }
+
+ $otp = self::createOTP($parsed_url);
+
+ self::populateOTP($otp, $parsed_url);
+
+ return $otp;
+ }
+
+ private static function populateParameters(OTPInterface $otp, Url $data): void
+ {
+ foreach ($data->getQuery() as $key => $value) {
+ $otp->setParameter($key, $value);
+ }
+ }
+
+ private static function populateOTP(OTPInterface $otp, Url $data): void
+ {
+ self::populateParameters($otp, $data);
+ $result = explode(':', rawurldecode(mb_substr($data->getPath(), 1)));
+
+ if (count($result) < 2) {
+ $otp->setIssuerIncludedAsParameter(false);
+
+ return;
+ }
+
+ if ($otp->getIssuer() !== null) {
+ $result[0] === $otp->getIssuer() || throw new InvalidArgumentException(
+ 'Invalid OTP: invalid issuer in parameter'
+ );
+ $otp->setIssuerIncludedAsParameter(true);
+ }
+ $otp->setIssuer($result[0]);
+ }
+
+ private static function createOTP(Url $parsed_url): OTPInterface
+ {
+ switch ($parsed_url->getHost()) {
+ case 'totp':
+ $totp = TOTP::createFromSecret($parsed_url->getSecret());
+ $totp->setLabel(self::getLabel($parsed_url->getPath()));
+
+ return $totp;
+ case 'hotp':
+ $hotp = HOTP::createFromSecret($parsed_url->getSecret());
+ $hotp->setLabel(self::getLabel($parsed_url->getPath()));
+
+ return $hotp;
+ default:
+ throw new InvalidArgumentException(sprintf('Unsupported "%s" OTP type', $parsed_url->getHost()));
+ }
+ }
+
+ private static function getLabel(string $data): string
+ {
+ $result = explode(':', rawurldecode(mb_substr($data, 1)));
+
+ return count($result) === 2 ? $result[1] : $result[0];
+ }
+}
diff --git a/vendor/spomky-labs/otphp/src/FactoryInterface.php b/vendor/spomky-labs/otphp/src/FactoryInterface.php
new file mode 100644
index 000000000..74386adeb
--- /dev/null
+++ b/vendor/spomky-labs/otphp/src/FactoryInterface.php
@@ -0,0 +1,14 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+interface FactoryInterface
+{
+ /**
+ * This method is the unique public method of the class. It can load a provisioning Uri and convert it into an OTP
+ * object.
+ */
+ public static function loadFromProvisioningUri(string $uri): OTPInterface;
+}
diff --git a/vendor/spomky-labs/otphp/src/HOTP.php b/vendor/spomky-labs/otphp/src/HOTP.php
new file mode 100644
index 000000000..aa5a22754
--- /dev/null
+++ b/vendor/spomky-labs/otphp/src/HOTP.php
@@ -0,0 +1,124 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+use InvalidArgumentException;
+use function is_int;
+
+/**
+ * @see \OTPHP\Test\HOTPTest
+ */
+final class HOTP extends OTP implements HOTPInterface
+{
+ private const DEFAULT_WINDOW = 0;
+
+ public static function create(
+ null|string $secret = null,
+ int $counter = self::DEFAULT_COUNTER,
+ string $digest = self::DEFAULT_DIGEST,
+ int $digits = self::DEFAULT_DIGITS
+ ): self {
+ $htop = $secret !== null
+ ? self::createFromSecret($secret)
+ : self::generate()
+ ;
+ $htop->setCounter($counter);
+ $htop->setDigest($digest);
+ $htop->setDigits($digits);
+
+ return $htop;
+ }
+
+ public static function createFromSecret(string $secret): self
+ {
+ $htop = new self($secret);
+ $htop->setCounter(self::DEFAULT_COUNTER);
+ $htop->setDigest(self::DEFAULT_DIGEST);
+ $htop->setDigits(self::DEFAULT_DIGITS);
+
+ return $htop;
+ }
+
+ public static function generate(): self
+ {
+ return self::createFromSecret(self::generateSecret());
+ }
+
+ public function getCounter(): int
+ {
+ $value = $this->getParameter('counter');
+ is_int($value) || throw new InvalidArgumentException('Invalid "counter" parameter.');
+
+ return $value;
+ }
+
+ public function getProvisioningUri(): string
+ {
+ return $this->generateURI('hotp', [
+ 'counter' => $this->getCounter(),
+ ]);
+ }
+
+ /**
+ * If the counter is not provided, the OTP is verified at the actual counter.
+ */
+ public function verify(string $otp, null|int $counter = null, null|int $window = null): bool
+ {
+ $counter >= 0 || throw new InvalidArgumentException('The counter must be at least 0.');
+
+ if ($counter === null) {
+ $counter = $this->getCounter();
+ } elseif ($counter < $this->getCounter()) {
+ return false;
+ }
+
+ return $this->verifyOtpWithWindow($otp, $counter, $window);
+ }
+
+ public function setCounter(int $counter): void
+ {
+ $this->setParameter('counter', $counter);
+ }
+
+ /**
+ * @return array<string, callable>
+ */
+ protected function getParameterMap(): array
+ {
+ return [...parent::getParameterMap(), ...[
+ 'counter' => static function (mixed $value): int {
+ $value = (int) $value;
+ $value >= 0 || throw new InvalidArgumentException('Counter must be at least 0.');
+
+ return $value;
+ },
+ ]];
+ }
+
+ private function updateCounter(int $counter): void
+ {
+ $this->setCounter($counter);
+ }
+
+ private function getWindow(null|int $window): int
+ {
+ return abs($window ?? self::DEFAULT_WINDOW);
+ }
+
+ private function verifyOtpWithWindow(string $otp, int $counter, null|int $window): bool
+ {
+ $window = $this->getWindow($window);
+
+ for ($i = $counter; $i <= $counter + $window; ++$i) {
+ if ($this->compareOTP($this->at($i), $otp)) {
+ $this->updateCounter($i + 1);
+
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/vendor/spomky-labs/otphp/src/HOTPInterface.php b/vendor/spomky-labs/otphp/src/HOTPInterface.php
new file mode 100644
index 000000000..853e76c07
--- /dev/null
+++ b/vendor/spomky-labs/otphp/src/HOTPInterface.php
@@ -0,0 +1,34 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+interface HOTPInterface extends OTPInterface
+{
+ public const DEFAULT_COUNTER = 0;
+
+ /**
+ * The initial counter (a positive integer).
+ */
+ public function getCounter(): int;
+
+ /**
+ * Create a new HOTP object.
+ *
+ * If the secret is null, a random 64 bytes secret will be generated.
+ *
+ * @param null|non-empty-string $secret
+ * @param non-empty-string $digest
+ *
+ * @deprecated Deprecated since v11.1, use ::createFromSecret or ::generate instead
+ */
+ public static function create(
+ null|string $secret = null,
+ int $counter = 0,
+ string $digest = 'sha1',
+ int $digits = 6
+ ): self;
+
+ public function setCounter(int $counter): void;
+}
diff --git a/vendor/spomky-labs/otphp/src/OTP.php b/vendor/spomky-labs/otphp/src/OTP.php
new file mode 100644
index 000000000..2cba067b3
--- /dev/null
+++ b/vendor/spomky-labs/otphp/src/OTP.php
@@ -0,0 +1,129 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+use function chr;
+use function count;
+use Exception;
+use InvalidArgumentException;
+use function is_string;
+use ParagonIE\ConstantTime\Base32;
+use RuntimeException;
+use const STR_PAD_LEFT;
+
+abstract class OTP implements OTPInterface
+{
+ use ParameterTrait;
+
+ private const DEFAULT_SECRET_SIZE = 64;
+
+ /**
+ * @param non-empty-string $secret
+ */
+ protected function __construct(string $secret)
+ {
+ $this->setSecret($secret);
+ }
+
+ public function getQrCodeUri(string $uri, string $placeholder): string
+ {
+ $provisioning_uri = urlencode($this->getProvisioningUri());
+
+ return str_replace($placeholder, $provisioning_uri, $uri);
+ }
+
+ 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.
+ */
+ protected function generateOTP(int $input): string
+ {
+ $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);
+ }
+
+ /**
+ * @param array<string, mixed> $options
+ */
+ protected function filterOptions(array &$options): void
+ {
+ foreach ([
+ 'algorithm' => 'sha1',
+ 'period' => 30,
+ 'digits' => 6,
+ ] as $key => $default) {
+ if (isset($options[$key]) && $default === $options[$key]) {
+ unset($options[$key]);
+ }
+ }
+
+ ksort($options);
+ }
+
+ /**
+ * @param array<string, mixed> $options
+ */
+ protected function generateURI(string $type, array $options): string
+ {
+ $label = $this->getLabel();
+ 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(($this->getIssuer() !== null ? $this->getIssuer() . ':' : '') . $label),
+ $params
+ );
+ }
+
+ protected function compareOTP(string $safe, string $user): bool
+ {
+ return hash_equals($safe, $user);
+ }
+
+ private function getDecodedSecret(): string
+ {
+ try {
+ return Base32::decodeUpper($this->getSecret());
+ } catch (Exception) {
+ throw new RuntimeException('Unable to decode the secret. Is it correctly base32 encoded?');
+ }
+ }
+
+ 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);
+ }
+}
diff --git a/vendor/spomky-labs/otphp/src/OTPInterface.php b/vendor/spomky-labs/otphp/src/OTPInterface.php
new file mode 100644
index 000000000..3b27f5456
--- /dev/null
+++ b/vendor/spomky-labs/otphp/src/OTPInterface.php
@@ -0,0 +1,110 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+interface OTPInterface
+{
+ public const DEFAULT_DIGITS = 6;
+
+ public const DEFAULT_DIGEST = 'sha1';
+
+ /**
+ * Create a OTP object from an existing secret.
+ *
+ * @param non-empty-string $secret
+ */
+ public static function createFromSecret(string $secret): self;
+
+ /**
+ * 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;
+
+ /**
+ * @return 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.
+ */
+ public function verify(string $otp, null|int $input = null, null|int $window = null): bool;
+
+ /**
+ * @return string The secret of the OTP
+ */
+ public function getSecret(): string;
+
+ /**
+ * @param string $label The label of the OTP
+ */
+ public function setLabel(string $label): void;
+
+ /**
+ * @return string|null The label of the OTP
+ */
+ public function getLabel(): null|string;
+
+ /**
+ * @return string|null The issuer
+ */
+ public function getIssuer(): ?string;
+
+ 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;
+
+ public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter): void;
+
+ /**
+ * @return 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'
+ */
+ public function getDigest(): string;
+
+ public function getParameter(string $parameter): mixed;
+
+ public function hasParameter(string $parameter): bool;
+
+ /**
+ * @return array<string, mixed>
+ */
+ public function getParameters(): array;
+
+ public function setParameter(string $parameter, mixed $value): void;
+
+ /**
+ * Get the provisioning URI.
+ */
+ public function getProvisioningUri(): string;
+
+ /**
+ * Get the provisioning URI.
+ *
+ * @param 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 string $placeholder the placeholder to be replaced in the QR Code generator URI
+ */
+ public function getQrCodeUri(string $uri, string $placeholder): string;
+}
diff --git a/vendor/spomky-labs/otphp/src/ParameterTrait.php b/vendor/spomky-labs/otphp/src/ParameterTrait.php
new file mode 100644
index 000000000..b05092351
--- /dev/null
+++ b/vendor/spomky-labs/otphp/src/ParameterTrait.php
@@ -0,0 +1,188 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+use function array_key_exists;
+use function in_array;
+use InvalidArgumentException;
+use function is_int;
+use function is_string;
+
+trait ParameterTrait
+{
+ /**
+ * @var array<string, mixed>
+ */
+ private array $parameters = [];
+
+ private null|string $issuer = null;
+
+ private null|string $label = null;
+
+ private bool $issuer_included_as_parameter = true;
+
+ /**
+ * @return array<string, mixed>
+ */
+ public function getParameters(): array
+ {
+ $parameters = $this->parameters;
+
+ if ($this->getIssuer() !== null && $this->isIssuerIncludedAsParameter() === true) {
+ $parameters['issuer'] = $this->getIssuer();
+ }
+
+ return $parameters;
+ }
+
+ public function getSecret(): string
+ {
+ $value = $this->getParameter('secret');
+ is_string($value) || throw new InvalidArgumentException('Invalid "secret" parameter.');
+
+ return $value;
+ }
+
+ public function getLabel(): null|string
+ {
+ return $this->label;
+ }
+
+ public function setLabel(string $label): void
+ {
+ $this->setParameter('label', $label);
+ }
+
+ public function getIssuer(): null|string
+ {
+ return $this->issuer;
+ }
+
+ public function setIssuer(string $issuer): void
+ {
+ $this->setParameter('issuer', $issuer);
+ }
+
+ public function isIssuerIncludedAsParameter(): bool
+ {
+ return $this->issuer_included_as_parameter;
+ }
+
+ public function setIssuerIncludedAsParameter(bool $issuer_included_as_parameter): void
+ {
+ $this->issuer_included_as_parameter = $issuer_included_as_parameter;
+ }
+
+ public function getDigits(): int
+ {
+ $value = $this->getParameter('digits');
+ is_int($value) || throw new InvalidArgumentException('Invalid "digits" parameter.');
+
+ return $value;
+ }
+
+ public function getDigest(): string
+ {
+ $value = $this->getParameter('algorithm');
+ is_string($value) || throw new InvalidArgumentException('Invalid "algorithm" parameter.');
+
+ return $value;
+ }
+
+ public function hasParameter(string $parameter): bool
+ {
+ return array_key_exists($parameter, $this->parameters);
+ }
+
+ public function getParameter(string $parameter): mixed
+ {
+ if ($this->hasParameter($parameter)) {
+ return $this->getParameters()[$parameter];
+ }
+
+ throw new InvalidArgumentException(sprintf('Parameter "%s" does not exist', $parameter));
+ }
+
+ public function setParameter(string $parameter, mixed $value): void
+ {
+ $map = $this->getParameterMap();
+
+ if (array_key_exists($parameter, $map) === true) {
+ $callback = $map[$parameter];
+ $value = $callback($value);
+ }
+
+ if (property_exists($this, $parameter)) {
+ $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<string, callable>
+ */
+ protected function getParameterMap(): array
+ {
+ return [
+ 'label' => function ($value) {
+ $this->hasColon($value) === false || throw new InvalidArgumentException(
+ 'Label must not contain a colon.'
+ );
+
+ return $value;
+ },
+ 'secret' => static fn ($value): string => mb_strtoupper(trim((string) $value, '=')),
+ 'algorithm' => static function ($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;
+ },
+ 'digits' => static function ($value): int {
+ $value > 0 || throw new InvalidArgumentException('Digits must be at least 1.');
+
+ return (int) $value;
+ },
+ 'issuer' => function ($value) {
+ $this->hasColon($value) === false || throw new InvalidArgumentException(
+ 'Issuer must not contain a colon.'
+ );
+
+ return $value;
+ },
+ ];
+ }
+
+ private function hasColon(string $value): bool
+ {
+ $colons = [':', '%3A', '%3a'];
+ foreach ($colons as $colon) {
+ if (str_contains($value, $colon)) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/vendor/spomky-labs/otphp/src/TOTP.php b/vendor/spomky-labs/otphp/src/TOTP.php
new file mode 100644
index 000000000..e9bce9e14
--- /dev/null
+++ b/vendor/spomky-labs/otphp/src/TOTP.php
@@ -0,0 +1,172 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+use InvalidArgumentException;
+use function is_int;
+
+/**
+ * @see \OTPHP\Test\TOTPTest
+ */
+final class TOTP extends OTP implements TOTPInterface
+{
+ 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
+ ): self {
+ $totp = $secret !== null
+ ? self::createFromSecret($secret)
+ : self::generate()
+ ;
+ $totp->setPeriod($period);
+ $totp->setDigest($digest);
+ $totp->setDigits($digits);
+ $totp->setEpoch($epoch);
+
+ return $totp;
+ }
+
+ public static function createFromSecret(string $secret): self
+ {
+ $totp = new self($secret);
+ $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(): self
+ {
+ return self::createFromSecret(self::generateSecret());
+ }
+
+ public function getPeriod(): int
+ {
+ $value = $this->getParameter('period');
+ is_int($value) || throw new InvalidArgumentException('Invalid "period" parameter.');
+
+ return $value;
+ }
+
+ public function getEpoch(): int
+ {
+ $value = $this->getParameter('epoch');
+ is_int($value) || throw new InvalidArgumentException('Invalid "epoch" parameter.');
+
+ return $value;
+ }
+
+ public function expiresIn(): int
+ {
+ $period = $this->getPeriod();
+
+ return $period - (time() % $this->getPeriod());
+ }
+
+ public function at(int $input): string
+ {
+ return $this->generateOTP($this->timecode($input));
+ }
+
+ public function now(): string
+ {
+ return $this->at(time());
+ }
+
+ /**
+ * 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.
+ */
+ public function verify(string $otp, null|int $timestamp = null, null|int $leeway = null): bool
+ {
+ $timestamp ??= time();
+ $timestamp >= 0 || throw new InvalidArgumentException('Timestamp must be at least 0.');
+
+ if ($leeway === null) {
+ return $this->compareOTP($this->at($timestamp), $otp);
+ }
+
+ $leeway = abs($leeway);
+ $leeway < $this->getPeriod() || throw new InvalidArgumentException(
+ 'The leeway must be lower than the TOTP period'
+ );
+
+ return $this->compareOTP($this->at($timestamp - $leeway), $otp)
+ || $this->compareOTP($this->at($timestamp), $otp)
+ || $this->compareOTP($this->at($timestamp + $leeway), $otp);
+ }
+
+ public function getProvisioningUri(): string
+ {
+ $params = [];
+ if ($this->getPeriod() !== 30) {
+ $params['period'] = $this->getPeriod();
+ }
+
+ if ($this->getEpoch() !== 0) {
+ $params['epoch'] = $this->getEpoch();
+ }
+
+ return $this->generateURI('totp', $params);
+ }
+
+ public function setPeriod(int $period): void
+ {
+ $this->setParameter('period', $period);
+ }
+
+ public function setEpoch(int $epoch): void
+ {
+ $this->setParameter('epoch', $epoch);
+ }
+
+ /**
+ * @return array<string, callable>
+ */
+ protected function getParameterMap(): array
+ {
+ return array_merge(
+ parent::getParameterMap(),
+ [
+ 'period' => static function ($value): int {
+ (int) $value > 0 || throw new InvalidArgumentException('Period must be at least 1.');
+
+ 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;
+ },
+ ]
+ );
+ }
+
+ /**
+ * @param array<string, mixed> $options
+ */
+ protected function filterOptions(array &$options): void
+ {
+ parent::filterOptions($options);
+
+ if (isset($options['epoch']) && $options['epoch'] === 0) {
+ unset($options['epoch']);
+ }
+
+ ksort($options);
+ }
+
+ private function timecode(int $timestamp): int
+ {
+ return (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod());
+ }
+}
diff --git a/vendor/spomky-labs/otphp/src/TOTPInterface.php b/vendor/spomky-labs/otphp/src/TOTPInterface.php
new file mode 100644
index 000000000..afb54e8e9
--- /dev/null
+++ b/vendor/spomky-labs/otphp/src/TOTPInterface.php
@@ -0,0 +1,47 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+interface TOTPInterface extends OTPInterface
+{
+ public const DEFAULT_PERIOD = 30;
+
+ public const DEFAULT_EPOCH = 0;
+
+ /**
+ * 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 non-empty-string $digest
+ *
+ * @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.
+ */
+ public function now(): string;
+
+ /**
+ * 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;
+}
diff --git a/vendor/spomky-labs/otphp/src/Url.php b/vendor/spomky-labs/otphp/src/Url.php
new file mode 100644
index 000000000..56ad979c5
--- /dev/null
+++ b/vendor/spomky-labs/otphp/src/Url.php
@@ -0,0 +1,88 @@
+<?php
+
+declare(strict_types=1);
+
+namespace OTPHP;
+
+use function array_key_exists;
+use InvalidArgumentException;
+use function is_string;
+
+/**
+ * @internal
+ */
+final class Url
+{
+ /**
+ * @param non-empty-string $secret
+ * @param array<string, mixed> $query
+ */
+ public function __construct(
+ private readonly string $scheme,
+ private readonly string $host,
+ private readonly string $path,
+ private readonly string $secret,
+ private readonly array $query
+ ) {
+ }
+
+ public function getScheme(): string
+ {
+ return $this->scheme;
+ }
+
+ public function getHost(): string
+ {
+ return $this->host;
+ }
+
+ public function getPath(): string
+ {
+ return $this->path;
+ }
+
+ /**
+ * @return non-empty-string
+ */
+ public function getSecret(): string
+ {
+ return $this->secret;
+ }
+
+ /**
+ * @return array<string, mixed>
+ */
+ public function getQuery(): array
+ {
+ return $this->query;
+ }
+
+ public static function fromString(string $uri): self
+ {
+ $parsed_url = parse_url($uri);
+ $parsed_url !== false || throw new InvalidArgumentException('Invalid URI.');
+ foreach (['scheme', 'host', 'path', 'query'] as $key) {
+ array_key_exists($key, $parsed_url) || throw new InvalidArgumentException(
+ 'Not a valid OTP provisioning URI'
+ );
+ is_string($parsed_url[$key]) || throw new InvalidArgumentException('Not a valid OTP provisioning URI');
+ }
+ $scheme = $parsed_url['scheme'] ?? null;
+ $host = $parsed_url['host'] ?? null;
+ $path = $parsed_url['path'] ?? null;
+ $query = $parsed_url['query'] ?? null;
+ $scheme === 'otpauth' || throw new InvalidArgumentException('Not a valid OTP provisioning URI');
+ is_string($host) || throw new InvalidArgumentException('Invalid URI.');
+ is_string($path) || throw new InvalidArgumentException('Invalid URI.');
+ is_string($query) || throw new InvalidArgumentException('Invalid URI.');
+ $parsedQuery = [];
+ parse_str($query, $parsedQuery);
+ array_key_exists('secret', $parsedQuery) || throw new InvalidArgumentException(
+ 'Not a valid OTP provisioning URI'
+ );
+ $secret = $parsedQuery['secret'];
+ unset($parsedQuery['secret']);
+
+ return new self($scheme, $host, $path, $secret, $parsedQuery);
+ }
+}