From 5c165c9240e41fb8b2922b3e0b8e6fde6dc8a8ab Mon Sep 17 00:00:00 2001 From: abmmhasan Date: Wed, 3 Jan 2024 13:00:37 +0600 Subject: [PATCH] updated otp & totp 1. otp now supports retry 2. totp now supports leeway --- .github/workflows/php.yml | 7 +++- README.md | 9 +++++- src/OTP.php | 67 +++++++++++++++++++++++++++++++++------ src/TOTP.php | 15 ++++++--- 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 9767bab..02820e0 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -8,7 +8,12 @@ on: jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.operating-system }} + strategy: + matrix: + operating-system: [ ubuntu-latest ] + php-versions: [ '8.0', '8.1', '8.2', '8.3' ] + name: PHP ${{ matrix.php-versions }} Test on ${{ matrix.operating-system }} steps: - name: Checkout uses: actions/checkout@v4 diff --git a/README.md b/README.md index 3004b0c..dbeb2f3 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ Simple but Secure Generic OTP, TOTP (RFC6238), HOTP (RFC4226) solution! Language: PHP 8.0/+ _Note: v1.x.x supports PHP 7.x.x series_ +_Note: v2.x.x supports PHP 8.x.x series_ ## Installation @@ -88,6 +89,9 @@ $otp = (new \AbmmHasan\OTP\TOTP($secret))->getOTP(1604820275); /** * Verify +* +* on 3rd parameter it supports, enabling leeway. +* if enabled, it will also check with last segment's generated otp */ (new \AbmmHasan\OTP\TOTP($secret))->verify($otp); // or verify for a specified time @@ -98,7 +102,10 @@ $otp = (new \AbmmHasan\OTP\TOTP($secret))->getOTP(1604820275); ```php /** -* Initiate (Param 1 is OTP length, Param 2 is validity in seconds) +* Initiate +* Param 1 is OTP length (default 6) +* Param 2 is validity in seconds (default 30 seconds) +* Param 3 is retry count on failure (default 3) */ $otpInstance = new \AbmmHasan\OTP\OTP(4, 60); diff --git a/src/OTP.php b/src/OTP.php index 83bfac0..d094568 100644 --- a/src/OTP.php +++ b/src/OTP.php @@ -5,6 +5,7 @@ use Exception; use Psr\Cache\InvalidArgumentException; use Symfony\Component\Cache\Adapter\FilesystemAdapter; +use Symfony\Component\Cache\CacheItem; class OTP { @@ -19,6 +20,7 @@ class OTP public function __construct( private int $digitCount = 6, private int $validUpto = 30, + private int $retry = 3, private string $hashAlgorithm = 'sha256' ) { $this->cacheAdapter = new FilesystemAdapter(); @@ -33,10 +35,10 @@ public function __construct( */ public function generate(string $signature): int { + $this->validateRequirements(); $otpAdapter = $this->cacheAdapter->getItem('ao-otp_' . base64_encode($signature)); $otp = $this->number($this->digitCount); - $otpAdapter->set(hash($this->hashAlgorithm, $otp))->expiresAfter($this->validUpto); - $this->cacheAdapter->save($otpAdapter); + $this->storeData($otpAdapter, hash($this->hashAlgorithm, $otp), $this->retry, $this->validUpto); return $otp; } @@ -56,12 +58,16 @@ public function verify(string $signature, int $otp, bool $deleteIfFound = true): } $signature = 'ao-otp_' . base64_encode($signature); $otpAdapter = $this->cacheAdapter->getItem($signature); - if ($otpAdapter->isHit()) { - $isVerified = hash_equals($otpAdapter->get(), hash($this->hashAlgorithm, $otp)); - ($deleteIfFound || $isVerified) && $this->cacheAdapter->deleteItem($signature); - return $isVerified; + if (!$otpAdapter->isHit()) { + return false; } - return false; + ['secret' => $secret, 'retry' => $retry, 'expiresAt' => $expiresAt] = $otpAdapter->get(); + $isVerified = hash_equals($secret, hash($this->hashAlgorithm, $otp)); + match (true) { + $deleteIfFound || $isVerified || $retry < 1 => $this->cacheAdapter->deleteItem($signature), + default => $this->storeData($otpAdapter, $secret, --$retry, $expiresAt - time()) + }; + return $isVerified; } /** @@ -86,6 +92,49 @@ public function flush(): bool return $this->cacheAdapter->clear(); } + /** + * Stores the data in the cache. + * + * @param CacheItem $otpAdapter The OTP adapter. + * @param string $secret The secret. + * @param int $retry The number of retries. + * @param int $ttl The time to live in seconds. + * @return void + */ + private function storeData(CacheItem $otpAdapter, string $secret, int $retry, int $ttl): void + { + if ($ttl < 1) { + return; + } + $this->cacheAdapter->save( + $otpAdapter->set([ + 'secret' => $secret, + 'retry' => $retry, + 'expiresAt' => time() + $ttl + ])->expiresAfter($ttl) + ); + } + + /** + * Validates the requirement for the PHP function. + * + * @throws Exception The number of digits must be between 2 and PHP_INT_SIZE. + * @throws Exception The number of retries must be at least 0. + * @throws Exception Validity duration is invalid. + */ + private function validateRequirements(): void + { + match (true) { + $this->digitCount < 2 || $this->digitCount > PHP_INT_SIZE + => throw new Exception('The number of digits must be between 2 and ' . PHP_INT_SIZE . '.'), + $this->retry < 0 + => throw new Exception('The number of retries must be atleast 0.'), + $this->validUpto < 1 + => throw new Exception('Validity duration is invalid.'), + default => null + }; + } + /** * Generate Secure random number of given length * @@ -96,8 +145,8 @@ public function flush(): bool private function number(int $length): int { return random_int( - intval('1' . str_repeat('0', $length - 1)), - intval(str_repeat('9', $length)) + (int)('1' . str_repeat('0', $length - 1)), + (int)str_repeat('9', $length) ); } } diff --git a/src/TOTP.php b/src/TOTP.php index fb35703..cb51925 100644 --- a/src/TOTP.php +++ b/src/TOTP.php @@ -36,14 +36,19 @@ public function getOTP(int $input = null): string } /** - * Verifies if the given OTP matches the OTP generated by the given timestamp (or Current Timestamp). + * Verifies the provided OTP against the generated OTP for the given timestamp (or Current Timestamp). * * @param string $otp The OTP to be verified. - * @param int|null $input The input used to generate the OTP. Defaults to Current Timestamp. - * @return bool Returns true if the OTP matches the generated one, otherwise false. + * @param int|null $timestamp The timestamp for which the OTP is to be generated. Defaults to Current Timestamp. + * @param bool $leeway Whether to allow a time leeway for OTP verification. + * @return bool Returns true if the provided OTP matches the generated OTP, false otherwise. */ - public function verify(string $otp, int $input = null): bool + public function verify(string $otp, int $timestamp = null, bool $leeway = false): bool { - return hash_equals($otp, $this->getOTP($input)); + $isVerified = hash_equals($otp, $this->getOTP($timestamp)); + if (!$isVerified && $leeway) { + return hash_equals($otp, $this->getOTP(($timestamp ?? time()) - $this->period)); + } + return $isVerified; } }