diff --git a/README.md b/README.md index 9670279..747ce8c 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,30 @@ -Google Authenticator (TOTP) -=========================== +# Google Authenticator (TOTP) ![Build Status](https://github.com/Vectorface/GoogleAuthenticator/workflows/Test/badge.svg) **English** | [中文](./README.zh-CN.md) -This is a fork of https://github.com/PHPGangsta/GoogleAuthenticator with the following changes: +A PHP library for two-factor authentication (2FA) compatible with the Google Authenticator mobile app. +It can generate secrets, generate and verify one-time codes, and render the secret as a scannable +QR code — implementing TOTP as specified in [RFC 6238](https://tools.ietf.org/html/rfc6238). -- Uses https://github.com/endroid/qr-code to generate QR code data URIs -- No longer generates Google's Chart API to make QR code links -- Uses namespacing -- Augmented test coverage to 100% -- Bumped minimum PHP version to 8.2 - -Original License: ------------------ +This is a fork of [PHPGangsta/GoogleAuthenticator](https://github.com/PHPGangsta/GoogleAuthenticator) with the following changes: -* Copyright (c) 2012-2016, [http://www.phpgangsta.de](http://www.phpgangsta.de) -* Author: Michael Kliewe, [@PHPGangsta](http://twitter.com/PHPGangsta) and [contributors](https://github.com/PHPGangsta/GoogleAuthenticator/graphs/contributors) -* Licensed under the BSD License. - -Description: ------------- +- Uses [endroid/qr-code](https://github.com/endroid/qr-code) to generate QR code data URIs +- No longer relies on Google's Chart API for QR code links +- Uses namespacing +- Test coverage raised to 100% +- Minimum PHP version bumped to 8.2 -This PHP class can be used to interact with the Google Authenticator mobile app for 2-factor-authentication. This class -can generate secrets, generate codes, validate codes and present a QR-Code for scanning the secret. It implements TOTP -according to [RFC6238](https://tools.ietf.org/html/rfc6238) +## Installation -For a secure installation you have to make sure that used codes cannot be reused (replay-attack). You also need to -limit the number of verifications, to fight against brute-force attacks. For example you could limit the amount of -verifications to 10 tries within 10 minutes for one IP address (or IPv6 block). It depends on your environment. +Install via [Composer](https://getcomposer.org/doc/01-basic-usage.md): -Usage: ------- +```bash +composer require vectorface/googleauthenticator +``` -See following example: +## Quick Start ```php createSecret(); echo "Secret is: {$secret}\n\n"; +// 2. Render the secret as a QR code (PNG data URI) for the user to scan $qrCodeUrl = $ga->getQRCodeUrl('Admin', $secret, 'Blog'); echo "PNG Data URI for the QR-Code: {$qrCodeUrl}\n\n"; +// 3. Verify the one-time code entered by the user $oneCode = $ga->getCode($secret); echo "Checking Code '$oneCode' and Secret '$secret':\n"; -// 2 = 2*30sec clock tolerance +// discrepancy = 2 allows a clock tolerance of ±2 × 30 seconds $checkResult = $ga->verifyCode($secret, $oneCode, 2); -if ($checkResult) { - echo 'OK'; -} else { - echo 'FAILED'; -} +echo $checkResult ? 'OK' : 'FAILED'; ``` -Running the script provides output similar to: + +Running the script produces output similar to: + ``` Secret is: OQB6ZZGYHCPSX4AK @@ -70,17 +62,71 @@ Checking Code '848634' and Secret 'OQB6ZZGYHCPSX4AK': OK ``` -Installation: -------------- +## Security Considerations + +### Preventing replay attacks + +A TOTP code must never be accepted twice. `verifyCode()` accepts an optional by-reference +parameter that receives the time slice which matched. As required by +[RFC 6238 section 5.2](https://tools.ietf.org/html/rfc6238#section-5.2), persist the last +accepted time slice per user and refuse any code that matches a slice less than or equal to +the stored value: + +```php +$matchedTimeSlice = null; +if ($ga->verifyCode($secret, $oneCode, 2, $matchedTimeSlice)) { + if ($matchedTimeSlice <= $user->lastUsedTimeSlice) { + // Code already used: reject to prevent a replay attack + } else { + $user->lastUsedTimeSlice = $matchedTimeSlice; // persist this value + // Code accepted + } +} +``` + +### Rate limiting + +To defend against brute-force attacks, limit the number of verification attempts. For example, +allow at most 10 tries within 10 minutes per IP address (or IPv6 block) — adjust to fit your +environment. + +## Advanced Usage + +### Custom code length + +Codes are 6 digits by default. Lengths of 6 to 8 digits are supported (RFC 4226): + +```php +$ga = (new GoogleAuthenticator())->setCodeLength(8); +``` + +### Building otpauth:// URIs + +For full control over the provisioning URI (algorithm, digits, period, HOTP counter, etc.), +use the fluent `UriBuilder`: + +```php +$uri = $ga->getUriBuilder() + ->issuer('Blog') + ->account('Admin') + ->secret($secret) + ->getUri(); // otpauth://totp/Blog:%20Admin?secret=...&issuer=Blog +``` + +## Running Tests + +All tests live in the `tests` folder. + +```bash +composer install +composer test +``` -- Use [Composer](https://getcomposer.org/doc/01-basic-usage.md) to - install the package +## License -```composer require vectorface/googleauthenticator``` +Licensed under the [BSD License](./LICENSE.md). -Run Tests: ----------- +Original work: -- All tests are inside `tests` folder. -- Execute `composer install` to prepare your environment. -- Run `composer test` from the project root directory. +- Copyright (c) 2012-2016, [http://www.phpgangsta.de](http://www.phpgangsta.de) +- Author: Michael Kliewe, [@PHPGangsta](http://twitter.com/PHPGangsta) and [contributors](https://github.com/PHPGangsta/GoogleAuthenticator/graphs/contributors) diff --git a/README.zh-CN.md b/README.zh-CN.md index 976ce1d..14c1e83 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,36 +1,30 @@ -Google Authenticator (TOTP) -=========================== +# Google Authenticator (TOTP) ![构建状态](https://github.com/Vectorface/GoogleAuthenticator/workflows/Test/badge.svg) **中文** | [English](./README.md) -这是 https://github.com/PHPGangsta/GoogleAuthenticator 的一个分支,包含以下更改: +一个与 Google Authenticator 移动应用兼容的 PHP 双因素认证(2FA)库。 +它可以生成密钥、生成并校验一次性验证码,并将密钥渲染为可扫描的二维码 —— 按照 +[RFC 6238](https://tools.ietf.org/html/rfc6238) 规范实现 TOTP。 -- 使用 https://github.com/endroid/qr-code 生成二维码数据 URIs -- 不再生成 Google 的 Chart API 来制作二维码链接 -- 使用命名空间 -- 将测试覆盖率增加到 100% -- 将最低 PHP 版本提升到 8.2 - -原始许可证: ------------------ +本项目是 [PHPGangsta/GoogleAuthenticator](https://github.com/PHPGangsta/GoogleAuthenticator) 的分支,包含以下改动: -* 版权所有 (c) 2012-2016, [http://www.phpgangsta.de](http://www.phpgangsta.de) -* 作者:Michael Kliewe, [@PHPGangsta](http://twitter.com/PHPGangsta) 和 [贡献者](https://github.com/PHPGangsta/GoogleAuthenticator/graphs/contributors) -* 根据 BSD 许可证授权。 - -描述: ------------- +- 使用 [endroid/qr-code](https://github.com/endroid/qr-code) 生成二维码数据 URI +- 不再依赖 Google Chart API 生成二维码链接 +- 使用命名空间 +- 测试覆盖率提升至 100% +- 最低 PHP 版本提升至 8.2 -这个 PHP 类可以用来与 Google Authenticator 移动应用进行双重因素认证交互。该类可以生成密钥、生成代码、验证代码并提供用于扫描密钥的二维码。它实现了根据 [RFC6238](https://tools.ietf.org/html/rfc6238) 的 TOTP。 +## 安装 -为了您能安全安装,您必须确保使用的代码不能被重复使用(重放攻击)。您还需要限制验证次数,以对抗暴力攻击。例如,您可以将一个 IP 地址(或 IPv6 块)的验证次数限制为 10 分钟内 10 次尝试。这取决于您的环境。 +通过 [Composer](https://getcomposer.org/doc/01-basic-usage.md) 安装: -用法: ------- +```bash +composer require vectorface/googleauthenticator +``` -请参见以下示例: +## 快速开始 ```php createSecret(); echo "密钥是: {$secret}\n\n"; +// 2. 将密钥渲染为二维码(PNG 数据 URI)供用户扫描 $qrCodeUrl = $ga->getQRCodeUrl('Admin', $secret, 'Blog'); echo "二维码的 PNG 数据 URI: {$qrCodeUrl}\n\n"; +// 3. 校验用户输入的一次性验证码 $oneCode = $ga->getCode($secret); -echo "检查代码 '$oneCode' 和密钥 '$secret':\n"; +echo "检查验证码 '$oneCode' 和密钥 '$secret':\n"; -// 2 = 2*30秒的时钟容差 +// discrepancy = 2 表示允许 ±2 × 30 秒的时钟偏差 $checkResult = $ga->verifyCode($secret, $oneCode, 2); -if ($checkResult) { - echo 'OK'; -} else { - echo 'FAILED'; -} +echo $checkResult ? 'OK' : 'FAILED'; ``` -运行脚本会提供类似以下的输出: + +运行脚本会得到类似以下的输出: + ``` 密钥是: OQB6ZZGYHCPSX4AK 二维码的 PNG 数据 URI: data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAARgAAAEYCAIAAAAI[已截断] -检查代码 '848634' 和密钥 'OQB6ZZGYHCPSX4AK': +检查验证码 '848634' 和密钥 'OQB6ZZGYHCPSX4AK': OK ``` -安装: -------------- +## 安全注意事项 + +### 防止重放攻击 + +同一个 TOTP 验证码绝不能被接受两次。`verifyCode()` 接受一个可选的引用参数, +用于接收匹配成功的时间片。按照 [RFC 6238 第 5.2 节](https://tools.ietf.org/html/rfc6238#section-5.2) +的要求,请为每个用户持久化最后一次接受的时间片,并拒绝任何匹配时间片小于或等于该存储值的验证码: + +```php +$matchedTimeSlice = null; +if ($ga->verifyCode($secret, $oneCode, 2, $matchedTimeSlice)) { + if ($matchedTimeSlice <= $user->lastUsedTimeSlice) { + // 验证码已被使用:拒绝以防止重放攻击 + } else { + $user->lastUsedTimeSlice = $matchedTimeSlice; // 持久化该值 + // 验证码通过 + } +} +``` + +### 限制验证频率 + +为防御暴力破解攻击,请限制验证尝试次数。例如,可以将单个 IP 地址(或 IPv6 网段) +的验证限制为 10 分钟内最多 10 次尝试 —— 请根据实际环境调整。 + +## 进阶用法 + +### 自定义验证码长度 + +验证码默认为 6 位,支持 6 到 8 位(RFC 4226): + +```php +$ga = (new GoogleAuthenticator())->setCodeLength(8); +``` + +### 构建 otpauth:// URI + +如需完全控制配置 URI(算法、位数、周期、HOTP 计数器等),可使用链式的 `UriBuilder`: + +```php +$uri = $ga->getUriBuilder() + ->issuer('Blog') + ->account('Admin') + ->secret($secret) + ->getUri(); // otpauth://totp/Blog:%20Admin?secret=...&issuer=Blog +``` + +## 运行测试 + +所有测试位于 `tests` 文件夹内。 + +```bash +composer install +composer test +``` -- 使用 [Composer](https://getcomposer.org/doc/01-basic-usage.md) 安装包 +## 许可证 -```composer require vectorface/googleauthenticator``` +基于 [BSD 许可证](./LICENSE.md)授权。 -运行测试: ----------- +原始项目: -- 所有测试都在 `tests` 文件夹内。 -- 执行 `composer install` 来准备您的环境。 -- 从项目根目录运行 `composer test`。 \ No newline at end of file +- 版权所有 (c) 2012-2016, [http://www.phpgangsta.de](http://www.phpgangsta.de) +- 作者:Michael Kliewe, [@PHPGangsta](http://twitter.com/PHPGangsta) 及[贡献者](https://github.com/PHPGangsta/GoogleAuthenticator/graphs/contributors) diff --git a/src/GoogleAuthenticator.php b/src/GoogleAuthenticator.php index 59b5eda..9b33243 100644 --- a/src/GoogleAuthenticator.php +++ b/src/GoogleAuthenticator.php @@ -5,6 +5,7 @@ use Endroid\QrCode\Builder\Builder; use Endroid\QrCode\Writer\PngWriter; use Exception; +use InvalidArgumentException; use Vectorface\OtpAuth\Base32; use Vectorface\OtpAuth\UriBuilder; @@ -138,12 +139,19 @@ protected function getQRCodeDataUri(string $uri) : string * Check if the code is correct. This will accept codes starting from $discrepancy*30sec ago to $discrepancy*30sec from now * * @param int $discrepancy This is the allowed time drift in 30 second units (8 means 4 minutes before or after) + * @param int|null $matchedTimeSlice Set to the time slice that matched on success; persist the last accepted + * slice and reject codes matching a slice <= that value to prevent replay + * @throws InvalidArgumentException if $discrepancy is out of the 0-60 range */ - public function verifyCode(string $secret, string $code, int $discrepancy = 1) : bool + public function verifyCode(string $secret, string $code, int $discrepancy = 1, ?int &$matchedTimeSlice = null) : bool { + if ($discrepancy < 0 || $discrepancy > 60) { + throw new InvalidArgumentException('Discrepancy must be between 0 and 60 time slices'); + } + $currentTimeSlice = floor(time() / 30); - if (strlen($code) != 6) { + if (strlen($code) !== $this->_codeLength || !ctype_digit($code)) { return false; } @@ -155,6 +163,7 @@ public function verifyCode(string $secret, string $code, int $discrepancy = 1) : } if (hash_equals($calculatedCode, $code)) { + $matchedTimeSlice = (int) ($currentTimeSlice + $i); return true; } } @@ -163,10 +172,15 @@ public function verifyCode(string $secret, string $code, int $discrepancy = 1) : } /** - * Set the code length, should be >=6 + * Set the code length, must be between 6 and 8 (RFC 4226) + * + * @throws InvalidArgumentException */ public function setCodeLength(int $length) : self { + if ($length < 6 || $length > 8) { + throw new InvalidArgumentException('Code length must be between 6 and 8'); + } $this->_codeLength = $length; return $this; } diff --git a/tests/GoogleAuthenticatorTest.php b/tests/GoogleAuthenticatorTest.php index f642650..177fc67 100644 --- a/tests/GoogleAuthenticatorTest.php +++ b/tests/GoogleAuthenticatorTest.php @@ -3,6 +3,7 @@ namespace Tests\Vectorface; use Exception; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; use Vectorface\GoogleAuthenticator; use Vectorface\OtpAuth\Parameters\Algorithm; @@ -163,6 +164,77 @@ public function testVerifyCodeWithLeadingZero() $this->assertEquals(false, $result); } + /** + * @throws Exception + */ + public function testVerifyCodeWithEightDigits() + { + $secret = 'SECRET'; + $ga = $this->googleAuthenticator->setCodeLength(8); + + $code = $ga->getCode($secret); + $this->assertEquals(8, strlen($code)); + $this->assertTrue($ga->verifyCode($secret, $code)); + + // A 6-digit code must not verify when 8 digits are configured + $this->assertFalse($ga->verifyCode($secret, substr($code, 0, 6))); + } + + /** + * @throws Exception + */ + public function testVerifyCodeRejectsNonNumericCode() + { + $secret = 'SECRET'; + + $this->assertFalse($this->googleAuthenticator->verifyCode($secret, 'abcdef')); + $this->assertFalse($this->googleAuthenticator->verifyCode($secret, '12345x')); + $this->assertFalse($this->googleAuthenticator->verifyCode($secret, "12345\n")); + } + + /** + * @throws Exception + */ + public function testVerifyCodeReportsMatchedTimeSlice() + { + $secret = 'SECRET'; + $currentTimeSlice = (int) floor(time() / 30); + + // A code from the previous time slice should match at $currentTimeSlice - 1 + $code = $this->googleAuthenticator->getCode($secret, $currentTimeSlice - 1); + $matchedTimeSlice = null; + $result = $this->googleAuthenticator->verifyCode($secret, $code, 1, $matchedTimeSlice); + + $this->assertTrue($result); + $this->assertSame($currentTimeSlice - 1, $matchedTimeSlice); + + // On failure, the matched time slice must remain untouched + $matchedTimeSlice = null; + $this->assertFalse($this->googleAuthenticator->verifyCode($secret, '000000', 0, $matchedTimeSlice)); + $this->assertNull($matchedTimeSlice); + } + + public function invalidDiscrepancyProvider() + { + return [ + 'Negative' => [-1], + 'Too large' => [61], + ]; + } + + /** + * @dataProvider invalidDiscrepancyProvider + * @param int $discrepancy + * @throws Exception + */ + public function testVerifyCodeRejectsOutOfRangeDiscrepancy(int $discrepancy) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Discrepancy must be between 0 and 60 time slices'); + + $this->googleAuthenticator->verifyCode('SECRET', '123456', $discrepancy); + } + public function testSetCodeLength() { $result = $this->googleAuthenticator->setCodeLength(6); @@ -170,6 +242,28 @@ public function testSetCodeLength() $this->assertInstanceOf(GoogleAuthenticator::class, $result); } + public function invalidCodeLengthProvider() + { + return [ + 'Too short' => [5], + 'Too long' => [9], + 'Zero' => [0], + 'Negative' => [-1], + ]; + } + + /** + * @dataProvider invalidCodeLengthProvider + * @param int $length + */ + public function testSetCodeLengthRejectsOutOfRangeValues(int $length) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Code length must be between 6 and 8'); + + $this->googleAuthenticator->setCodeLength($length); + } + public function badSecretProvider() { return [