diff --git a/src/HOTP.php b/src/HOTP.php index dd297ac..8dc18d3 100644 --- a/src/HOTP.php +++ b/src/HOTP.php @@ -12,33 +12,36 @@ */ final class HOTP extends OTP implements HOTPInterface { - protected function __construct(null|string $secret, int $counter, string $digest, int $digits) - { - parent::__construct($secret, $digest, $digits); - $this->setCounter($counter); - } - public static function create( null|string $secret = null, - int $counter = 0, - string $digest = 'sha1', - int $digits = 6 + int $counter = self::DEFAULT_COUNTER, + string $digest = self::DEFAULT_DIGEST, + int $digits = self::DEFAULT_DIGITS ): self { - return new self($secret, $counter, $digest, $digits); + $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, - int $counter = 0, - string $digest = 'sha1', - int $digits = 6 - ): self { - return new self($secret, $counter, $digest, $digits); + 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(int $counter = 0, string $digest = 'sha1', int $digits = 6): self + public static function generate(): self { - return new self(self::generateSecret(), $counter, $digest, $digits); + return self::createFromSecret(self::generateSecret()); } public function getCounter(): int @@ -72,7 +75,7 @@ public function verify(string $otp, null|int $counter = null, null|int $window = return $this->verifyOtpWithWindow($otp, $counter, $window); } - protected function setCounter(int $counter): void + public function setCounter(int $counter): void { $this->setParameter('counter', $counter); } diff --git a/src/HOTPInterface.php b/src/HOTPInterface.php index aa1f004..853e76c 100644 --- a/src/HOTPInterface.php +++ b/src/HOTPInterface.php @@ -6,6 +6,8 @@ interface HOTPInterface extends OTPInterface { + public const DEFAULT_COUNTER = 0; + /** * The initial counter (a positive integer). */ @@ -16,6 +18,9 @@ public function getCounter(): int; * * 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( @@ -25,20 +30,5 @@ public static function create( int $digits = 6 ): self; - /** - * Create a TOTP object from an existing secret. - * - * @param non-empty-string $secret - */ - public static function createFromSecret( - string $secret, - int $counter = 0, - string $digest = 'sha1', - int $digits = 6 - ): self; - - /** - * Create a new HOTP object. A random 64 bytes secret will be generated. - */ - public static function generate(int $counter = 0, string $digest = 'sha1', int $digits = 6): self; + public function setCounter(int $counter): void; } diff --git a/src/OTP.php b/src/OTP.php index d51ea7b..ba640a3 100644 --- a/src/OTP.php +++ b/src/OTP.php @@ -17,11 +17,12 @@ abstract class OTP implements OTPInterface { use ParameterTrait; - protected function __construct(null|string $secret, string $digest, int $digits) + /** + * @param non-empty-string $secret + */ + protected function __construct(string $secret) { $this->setSecret($secret); - $this->setDigest($digest); - $this->setDigits($digits); } public function getQrCodeUri(string $uri, string $placeholder): string diff --git a/src/OTPInterface.php b/src/OTPInterface.php index 072e821..3b27f54 100644 --- a/src/OTPInterface.php +++ b/src/OTPInterface.php @@ -6,6 +6,34 @@ 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 */ diff --git a/src/ParameterTrait.php b/src/ParameterTrait.php index 2a01ca6..80d3888 100644 --- a/src/ParameterTrait.php +++ b/src/ParameterTrait.php @@ -121,6 +121,21 @@ public function setParameter(string $parameter, mixed $value): void } } + 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 */ @@ -161,21 +176,6 @@ protected function getParameterMap(): array ]; } - private function setSecret(null|string $secret): void - { - $this->setParameter('secret', $secret); - } - - private function setDigits(int $digits): void - { - $this->setParameter('digits', $digits); - } - - private function setDigest(string $digest): void - { - $this->setParameter('algorithm', $digest); - } - private function hasColon(string $value): bool { $colons = [':', '%3A', '%3a']; diff --git a/src/TOTP.php b/src/TOTP.php index 61b8104..e9bce9e 100644 --- a/src/TOTP.php +++ b/src/TOTP.php @@ -12,40 +12,39 @@ */ final class TOTP extends OTP implements TOTPInterface { - protected function __construct(null|string $secret, int $period, string $digest, int $digits, int $epoch = 0) - { - parent::__construct($secret, $digest, $digits); - $this->setPeriod($period); - $this->setEpoch($epoch); - } - public static function create( null|string $secret = null, - int $period = 30, - string $digest = 'sha1', - int $digits = 6, - int $epoch = 0 + int $period = self::DEFAULT_PERIOD, + string $digest = self::DEFAULT_DIGEST, + int $digits = self::DEFAULT_DIGITS, + int $epoch = self::DEFAULT_EPOCH ): self { - return new self($secret, $period, $digest, $digits, $epoch); + $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, - int $period = 30, - string $digest = 'sha1', - int $digits = 6, - int $epoch = 0 - ): self { - return new self($secret, $period, $digest, $digits, $epoch); + 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( - int $period = 30, - string $digest = 'sha1', - int $digits = 6, - int $epoch = 0 - ): self { - return new self(self::generateSecret(), $period, $digest, $digits, $epoch); + public static function generate(): self + { + return self::createFromSecret(self::generateSecret()); } public function getPeriod(): int @@ -118,11 +117,16 @@ public function getProvisioningUri(): string return $this->generateURI('totp', $params); } - protected function setPeriod(int $period): void + public function setPeriod(int $period): void { $this->setParameter('period', $period); } + public function setEpoch(int $epoch): void + { + $this->setParameter('epoch', $epoch); + } + /** * @return array */ @@ -161,11 +165,6 @@ protected function filterOptions(array &$options): void ksort($options); } - private function setEpoch(int $epoch): void - { - $this->setParameter('epoch', $epoch); - } - private function timecode(int $timestamp): int { return (int) floor(($timestamp - $this->getEpoch()) / $this->getPeriod()); diff --git a/src/TOTPInterface.php b/src/TOTPInterface.php index d0f05d3..afb54e8 100644 --- a/src/TOTPInterface.php +++ b/src/TOTPInterface.php @@ -6,36 +6,30 @@ 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 = 30, - string $digest = 'sha1', - int $digits = 6 + int $period = self::DEFAULT_PERIOD, + string $digest = self::DEFAULT_DIGEST, + int $digits = self::DEFAULT_DIGITS ): self; - /** - * Create a TOTP object from an existing secret. - * - * @param non-empty-string $secret - */ - public static function createFromSecret( - string $secret, - int $period = 30, - string $digest = 'sha1', - int $digits = 6 - ): self; + public function setPeriod(int $period): void; - /** - * Create a new TOTP object. A random 64 bytes secret will be generated. - */ - public static function generate(int $period = 30, string $digest = 'sha1', int $digits = 6): self; + public function setEpoch(int $epoch): void; /** * Return the TOTP at the current time. diff --git a/tests/HOTPTest.php b/tests/HOTPTest.php index e21c2d6..f9e8531 100644 --- a/tests/HOTPTest.php +++ b/tests/HOTPTest.php @@ -30,10 +30,10 @@ public function labelNotDefined(): void */ public function issuerHasColon(): void { + $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Issuer must not contain a colon.'); - $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); - $otp->setLabel('alice'); $otp->setIssuer('foo%3Abar'); } @@ -42,10 +42,10 @@ public function issuerHasColon(): void */ public function issuerHasColon2(): void { + $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Issuer must not contain a colon.'); - $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); - $otp->setLabel('alice'); $otp->setIssuer('foo%3abar'); } @@ -54,11 +54,11 @@ public function issuerHasColon2(): void */ public function labelHasColon(): void { + $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Label must not contain a colon.'); - $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); $otp->setLabel('foo%3Abar'); - $otp->getProvisioningUri(); } /** @@ -66,11 +66,11 @@ public function labelHasColon(): void */ public function labelHasColon2(): void { + $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Label must not contain a colon.'); - $otp = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 8); $otp->setLabel('foo:bar'); - $otp->getProvisioningUri(); } /** @@ -78,9 +78,11 @@ public function labelHasColon2(): void */ public function digitsIsNot1OrMore(): void { + $htop = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Digits must be at least 1.'); - HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'sha512', 0); + $htop->setDigits(0); } /** @@ -88,9 +90,11 @@ public function digitsIsNot1OrMore(): void */ public function counterIsNot1OrMore(): void { + $htop = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Counter must be at least 0.'); - HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', -500); + $htop->setCounter(-500); } /** @@ -98,9 +102,11 @@ public function counterIsNot1OrMore(): void */ public function digestIsNotSupported(): void { + $htop = HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('The "foo" digest is not supported.'); - HOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 0, 'foo'); + $htop->setDigest('foo'); } /** @@ -110,11 +116,10 @@ public function digestIsNotSupported(): void */ public function secretShouldBeBase32Encoded(): void { + $otp = HOTP::createFromSecret(random_bytes(32)); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Unable to decode the secret. Is it correctly base32 encoded?'); - $secret = random_bytes(32); - - $otp = HOTP::createFromSecret($secret); $otp->at(0); } @@ -185,8 +190,12 @@ private function createHOTP( string $issuer = 'My Project' ): HOTP { static::assertNotSame('', $secret); + static::assertNotSame('', $digest); - $otp = HOTP::createFromSecret($secret, $counter, $digest, $digits); + $otp = HOTP::createFromSecret($secret); + $otp->setCounter($counter); + $otp->setDigest($digest); + $otp->setDigits($digits); $otp->setLabel($label); $otp->setIssuer($issuer); diff --git a/tests/TOTPTest.php b/tests/TOTPTest.php index f28b7cb..03691da 100644 --- a/tests/TOTPTest.php +++ b/tests/TOTPTest.php @@ -33,7 +33,11 @@ public function labelNotDefined(): void */ public function customParameter(): void { - $otp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 20, 'sha512', 8, 100); + $otp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $otp->setPeriod(20); + $otp->setDigest('sha512'); + $otp->setDigits(8); + $otp->setEpoch(100); $otp->setLabel('alice@foo.bar'); $otp->setIssuer('My Project'); $otp->setParameter('foo', 'bar.baz'); @@ -59,9 +63,11 @@ public function objectCreationValid(): void */ public function periodIsNot1OrMore(): void { + $totp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Period must be at least 1.'); - TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', -20, 'sha512', 8); + $totp->setPeriod(-20); } /** @@ -69,9 +75,11 @@ public function periodIsNot1OrMore(): void */ public function epochIsNot0OrMore(): void { + $totp = TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y'); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Epoch must be greater than or equal to 0.'); - TOTP::createFromSecret('JDDK4U6G3BJLEZ7Y', 30, 'sha512', 8, -1); + $totp->setEpoch(-1); } /** @@ -403,8 +411,13 @@ private function createTOTP( int $epoch = 0 ): TOTP { static::assertNotSame('', $secret); + static::assertNotSame('', $digest); - $otp = TOTP::createFromSecret($secret, $period, $digest, $digits, $epoch); + $otp = TOTP::createFromSecret($secret); + $otp->setPeriod($period); + $otp->setDigest($digest); + $otp->setDigits($digits); + $otp->setEpoch($epoch); $otp->setLabel($label); $otp->setIssuer($issuer);