diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..8fb29a7 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @MincDev \ No newline at end of file diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..4e1ae49 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,20 @@ +# Security Policy + +## Reporting a Vulnerability + +If you discover a security vulnerability in this project, please do **not** open a public GitHub issue. + +Instead, report it privately by emailing: + +**christopher@mincdevelopment.co.za** + +Please include: + +- A description of the vulnerability +- Steps to reproduce it +- The potential impact +- Any suggested fix (if available) + +I will acknowledge receipt as soon as possible and work to resolve verified issues promptly. + +Thank you for helping keep this project secure. \ No newline at end of file diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..3ab07e9 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,26 @@ +version: 2 + +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" + + open-pull-requests-limit: 2 + + commit-message: + prefix: "deps" + + labels: + - "dependencies" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + commit-message: + prefix: "ci" + + labels: + - "github-actions" \ No newline at end of file diff --git a/.github/workflows/run-tests.yml b/.github/workflows/run-tests.yml new file mode 100644 index 0000000..657b56a --- /dev/null +++ b/.github/workflows/run-tests.yml @@ -0,0 +1,35 @@ +name: Run Tests + +on: + push: + branches: + - master + pull_request: + +jobs: + phpunit: + runs-on: ubuntu-latest + + strategy: + matrix: + php: ['8.2', '8.3', '8.4'] + + name: PHP ${{ matrix.php }} + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + + - name: Validate composer.json + run: composer validate --strict + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run PHPUnit + run: vendor/bin/phpunit \ No newline at end of file diff --git a/.gitignore b/.gitignore index 55940e5..061e2fb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,29 @@ +# Composer /vendor/ -composer.lock \ No newline at end of file + +# Lock file (libraries typically don't commit this) +composer.lock + +# PHPUnit +.phpunit.cache/ + +# IDEs +.idea/ +.vscode/ + +# macOS +.DS_Store + +# Windows +Thumbs.db + +# Coverage reports +coverage/ +coverage.xml + +# PHPUnit reports +testdox.html +testdox.txt + +# Xdebug profiling +cachegrind.out.* \ No newline at end of file diff --git a/LICENSE b/LICENSE index aaf779e..a8dbb49 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2021 MincDev +Copyright (c) 2021-2026 MincDev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index d9c1d5f..6c0334f 100644 --- a/README.md +++ b/README.md @@ -1,44 +1,86 @@ -# php-otpauth +# PHP-OtpAuth - A library for generating a 2 factor authentication QR code to use with Google Authenticator, Authy, etc. - -### Secure QRCode Creation +[![Tests](https://github.com/mincdev/php-otpauth/actions/workflows/run-tests.yml/badge.svg)](https://github.com/mincdev/php-otpauth/actions/workflows/run-tests.yml) -This library has secure QRCode creation because of the fact that the QRCode is generated locally on your server. This means that the user's secret is not passed to any third party or remote server in order to generate a code. This was inspired by the stack overflow answer by **kravietz** as seen [here](https://stackoverflow.com/a/56737468/3948544) +A lightweight PHP library for generating and validating **Time-based One-Time Passwords (TOTP)** compatible with Google Authenticator, Microsoft Authenticator, Authy, 1Password, Bitwarden, and other RFC 6238 compatible authenticator applications. -### Installation (Composer) +## Features -``` +- 🔒 Generate cryptographically secure TOTP secrets +- 📱 Generate QR codes for easy authenticator app provisioning +- ✅ Validate one-time passwords +- 🧪 Verified against the official **RFC 6238** test vectors +- 🖥️ QR codes are generated locally on your server — no secrets are sent to third-party services +- 🚀 Lightweight with minimal dependencies + +## Installation + +Install the package using Composer: + +```bash composer require mincdev/php-otpauth ``` -### Dependencies +## Generating a Secret and QR Code -This library requires the **tc-lib-barcode** library found at https://github.com/tecnickcom/tc-lib-barcode. +```php +use MincDev\OtpAuth\OtpAuthenticator; -**Note:** The tc-lib-barcode library is maintained and owned by a separate entity. +$otp = new OtpAuthenticator(); -#### Generating a QR Code +// Store the secret securely for the user. +$secret = $otp->newSecret(); -You can generate a QR code which can be scanned by Google Authenticator, Authy, etc. by using the below. +$qrCode = $otp->getQR( + 'john.doe@example.com', + 'My Awesome App', + $secret +); +``` -```php -$otpAuth = new OtpAuthenticator(); +The returned value is a Base64 data URI that can be used directly in an image tag: + +```html +Authenticator QR Code +``` -$userName = "MrDoe"; -$appName = "My Awesome App"; +## Validating a Code + +```php +use MincDev\OtpAuth\OtpAuthenticator; -// Store this secret somewhere safe, as you'll need it to validate the pin later -$userSecret = $otpAuth->newSecret(); +$otp = new OtpAuthenticator(); -$qrBase64 = $otpAuth->getQR($userName, $appName, $userSecret); +$isValid = $otp->validate($secret, $userEnteredCode); ``` -#### Validating a PIN +## Security -Once your user logs in, you can validate their pin by making use of the following: +This library generates QR codes **entirely on your server**. The user's secret is never transmitted to an external QR code generation service, ensuring that sensitive authentication data remains under your control. -```php -$otpAuth = new OtpAuthenticator(); -$isValid = $otpAuth->validate($userSecret, $pinCode); +QR code generation is powered by `tc-lib-barcode`. + +## Compatibility + +This library implements **RFC 6238 (TOTP)** and works with applications such as: + +- Google Authenticator +- Microsoft Authenticator +- Authy +- 1Password +- Bitwarden +- and other compatible authenticator apps. + +## Testing + +The TOTP implementation is verified against the official **RFC 6238** test vectors to ensure standards-compliant code generation. + +Run the test suite with: + +```bash +composer test ``` + +## License + +Released under the MIT License. \ No newline at end of file diff --git a/composer.json b/composer.json index 0ea97ab..789d5f0 100644 --- a/composer.json +++ b/composer.json @@ -1,10 +1,18 @@ { "name": "mincdev/php-otpauth", - "description": "A library for genrating a 2 factor authentication QR code", + "description": "PHP library for Time-based One-Time Password (TOTP) authentication compatible with Google Authenticator, Microsoft Authenticator and Authy.", "type": "library", "license": "MIT", - "version": "1.0.1", "minimum-stability": "stable", + "keywords": [ + "totp", + "otp", + "2fa", + "two-factor", + "google-authenticator", + "authenticator", + "security" + ], "authors": [ { "name": "Christopher Smit", @@ -12,11 +20,19 @@ } ], "require": { - "tecnickcom/tc-lib-barcode": "^1.17" + "php": "^8.2 || ^8.3 || ^8.4", + "tecnickcom/tc-lib-barcode": "^2.11" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" }, "autoload": { "psr-4": { - "MincDev\\OtpAuth\\": "src/" + "MincDev\\OtpAuth\\": "src/", + "MincDev\\OtpAuth\\Tests\\": "tests/" } + }, + "scripts": { + "test": "phpunit" } -} +} \ No newline at end of file diff --git a/example/example.php b/example/example.php index 1074b64..821db94 100644 --- a/example/example.php +++ b/example/example.php @@ -1,34 +1,121 @@ getQR("John Doe", "Some Site", $authCode->newSecret()); +$error = null; +$isValid = null; -echo " +try { + $_SESSION['secret'] ??= $auth->newSecret(); + $secret = $_SESSION['secret']; + $qrCode = $auth->getQR('John Doe', 'Some Site', $secret); + + if ($_SERVER['REQUEST_METHOD'] === 'POST') { + $code = trim($_POST['code'] ?? ''); + $isValid = $auth->validate($secret, $code); + } +} catch (Throwable $e) { + $error = $e->getMessage(); +} +?> - - - Usage example of PHP-OtpAuth library - - - - -

Usage example of PHP-OtpAuth library

-

This is an usage example of PHP-OtpAuth library.

-

QR Code Output

-

\"Embedded

- - -"; \ No newline at end of file + + + + PHP-OtpAuth Usage Example + + + + +

PHP-OtpAuth Usage Example

+ +

+ Scan the QR code below using a compatible authenticator application, + such as Google Authenticator, Microsoft Authenticator or Authy. +

+ + + +

+ + + +

Your Secret

+ +

+ +

+ +

QR Code

+ +

+ Authenticator QR Code +

+ +

Validate a Code

+ +
+ + + +
+ + + +

+ +

+ + + + + + + \ No newline at end of file diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..392a801 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,18 @@ + + + + + + tests + + + + + + src + + + \ No newline at end of file diff --git a/src/FixedBitNotation.php b/src/FixedBitNotation.php index 5a681e9..28f2c34 100644 --- a/src/FixedBitNotation.php +++ b/src/FixedBitNotation.php @@ -1,46 +1,52 @@ |null */ + private ?array $charMap = null; + /** - * Constructor - * - * @param integer $bitsPerCharacter Bits to use for each encoded - * character - * @param string $chars Base character alphabet - * @param boolean $rightPadFinalBits How to encode last character - * @param boolean $padFinalGroup Add padding to end of encoded - * output - * @param string $padCharacter Character to use for padding - */ + * Constructor + * + * @param integer $bitsPerCharacter Bits to use for each encoded + * character + * @param string $chars Base character alphabet + * @param boolean $rightPadFinalBits How to encode last character + * @param boolean $padFinalGroup Add padding to end of encoded + * output + * @param string $padCharacter Character to use for padding + */ public function __construct( - $bitsPerCharacter, $chars = NULL, $rightPadFinalBits = FALSE, - $padFinalGroup = FALSE, $padCharacter = '=') + int $bitsPerCharacter, + string $chars = self::BASE_CHAR_ALPHABET, + bool $rightPadFinalBits = false, + bool $padFinalGroup = false, + string $padCharacter = '=') { // Ensure validity of $chars if (!is_string($chars) || ($charLength = strlen($chars)) < 2) { - $chars = - '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-,'; + $chars = self::BASE_CHAR_ALPHABET; $charLength = 64; } @@ -71,21 +77,21 @@ public function __construct( $radix = 1 << $bitsPerCharacter; } - $this->_chars = $chars; - $this->_bitsPerCharacter = $bitsPerCharacter; - $this->_radix = $radix; - $this->_rightPadFinalBits = $rightPadFinalBits; - $this->_padFinalGroup = $padFinalGroup; - $this->_padCharacter = $padCharacter[0]; + $this->chars = $chars; + $this->bitsPerCharacter = $bitsPerCharacter; + $this->radix = $radix; + $this->rightPadFinalBits = $rightPadFinalBits; + $this->padFinalGroup = $padFinalGroup; + $this->padCharacter = $padCharacter[0]; } /** * Encode a string * - * @param string $rawString Binary data to encode + * @param string $rawString Binary data to encode * @return string */ - public function encode($rawString) + public function encode(string $rawString): string { // Unpack string into an array of bytes $bytes = unpack('C*', $rawString); @@ -95,11 +101,11 @@ public function encode($rawString) $byte = array_shift($bytes); $bitsRead = 0; - $chars = $this->_chars; - $bitsPerCharacter = $this->_bitsPerCharacter; - $rightPadFinalBits = $this->_rightPadFinalBits; - $padFinalGroup = $this->_padFinalGroup; - $padCharacter = $this->_padCharacter; + $chars = $this->chars; + $bitsPerCharacter = $this->bitsPerCharacter; + $rightPadFinalBits = $this->rightPadFinalBits; + $padFinalGroup = $this->padFinalGroup; + $padCharacter = $this->padCharacter; // Generate encoded output; // each loop produces one encoded character @@ -116,19 +122,21 @@ public function encode($rawString) if (!$bytes) { // Last bits; match final character and exit loop - if ($rightPadFinalBits) $oldBits <<= $newBitCount; + if ($rightPadFinalBits) { + $oldBits <<= $newBitCount; + } $encodedString .= $chars[$oldBits]; if ($padFinalGroup) { // Array of the lowest common multiples of // $bitsPerCharacter and 8, divided by 8 - $lcmMap = array(1 => 1, 2 => 1, 3 => 3, 4 => 1, - 5 => 5, 6 => 3, 7 => 7, 8 => 1); + $lcmMap = [1 => 1, 2 => 1, 3 => 3, 4 => 1, + 5 => 5, 6 => 3, 7 => 7, 8 => 1]; $bytesPerGroup = $lcmMap[$bitsPerCharacter]; $pads = $bytesPerGroup * 8 / $bitsPerCharacter - ceil((strlen($rawString) % $bytesPerGroup) * 8 / $bitsPerCharacter); - $encodedString .= str_repeat($padCharacter[0], $pads); + $encodedString .= str_repeat($padCharacter[0], (int) $pads); } break; @@ -144,12 +152,12 @@ public function encode($rawString) } // Read only the needed bits from this byte - $bits = $byte >> 8 - ($bitsRead + ($newBitCount)); + $bits = $byte >> (8 - ($bitsRead + ($newBitCount))); $bits ^= $bits >> $newBitCount << $newBitCount; $bitsRead += $newBitCount; if ($oldBitCount) { - // Bits come from seperate bytes, add $oldBits to $bits + // Bits come from separate bytes, add $oldBits to $bits $bits = ($oldBits << $newBitCount) | $bits; } @@ -162,39 +170,37 @@ public function encode($rawString) /** * Decode a string * - * @param string $encodedString Data to decode - * @param boolean $caseSensitive - * @param boolean $strict Returns NULL if $encodedString contains + * @param string $encodedString Data to decode + * @param boolean $caseSensitive + * @param boolean $strict Returns NULL if $encodedString contains * an undecodable character * @return string|NULL */ - public function decode($encodedString, $caseSensitive = TRUE, - $strict = FALSE) + public function decode(string $encodedString, bool $caseSensitive = true, bool $strict = false): ?string { - if (!$encodedString || !is_string($encodedString)) { + if (!$encodedString) { // Empty string, nothing to decode return ''; } - $chars = $this->_chars; - $bitsPerCharacter = $this->_bitsPerCharacter; - $radix = $this->_radix; - $rightPadFinalBits = $this->_rightPadFinalBits; - $padFinalGroup = $this->_padFinalGroup; - $padCharacter = $this->_padCharacter; + $chars = $this->chars; + $bitsPerCharacter = $this->bitsPerCharacter; + $radix = $this->radix; + $rightPadFinalBits = $this->rightPadFinalBits; + $padCharacter = $this->padCharacter; // Get index of encoded characters - if ($this->_charmap) { - $charmap = $this->_charmap; + if ($this->charMap) { + $charmap = $this->charMap; } else { - $charmap = array(); + $charmap = []; for ($i = 0; $i < $radix; $i++) { $charmap[$chars[$i]] = $i; } - $this->_charmap = $charmap; + $this->charMap = $charmap; } // The last encoded character is $encodedString[$lastNotatedIndex] @@ -265,7 +271,7 @@ public function decode($encodedString, $caseSensitive = TRUE, } elseif ($strict) { // Unable to decode character; abort - return NULL; + return null; } } diff --git a/src/OtpAuthException.php b/src/OtpAuthException.php index 93c2609..7e7074e 100644 --- a/src/OtpAuthException.php +++ b/src/OtpAuthException.php @@ -1,35 +1,42 @@ - * @package \MincDev\OtpAuth - * @license MIT + * Exception thrown when invalid TOTP configuration is provided. */ -final class OtpAuthException extends \RuntimeException +final class OtpAuthException extends RuntimeException { - public static function InvalidAccountName(string $accountName): self + public const INVALID_ACCOUNT = 1001; + public const INVALID_ISSUER = 1002; + public const INVALID_SECRET = 1003; + + public static function invalidAccountName(string $accountName): self { - return new self(sprintf( - 'The account name may not contain a double colon (:) and may not be an empty string. Given "%s".', - $accountName - )); + return new self( + "The account name must not be empty or contain a colon (:). Given: \"$accountName\".", + self::INVALID_ACCOUNT + ); } - public static function InvalidIssuer(string $issuer): self + public static function invalidIssuer(string $issuer): self { - return new self(sprintf( - 'The issuer name may not contain a double colon (:) and may not be an empty string. Given "%s".', - $issuer - )); + return new self( + "The issuer must not be empty or contain a colon (:). Given: \"$issuer\".", + self::INVALID_ISSUER + ); } - public static function InvalidSecret(): self + public static function invalidSecret(): self { - return new self('The secret name may not be an empty string.'); + return new self( + 'The secret must not be empty.', + self::INVALID_SECRET + ); } } \ No newline at end of file diff --git a/src/OtpAuthInterface.php b/src/OtpAuthInterface.php index a4ae225..db4d4b2 100644 --- a/src/OtpAuthInterface.php +++ b/src/OtpAuthInterface.php @@ -3,35 +3,51 @@ namespace MincDev\OtpAuth; use DateTimeInterface; - -/** - * Interface for the OtpAuth class - * - * @author Christopher Smit - * @package \MincDev\OtpAuth - * @license MIT - */ +use Exception; +use Random\RandomException; interface OtpAuthInterface { /** - * @param string $secret - * @param string $code + * Validate a TOTP code against the supplied secret. + * + * @throws Exception */ public function validate(string $secret, string $code): bool; /** - * @param string $secret - * @param \DateTimeInterface|null $time + * Generate the TOTP code for the supplied secret. + * + * This method is primarily intended for testing, + * interoperability and advanced use cases. + * + * Most applications should use validate() instead. */ public function getCode(string $secret, ?DateTimeInterface $time = null): string; /** - * @param string $accountName - * @param string $issuer - * @param string $secret + * Generate a QR code that can be scanned by compatible + * authenticator applications to register the secret. + * + * Returns a PNG image as a Base64 data URI. + * + * @throws \Com\Tecnick\Barcode\Exception + * @throws \Com\Tecnick\Color\Exception */ - public function getQR(string $accountName, string $issuer, string $secret): string; + public function getQR(string $accountName, ?string $issuer, string $secret): string; + /** + * Generate a new Base32-encoded secret suitable for TOTP. + * + * @throws RandomException + */ public function newSecret(): string; + + /** + * Returns the standard otpauth:// URI for the TOTP configuration. + * + * This URI can be used with any QR code generation library or encoded + * as a QR code for provisioning compatible authenticator applications. + */ + public function getUri(string $accountName, ?string $issuer, string $secret): string; } \ No newline at end of file diff --git a/src/OtpAuthenticator.php b/src/OtpAuthenticator.php index 7354125..ccc9925 100644 --- a/src/OtpAuthenticator.php +++ b/src/OtpAuthenticator.php @@ -1,73 +1,83 @@ - * @package \MincDev\OtpAuth - * @license MIT - * @see https://github.com/google/google-authenticator/wiki/Key-Uri-Format + * Generates and validates Time-based One-Time Passwords (TOTP) + * and creates QR codes for provisioning authenticator applications. + * + * @see https://datatracker.ietf.org/doc/html/rfc6238 TOTP: Time-Based One-Time Password Algorithm */ class OtpAuthenticator implements OtpAuthInterface { + private const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; + private const QR_SIZE = 200; + /** - * The lenght of the OTP code + * The length of the OTP code * @var int */ - private $codeLength; + private readonly int $codeLength; /** * The length of the secret * @var int */ - private $secretLength; + private readonly int $secretLength; /** * Mod for the pin code * @var int */ - private $pinModulo; + private readonly int $pinModulo; /** - * @var \DateTimeInterface + * @var DateTimeInterface */ - private $instanceTime; + private readonly DateTimeInterface $instanceTime; /** * Validity of the code * @var int */ - private $validFor; + private readonly int $validFor; /** * Length of a period to calculate periods since Unix epoch. * @var int */ - private $periodSize = 30; + private int $periodSize = 30; - public function __construct(int $codeLength = 6, int $secretLength = 10, ?\DateTimeInterface $instanceTime = null, int $validFor = 30) - { + private readonly FixedBitNotation $base32; + + public function __construct( + int $codeLength = 6, + int $secretLength = 10, + ?DateTimeInterface $instanceTime = null, + int $validFor = 30 + ) { $this->codeLength = $codeLength; $this->secretLength = $secretLength; $this->validFor = $validFor; - $this->periodSize = $validFor < $this->periodSize ? $validFor : $this->periodSize; + $this->periodSize = min($validFor, $this->periodSize); $this->pinModulo = 10 ** $codeLength; - $this->instanceTime = $instanceTime ?? new \DateTimeImmutable(); + $this->instanceTime = $instanceTime ?? new DateTimeImmutable(); + $this->base32 = new FixedBitNotation(5, self::BASE32_ALPHABET, true, true); } public function validate(string $secret, string $code): bool { - $periods = floor($this->validFor / $this->periodSize); + $periods = (int) floor($this->validFor / $this->periodSize); $result = 0; for ($i = 0; $i < $periods; ++$i) { - $dateTime = new \DateTimeImmutable('@'.($this->instanceTime->getTimestamp() - ($i * $this->periodSize))); + $dateTime = new DateTimeImmutable('@'.($this->instanceTime->getTimestamp() - ($i * $this->periodSize))); $result = hash_equals($this->getCode($secret, $dateTime), $code) ? $dateTime->getTimestamp() : $result; } @@ -80,68 +90,68 @@ public function getCode(string $secret, ?DateTimeInterface $time = null): string $time = $this->instanceTime; } - $timeForCode = floor($time->getTimestamp() / $this->periodSize); - - - $base32 = new FixedBitNotation(5, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', true, true); - $secret = $base32->decode($secret); + $timeForCode = (int) floor($time->getTimestamp() / $this->periodSize); + + $secret = $this->base32->decode($secret); - $timeForCode = str_pad(pack('N', $timeForCode), 8, \chr(0), \STR_PAD_LEFT); + $timeForCode = str_pad(pack('N', $timeForCode), 8, chr(0), STR_PAD_LEFT); $hash = hash_hmac('sha1', $timeForCode, $secret, true); - $offset = \ord(substr($hash, -1)); + $offset = ord(substr($hash, -1)); $offset &= 0xF; $truncatedHash = $this->hashToInt($hash, $offset) & 0x7FFFFFFF; - return str_pad((string) ($truncatedHash % $this->pinModulo), $this->codeLength, '0', \STR_PAD_LEFT); + return str_pad((string) ($truncatedHash % $this->pinModulo), $this->codeLength, '0', STR_PAD_LEFT); } public function newSecret(): string { - return (new FixedBitNotation(5, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567', true, true)) - ->encode(random_bytes($this->secretLength)); + return $this->base32->encode(random_bytes($this->secretLength)); } private function hashToInt(string $bytes, int $start): int { - return unpack('N', substr(substr($bytes, $start), 0, 4))[1]; + return unpack('N', substr($bytes, $start, 4))[1]; } - public function getQR(string $accountName, string $issuer, string $secret): string + public function getQR(string $accountName, ?string $issuer, string $secret): string { - if ('' === $accountName || false !== strpos($accountName, ':')) { - throw OtpAuthException::InvalidAccountName($accountName); + $otpauthString = $this->getUri($accountName, $issuer, $secret); + $barcode = new Barcode(); + + $qrCode = $barcode->getBarcodeObj( + 'QRCODE,H', + $otpauthString, + self::QR_SIZE, + self::QR_SIZE + )->setBackgroundColor('white'); + + return 'data:image/png;base64,' . base64_encode($qrCode->getPngData()); + } + + public function getUri(string $accountName, ?string $issuer, string $secret): string + { + if ('' === $accountName || str_contains($accountName, ':')) { + throw OtpAuthException::invalidAccountName($accountName); } if ('' === $secret) { - throw OtpAuthException::InvalidSecret(); + throw OtpAuthException::invalidSecret(); } $label = $accountName; $otpauthString = 'otpauth://totp/%s?secret=%s'; if (null !== $issuer) { - if ('' === $issuer || false !== strpos($issuer, ':')) { - throw OtpAuthException::InvalidIssuer($issuer); + if ('' === $issuer || str_contains($issuer, ':')) { + throw OtpAuthException::invalidIssuer($issuer); } $label = $issuer.':'.$label; $otpauthString .= '&issuer=%s'; } - $otpauthString = sprintf($otpauthString, $label, $secret, $issuer); - - $barcode = new Barcode(); - $qrCode = $barcode->getBarcodeObj( - 'QRCODE,H', - $otpauthString, - 200, - 200, - 'black', - [0,0,0,0] - )->setBackgroundColor('white'); - - return base64_encode($qrCode->getPngData()); + return sprintf($otpauthString, $label, $secret, $issuer); } } \ No newline at end of file diff --git a/tests/FixedBitNotationTest.php b/tests/FixedBitNotationTest.php new file mode 100644 index 0000000..bf511b3 --- /dev/null +++ b/tests/FixedBitNotationTest.php @@ -0,0 +1,25 @@ +encode($raw); + + $decoded = $base32->decode($encoded); + + $this->assertSame($raw, $decoded); + } +} \ No newline at end of file diff --git a/tests/OtpAuthenticatorTest.php b/tests/OtpAuthenticatorTest.php new file mode 100644 index 0000000..8cb6f3b --- /dev/null +++ b/tests/OtpAuthenticatorTest.php @@ -0,0 +1,363 @@ +encode('12345678901234567890'); + + $this->assertSame( + $expected, + $totp->getCode( + $secret, + new DateTimeImmutable("@$timestamp") + ) + ); + } + + /** + * @throws RandomException + */ + #[Test] + public function newSecretReturnsString(): void + { + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + + $isBase32 = function($secret) { + // RFC 4648 Base32 pattern (A-Z and 2-7) with optional strict padding + $pattern = '#^(?:[A-Z2-7]{8})*(?:[A-Z2-7]{2}={6}|[A-Z2-7]{4}={4}|[A-Z2-7]{5}={3}|[A-Z2-7]{7}=)?$#i'; + return preg_match($pattern, $secret) === 1; + }; + + $this->assertNotEmpty($secret); + $this->assertIsString($secret); + $this->assertTrue($isBase32($secret)); + } + + /** + * @throws RandomException + */ + #[Test] + public function generatedSecretsAreUnique(): void + { + $totp = new OtpAuthenticator(); + + $secrets = []; + + for ($i = 0; $i < 100; $i++) { + $secrets[] = $totp->newSecret(); + } + + $this->assertCount(100, $secrets); + $this->assertCount(100, array_unique($secrets)); + } + + /** + * @throws RandomException + */ + #[Test] + public function getCodeReturnsSixDigits(): void + { + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + $code = $totp->getCode($secret); + + $this->assertMatchesRegularExpression('/^\d{6}$/', $code); + } + + /** + * @throws RandomException + */ + #[Test] + public function sameSecretAndTimeGenerateSameCode(): void + { + $totp = new OtpAuthenticator(); + + $secret = $totp->newSecret(); + $time = new DateTimeImmutable('@1234'); + + $firstCode = $totp->getCode($secret, $time); + $secondCode = $totp->getCode($secret, $time); + + $this->assertSame($firstCode, $secondCode); + } + + /** + * @throws RandomException + */ + #[Test] + public function differentTimeGeneratesDifferentCode(): void + { + $totp = new OtpAuthenticator(); + + $secret = $totp->newSecret(); + $firstTime = new DateTimeImmutable('@1234'); + $secondTime = new DateTimeImmutable('@4321'); + + $firstCode = $totp->getCode($secret, $firstTime); + $secondCode = $totp->getCode($secret, $secondTime); + + $this->assertNotSame($firstCode, $secondCode); + } + + /** + * @throws RandomException + */ + #[Test] + public function getCodeReturnsEightDigitsWhenConfigured(): void + { + $totp = new OtpAuthenticator(8); + + $secret = $totp->newSecret(); + $code = $totp->getCode($secret); + + $this->assertMatchesRegularExpression('/^\d{8}$/', $code); + } + + /** + * @throws RandomException + * @throws Exception + */ + #[Test] + public function validateReturnsTrueForCorrectCode(): void + { + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + $code = $totp->getCode($secret); + + $validationResult = $totp->validate($secret, $code); + + $this->assertTrue($validationResult); + } + + /** + * @throws RandomException + * @throws Exception + */ + #[Test] + public function validateReturnsTrueForCorrectCodeWithDeterministicTimestamp(): void + { + $time = new DateTimeImmutable('@1234'); + + $totp = new OtpAuthenticator(instanceTime: $time); + $secret = $totp->newSecret(); + + $code = $totp->getCode($secret, $time); + $validationResult = $totp->validate($secret, $code); + + $this->assertTrue($validationResult); + } + + /** + * @throws RandomException + * @throws Exception + */ + #[Test] + public function validateReturnsFalseForIncorrectCode(): void + { + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + $randomCode = '123456'; + + $validationResult = $totp->validate($secret, $randomCode); + + $this->assertFalse($validationResult); + } + + /** + * @throws RandomException + * @throws \Com\Tecnick\Barcode\Exception + * @throws \Com\Tecnick\Color\Exception + */ + #[Test] + public function getQRWithEmptyAccountNameThrowsException(): void + { + $this->expectException(OtpAuthException::class); + $this->expectExceptionMessage("The account name must not be empty or contain a colon (:). Given: \"\"."); + $this->expectExceptionCode(1001); + + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + + $totp->getQR("", null, $secret); + } + + /** + * @throws RandomException + * @throws \Com\Tecnick\Barcode\Exception + * @throws \Com\Tecnick\Color\Exception + */ + #[Test] + public function getQRWithInvalidAccountNameThrowsException(): void + { + $invalidAccountName = "invalid:accountName"; + + $this->expectException(OtpAuthException::class); + $this->expectExceptionMessage("The account name must not be empty or contain a colon (:). Given: \"$invalidAccountName\"."); + $this->expectExceptionCode(1001); + + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + + $totp->getQR($invalidAccountName, null, $secret); + } + + /** + * @throws \Com\Tecnick\Barcode\Exception + * @throws RandomException + * @throws \Com\Tecnick\Color\Exception + */ + #[Test] + public function getQRWithEmptyIssuerThrowsException(): void + { + $this->expectException(OtpAuthException::class); + $this->expectExceptionMessage("The issuer must not be empty or contain a colon (:). Given: \"\"."); + $this->expectExceptionCode(1002); + + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + + $totp->getQR("john.doe@example.com", "", $secret); + } + + /** + * @throws \Com\Tecnick\Barcode\Exception + * @throws RandomException + * @throws \Com\Tecnick\Color\Exception + */ + #[Test] + public function getQRWithInvalidIssuerThrowsException(): void + { + $invalidIssuer = "invalid:issuer"; + + $this->expectException(OtpAuthException::class); + $this->expectExceptionMessage("The issuer must not be empty or contain a colon (:). Given: \"$invalidIssuer\"."); + $this->expectExceptionCode(1002); + + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + + $totp->getQR("john.doe@example.com", $invalidIssuer, $secret); + } + + /** + * @throws RandomException + */ + #[Test] + public function getUriReturnsTheFullValidOtpAuthUri(): void + { + $accountName = "john.doe@example.com"; + $issuer = "MyAwesomeApp"; + + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + + $uri = $totp->getUri($accountName, $issuer, $secret); + + $this->assertSame("otpauth://totp/$issuer:$accountName?secret=$secret&issuer=$issuer", $uri); + } + + /** + * @throws RandomException + */ + #[Test] + public function getUriWithoutIssuerReturnsTheFullValidOtpAuthUri(): void + { + $accountName = "john.doe@example.com"; + + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + + $uri = $totp->getUri($accountName, null, $secret); + + $this->assertSame("otpauth://totp/$accountName?secret=$secret", $uri); + } + + /** + * @throws \Com\Tecnick\Barcode\Exception + * @throws \Com\Tecnick\Color\Exception + */ + #[Test] + public function getQRWithEmptySecretThrowsException(): void + { + $this->expectException(OtpAuthException::class); + $this->expectExceptionMessage("The secret must not be empty."); + $this->expectExceptionCode(1003); + + $totp = new OtpAuthenticator(); + + $totp->getQR("john.doe@example.com", null, ""); + } + + /** + * @throws \Com\Tecnick\Barcode\Exception + * @throws RandomException + * @throws \Com\Tecnick\Color\Exception + */ + #[Test] + public function getQRReturnsDataUri(): void + { + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + + $qr = $totp->getQR("john.doe@example.com", null, $secret); + + $this->assertStringStartsWith( + 'data:image/png;base64,', + $qr + ); + } + + /** + * @throws \Com\Tecnick\Barcode\Exception + * @throws RandomException + * @throws \Com\Tecnick\Color\Exception + */ + #[Test] + public function getQRContainsPNG(): void + { + $totp = new OtpAuthenticator(); + $secret = $totp->newSecret(); + + $qr = $totp->getQR("john.doe@example.com", null, $secret); + $png = base64_decode(str_replace('data:image/png;base64,', '', $qr)); + + $this->assertStringStartsWith( + "\x89PNG", + $png + ); + } + + public static function rfc6238Vectors(): iterable + { + yield '59 seconds' => [59, '94287082']; + yield '1111111109' => [1111111109, '07081804']; + yield '1111111111' => [1111111111, '14050471']; + yield '1234567890' => [1234567890, '89005924']; + yield '2000000000' => [2000000000, '69279037']; + yield '20000000000' => [20000000000, '65353130']; + } +} \ No newline at end of file