diff --git a/src/Factory.php b/src/Factory.php index 4bf41a8..13418df 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -9,6 +9,7 @@ use Throwable; use function assert; use function count; +use function sprintf; /** * This class is used to load OTP object from a provisioning Uri. diff --git a/src/OTP.php b/src/OTP.php index f4c242c..c51dba4 100644 --- a/src/OTP.php +++ b/src/OTP.php @@ -12,6 +12,7 @@ use function chr; use function count; use function is_string; +use function sprintf; use const STR_PAD_LEFT; abstract class OTP implements OTPInterface diff --git a/src/OTPWithPreviousTimestampInterface.php b/src/OTPWithPreviousTimestampInterface.php new file mode 100644 index 0000000..03ea2b0 --- /dev/null +++ b/src/OTPWithPreviousTimestampInterface.php @@ -0,0 +1,24 @@ +verifyWithPreviousTimestamp($otp, $timestamp, $leeway, null) !== false; + } + + /** + * Verify method which prevents previously used codes from being used again. The passed values are in seconds. + * + * @param non-empty-string $otp + * @param 0|positive-int $timestamp + * @param null|0|positive-int $leeway + * @param null|0|positive-int $previousTimestamp + * @return int|false the timestamp matching the otp on success, and false on error + */ + public function verifyWithPreviousTimestamp( + string $otp, + null|int $timestamp = null, + null|int $leeway = null, + null|int $previousTimestamp = null + ): int|false { $timestamp ??= $this->clock->now() ->getTimestamp(); $timestamp >= 0 || throw new InvalidArgumentException('Timestamp must be at least 0.'); if ($leeway === null) { - return $this->compareOTP($this->at($timestamp), $otp); + return $this->verifyOTPAtTimestamps($otp, [$timestamp], $previousTimestamp); } $leeway = abs($leeway); @@ -135,9 +153,11 @@ public function verify(string $otp, null|int $timestamp = null, null|int $leeway '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); + return $this->verifyOTPAtTimestamps( + $otp, + [$timestampMinusLeeway, $timestamp, $timestamp + $leeway], + $previousTimestamp + ); } public function getProvisioningUri(): string @@ -200,6 +220,30 @@ protected function filterOptions(array &$options): void ksort($options); } + /** + * @param non-empty-string $otp + * @param array<0|positive-int> $timestamps + */ + private function verifyOTPAtTimestamps(string $otp, array $timestamps, null|int $previousTimestamp): int|false + { + $previousTimeCode = null; + if ($previousTimestamp > 0) { + $previousTimeCode = $this->timecode($previousTimestamp); + } + + foreach ($timestamps as $timestamp) { + if ($previousTimeCode !== null && $previousTimeCode >= $this->timecode($timestamp)) { + continue; + } + + if ($this->compareOTP($this->at($timestamp), $otp)) { + return $timestamp; + } + } + + return false; + } + /** * @param 0|positive-int $timestamp * diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php index 1b8a2df..e8e14e3 100644 --- a/tests/TOTPTest.php +++ b/tests/TOTPTest.php @@ -211,6 +211,31 @@ public function verifyOtpWithEpoch(): void static::assertFalse($otp->verify('139664', 1_301_012_297)); } + #[Test] + public function verifyOtpWithPreviousTimestamp(): void + { + $otp = self::createTOTP(6, 'sha1', 30); + + static::assertSame(319_690_800, $otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 0)); + static::assertSame( + 319_690_800, + $otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 319_690_770), + 'Can use new code' + ); + static::assertFalse( + $otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 319_690_800), + 'Cannot use same code again' + ); + static::assertFalse( + $otp->verifyWithPreviousTimestamp('762124', 319_690_801, null, 319_690_800), + 'Cannot use same code again at different timestamp' + ); + static::assertFalse( + $otp->verifyWithPreviousTimestamp('762124', 319_690_800, null, 319_690_830), + 'Cannot use previous code' + ); + } + #[Test] public function notCompatibleWithGoogleAuthenticator(): void {