diff --git a/doc/Window.md b/doc/Window.md index 0613984da7f07e9093e65fccce829df351cea258..45fcb233df3363ecd1df2e97cef161944fdc074e 100644 --- a/doc/Window.md +++ b/doc/Window.md @@ -15,13 +15,27 @@ $hotp->verify('123456', 999, 10); // Will return true (1000 is tested) ## Window and TOTP -The window of timestamps goes from `timestamp - window * period` to `timestamp + window * period`. -For example, if the `window` is `5`, the period `30` and the timestamp `1476822000`, the OTP tested are within `1476821850` (`1476822000 - 5 * 30`) and `1476822150` (`1476822000 + 5 * 30`). +The window of TOTP acts as time drift. +If the `window` is `10`, the period `30` and the timestamp `147682209`, +the OTP tested are within `1476821999` (`147682209 - 10`), `147682209` and `1476822219` (`147682209 + 10`). +This includes the previous OTP, but not the next one. + +As an example, at the timestamp `147682209` the correct TOTP is `123456`. +But the user device timestamp `1476821995` and the proposed input is the previous one `654321`. + +**It is mandatory to have a window lower than the period**. ```php -$totp->at(1000); // e.g. will return '123456' -$totp->verify('123456'); // Will return true -// 30 seconds later -$totp->verify('123456'); // Will return false -$totp->verify('123456', null, 1); // Will return true during the next period +// Without the window feature, this will fail +$totp->verify('654321'); // returns false + +// With a window 5 seconds, this will fail +// because the input is tested with 147682209-5 and 147682209+5 seconds. +// and this does not allow the previous OTP to be used +$totp->verify('654321', null, 5); // returns false + +// With a window 10 seconds, this will succeed +// because the input is tested with 147682209-10 and 147682209+10 seconds. +// and the previous OTP is tested +$totp->verify('654321', null, 10); // returns true ``` diff --git a/src/TOTP.php b/src/TOTP.php index ea9c0affcca3e95af03acc3f6d43dec07467a9d3..2ac22502ae390df89194f97e4ad071c209d4254f 100644 --- a/src/TOTP.php +++ b/src/TOTP.php @@ -60,17 +60,24 @@ final class TOTP extends OTP implements TOTPInterface } /** - * If no timestamp is provided, the OTP is verified at the actual timestamp. + * 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 $window = null): bool + public function verify(string $otp, null|int $timestamp = null, null|int $leeway = null): bool { - $timestamp = $this->getTimestamp($timestamp); + $timestamp = $timestamp ?? time(); + Assertion::greaterOrEqualThan($timestamp, 0, 'Timestamp must be at least 0.'); - if ($window === null) { + if ($leeway === null) { return $this->compareOTP($this->at($timestamp), $otp); } - return $this->verifyOtpWithWindow($otp, $timestamp, $window); + $leeway = abs($leeway); + Assertion::lessThan($leeway, $this->getPeriod(), '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 @@ -133,32 +140,6 @@ final class TOTP extends OTP implements TOTPInterface $this->setParameter('epoch', $epoch); } - private function verifyOtpWithWindow(string $otp, int $timestamp, int $window): bool - { - $window = abs($window); - - for ($i = 0; $i <= $window; ++$i) { - $next = $i * $this->getPeriod() + $timestamp; - $previous = -$i * $this->getPeriod() + $timestamp; - $valid = $this->compareOTP($this->at($next), $otp) || - $this->compareOTP($this->at($previous), $otp); - - if ($valid) { - return true; - } - } - - return false; - } - - private function getTimestamp(null|int $timestamp): int - { - $timestamp = $timestamp ?? time(); - Assertion::greaterOrEqualThan($timestamp, 0, 'Timestamp must be at least 0.'); - - return $timestamp; - } - private function timecode(int $timestamp): int { return (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod()); diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php index d97d0eff0e3e7039b068587055407791bbdee333..497105efd4680bbf66e547072beb915c7bc30477 100644 --- a/tests/TOTPTest.php +++ b/tests/TOTPTest.php @@ -276,33 +276,58 @@ final class TOTPTest extends TestCase /** * @test */ - public function verifyOtpInWindow(): void + public function invalidOtpWindow(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The leeway must be lower than the TOTP period'); + $otp = $this->createTOTP(6, 'sha1', 30); + $otp->verify('123456', null, 31); + } + + /** + * @test + * @dataProvider dataLeeway + */ + public function verifyOtpInWindow(int $timestamp, string $input, int $leeway, bool $expectedResult): void + { + ClockMock::register(TOTP::class); + ClockMock::withClockMock($timestamp); $otp = $this->createTOTP(6, 'sha1', 30); - static::assertFalse($otp->verify('054409', 319690800, 10)); // -11 periods - static::assertTrue($otp->verify('808167', 319690800, 10)); // -10 periods - static::assertTrue($otp->verify('364393', 319690800, 10)); // -9 periods - static::assertTrue($otp->verify('762124', 319690800, 10)); // 0 periods - static::assertTrue($otp->verify('988451', 319690800, 10)); // +9 periods - static::assertTrue($otp->verify('789387', 319690800, 10)); // +10 periods - static::assertFalse($otp->verify('465009', 319690800, 10)); // +11 periods + static::assertSame($expectedResult, $otp->verify($input, null, $leeway)); } /** * @test + * @dataProvider dataLeewayWithEpoch */ - public function verifyOtpWithEpochInWindow(): void + public function verifyOtpWithEpochInWindow(int $timestamp, string $input, int $leeway, bool $expectedResult): void { + ClockMock::register(TOTP::class); + ClockMock::withClockMock($timestamp); $otp = $this->createTOTP(6, 'sha1', 30, 'JDDK4U6G3BJLEZ7Y', 'alice@foo.bar', 'My Project', 100); - static::assertFalse($otp->verify('054409', 319690900, 10)); // -11 periods - static::assertTrue($otp->verify('808167', 319690900, 10)); // -10 periods - static::assertTrue($otp->verify('364393', 319690900, 10)); // -9 periods - static::assertTrue($otp->verify('762124', 319690900, 10)); // 0 periods - static::assertTrue($otp->verify('988451', 319690900, 10)); // +9 periods - static::assertTrue($otp->verify('789387', 319690900, 10)); // +10 periods - static::assertFalse($otp->verify('465009', 319690900, 10)); // +11 periods + static::assertSame($expectedResult, $otp->verify($input, null, $leeway)); + } + + /** + * @return array<int, int|string|bool>[] + */ + public function dataLeewayWithEpoch(): array + { + return [ + [319690889, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec + [319690890, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec + [319690899, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec + [319690899, '762124', 0, false], //No leeway, **out** the period + [319690900, '762124', 0, true], //No leeway, in the period + [319690920, '762124', 0, true], //No leeway, in the period + [319690929, '762124', 0, true], //No leeway, in the period + [319690930, '762124', 0, false], //No leeway, **out** the period + [319690930, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec + [319690939, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec + [319690940, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec + ]; } /** @@ -349,6 +374,26 @@ final class TOTPTest extends TestCase ]; } + /** + * @return array<int, int|string|bool>[] + */ + public function dataLeeway(): array + { + return [ + [319690789, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec + [319690790, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec + [319690799, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec + [319690799, '762124', 0, false], //No leeway, **out** the period + [319690800, '762124', 0, true], //No leeway, in the period + [319690820, '762124', 0, true], //No leeway, in the period + [319690829, '762124', 0, true], //No leeway, in the period + [319690830, '762124', 0, false], //No leeway, **out** the period + [319690830, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 1sec + [319690839, '762124', 10, true], //Leeway of 10 seconds, **out** the period of 10sec + [319690840, '762124', 10, false], //Leeway of 10 seconds, **out** the period of 11sec + ]; + } + private function createTOTP( int $digits, string $digest,