├── CHANGELOG.md ├── LICENSE ├── README.md ├── composer.json └── lib ├── Algorithm.php ├── Providers ├── Qr │ ├── BaconQrCodeProvider.php │ ├── BaseHTTPQRCodeProvider.php │ ├── EndroidQrCodeProvider.php │ ├── EndroidQrCodeWithLogoProvider.php │ ├── GoogleChartsQrCodeProvider.php │ ├── HandlesDataUri.php │ ├── IQRCodeProvider.php │ ├── ImageChartsQRCodeProvider.php │ ├── QRException.php │ ├── QRServerProvider.php │ └── QRicketProvider.php ├── Rng │ ├── CSRNGProvider.php │ ├── IRNGProvider.php │ └── RNGException.php └── Time │ ├── HttpTimeProvider.php │ ├── ITimeProvider.php │ ├── LocalMachineTimeProvider.php │ ├── NTPTimeProvider.php │ └── TimeException.php ├── TwoFactorAuth.php └── TwoFactorAuthException.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # RobThree\TwoFactorAuth changelog 2 | 3 | # Version 3.x 4 | 5 | ## Breaking changes 6 | 7 | ### PHP Version 8 | 9 | Version 3.x requires at least PHP 8.2. 10 | 11 | ### Constructor signature change 12 | 13 | In order to ensure users of this library make a conscious choice of QR Code Provider, the QR Code Provider is now a mandatory argument, in first place. 14 | 15 | If you didn't provide one explicitly before, you can get the old behavior with: 16 | 17 | ~~~php 18 | use RobThree\Auth\TwoFactorAuth; 19 | use RobThree\Auth\Providers\Qr\QRServerProvider; 20 | $tfa = new TwoFactorAuth(new QRServerProvider()); 21 | ~~~ 22 | 23 | If you provided one before, the order of the parameters have been changed, so simply move the QRCodeProvider argument to the first place or use named arguments. 24 | 25 | Documentation on selecting a QR Code Provider is available here: [QR Code Provider documentation](https://robthree.github.io/TwoFactorAuth/qr-codes.html). 26 | 27 | ### Default secret length 28 | 29 | The default secret length has been increased from 80 bits to 160 bits (RFC4226) PR [#117](https://github.com/RobThree/TwoFactorAuth/pull/117). This might cause an issue in your application if you were previously storing secrets in a column with restricted size. This change doesn't impact existing secrets, only new ones will get longer. 30 | 31 | Previously a secret was 16 characters, now it needs to be stored in a 32 characters width column. 32 | 33 | You can keep the old behavior by setting `80` as argument to `createSecret()` (not recommended, see [#117](https://github.com/RobThree/TwoFactorAuth/pull/117) for further discussion). 34 | 35 | ## Other changes 36 | 37 | * The new PHP attribute [SensitiveParameter](https://www.php.net/manual/en/class.sensitiveparameter.php) was added to the code, to prevent accidental leak of secrets in stack traces. 38 | * Likely not breaking anything, but now all external QR Code providers use HTTPS with a verified certificate. PR [#126](https://github.com/RobThree/TwoFactorAuth/pull/126). 39 | * The CSPRNG is now exclusively using `random_bytes()` PHP function. Previously a fallback to `openssl` or non cryptographically secure PRNG existed, they have been removed. PR [#122](https://github.com/RobThree/TwoFactorAuth/pull/122). 40 | * If an external QR code provider is used and the HTTP request results in an error, it will throw a `QRException`. Previously the error was ignored. PR [#130](https://github.com/RobThree/TwoFactorAuth/pull/130), fixes [#129](https://github.com/RobThree/TwoFactorAuth/issues/129). 41 | 42 | # Version 2.x 43 | 44 | ## Breaking changes 45 | 46 | ### PHP Version 47 | 48 | Version 2.x requires at least PHP 8.1. 49 | 50 | ### Constructor signature 51 | 52 | With version 2.x, the `algorithm` parameter of `RobThree\Auth\TwoFactorAuth` constructor is now an `enum`. 53 | 54 | On version 1.x: 55 | 56 | ~~~php 57 | use RobThree\Auth\TwoFactorAuth; 58 | 59 | $lib = new TwoFactorAuth('issuer-name', 6, 30, 'sha1'); 60 | ~~~ 61 | 62 | On version 2.x, simple change the algorithm from a `string` to the correct `enum`: 63 | 64 | ~~~php 65 | use RobThree\Auth\TwoFactorAuth; 66 | use RobThree\Auth\Algorithm; 67 | 68 | $lib = new TwoFactorAuth('issuer-name', 6, 30, Algorithm::Sha1); 69 | ~~~ 70 | 71 | See the [Algorithm.php](./lib/Algorithm.php) file to see available algorithms. 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2021 Rob Janssen and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ![Logo](https://raw.githubusercontent.com/RobThree/TwoFactorAuth/master/logo.png) PHP library for Two Factor Authentication 2 | 3 | [![Build status](https://img.shields.io/github/actions/workflow/status/robthree/twofactorauth/test.yml?branch=master)](https://github.com/RobThree/TwoFactorAuth/actions?query=branch%3Amaster) [![Latest Stable Version](https://img.shields.io/packagist/v/robthree/twofactorauth.svg?style=flat-square)](https://packagist.org/packages/robthree/twofactorauth) [![License](https://img.shields.io/packagist/l/robthree/twofactorauth.svg?style=flat-square)](LICENSE) [![Downloads](https://img.shields.io/packagist/dt/robthree/twofactorauth.svg?style=flat-square)](https://packagist.org/packages/robthree/twofactorauth) [![PayPal donate button](http://img.shields.io/badge/paypal-donate-orange.svg?style=flat-square)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=6MB5M2SQLP636 "Keep me off the streets") 4 | 5 | PHP library for [two-factor (or multi-factor) authentication](http://en.wikipedia.org/wiki/Multi-factor_authentication) using [TOTP](http://en.wikipedia.org/wiki/Time-based_One-time_Password_Algorithm) and [QR-codes](http://en.wikipedia.org/wiki/QR_code). Inspired by, based on but most importantly an *improvement* on '[PHPGangsta/GoogleAuthenticator](https://github.com/PHPGangsta/GoogleAuthenticator)'. There's a [.Net implementation](https://github.com/RobThree/TwoFactorAuth.Net) of this library as well. 6 | 7 |

8 | 9 |

10 | 11 | ## Requirements 12 | 13 | * Requires PHP version >=8.2 14 | 15 | Optionally, you may need: 16 | 17 | * [sockets](https://www.php.net/manual/en/book.sockets.php) if you are using `NTPTimeProvider` 18 | * [endroid/qr-code](https://github.com/endroid/qr-code) if using `EndroidQrCodeProvider` or `EndroidQrCodeWithLogoProvider`. 19 | * [bacon/bacon-qr-code](https://github.com/Bacon/BaconQrCode) if using `BaconQrCodeProvider`. 20 | * [php-curl library](http://php.net/manual/en/book.curl.php) when using an external QR Code provider such as `QRServerProvider`, `ImageChartsQRCodeProvider`, `QRicketProvider` or any other custom provider connecting to an external service. 21 | 22 | ## Installation 23 | 24 | The best way of installing this library is with composer: 25 | 26 | `php composer.phar require robthree/twofactorauth` 27 | 28 | ## Usage 29 | 30 | For a quick start, have a look at the [getting started](https://robthree.github.io/TwoFactorAuth/getting-started.html) page or try out the [demo](demo/demo.php). 31 | 32 | If you need more in-depth information about the configuration available then you can read through the rest of [documentation](https://robthree.github.io/TwoFactorAuth). 33 | 34 | ## Integrations 35 | 36 | - [CakePHP plugin](https://github.com/andrej-griniuk/cakephp-two-factor-auth) 37 | - [CI4-Auth: a user, group, role and permission management library for Codeigniter 4](https://github.com/glewe/ci4-auth) 38 | 39 | ## License 40 | 41 | Licensed under MIT license. See [LICENSE](./LICENSE) for details. 42 | 43 | [Logo / icon](http://www.iconmay.com/Simple/Travel_and_Tourism_Part_2/luggage_lock_safety_baggage_keys_cylinder_lock_hotel_travel_tourism_luggage_lock_icon_465) under CC0 1.0 Universal (CC0 1.0) Public Domain Dedication ([Archived page](http://riii.nl/tm7ap)) 44 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "robthree/twofactorauth", 3 | "description": "Two Factor Authentication", 4 | "type": "library", 5 | "keywords": [ "Authentication", "Two Factor Authentication", "Multi Factor Authentication", "TFA", "MFA", "PHP", "Authenticator", "Authy" ], 6 | "homepage": "https://github.com/RobThree/TwoFactorAuth", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Rob Janssen", 11 | "homepage": "http://robiii.me", 12 | "role": "Developer" 13 | }, 14 | { 15 | "name": "Nicolas CARPi", 16 | "homepage": "https://github.com/NicolasCARPi", 17 | "role": "Developer" 18 | }, 19 | { 20 | "name": "Will Power", 21 | "homepage": "https://github.com/willpower232", 22 | "role": "Developer" 23 | } 24 | ], 25 | "support": { 26 | "issues": "https://github.com/RobThree/TwoFactorAuth/issues", 27 | "source": "https://github.com/RobThree/TwoFactorAuth" 28 | }, 29 | "require": { 30 | "php": ">=8.2.0" 31 | }, 32 | "require-dev": { 33 | "phpunit/phpunit": "^9", 34 | "friendsofphp/php-cs-fixer": "^3.13", 35 | "phpstan/phpstan": "^1.9" 36 | }, 37 | "suggest": { 38 | "bacon/bacon-qr-code": "Needed for BaconQrCodeProvider provider", 39 | "endroid/qr-code": "Needed for EndroidQrCodeProvider" 40 | }, 41 | "autoload": { 42 | "psr-4": { 43 | "RobThree\\Auth\\": "lib" 44 | } 45 | }, 46 | "autoload-dev": { 47 | "psr-4": { 48 | "Tests\\": "tests/" 49 | } 50 | }, 51 | "scripts": { 52 | "phpstan": [ 53 | "phpstan analyze --xdebug lib tests testsDependency" 54 | ], 55 | "lint": [ 56 | "php-cs-fixer fix -v" 57 | ], 58 | "lint-ci": [ 59 | "PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix -v --dry-run --stop-on-violation" 60 | ], 61 | "test": [ 62 | "XDEBUG_MODE=coverage phpunit" 63 | ] 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/Algorithm.php: -------------------------------------------------------------------------------- 1 | backgroundColour = $this->handleColour($this->backgroundColour); 31 | $this->foregroundColour = $this->handleColour($this->foregroundColour); 32 | $this->format = strtolower($this->format); 33 | } 34 | 35 | public function getMimeType(): string 36 | { 37 | switch ($this->format) { 38 | case 'png': 39 | return 'image/png'; 40 | case 'gif': 41 | return 'image/gif'; 42 | case 'jpg': 43 | case 'jpeg': 44 | return 'image/jpeg'; 45 | case 'svg': 46 | return 'image/svg+xml'; 47 | case 'eps': 48 | return 'application/postscript'; 49 | } 50 | 51 | throw new RuntimeException(sprintf('Unknown MIME-type: %s', $this->format)); 52 | } 53 | 54 | public function getQRCodeImage(string $qrText, int $size): string 55 | { 56 | $backend = match ($this->format) { 57 | 'svg' => new SvgImageBackEnd(), 58 | 'eps' => new EpsImageBackEnd(), 59 | default => new ImagickImageBackEnd($this->format), 60 | }; 61 | 62 | $output = $this->getQRCodeByBackend($qrText, $size, $backend); 63 | 64 | if ($this->format === 'svg') { 65 | $svg = explode("\n", $output); 66 | return $svg[1]; 67 | } 68 | 69 | return $output; 70 | } 71 | 72 | /** 73 | * Abstract QR code generation function 74 | * providing colour changing support 75 | */ 76 | private function getQRCodeByBackend($qrText, $size, ImageBackEndInterface $backend) 77 | { 78 | $rendererStyleArgs = array($size, $this->borderWidth); 79 | 80 | if (is_array($this->foregroundColour) && is_array($this->backgroundColour)) { 81 | $rendererStyleArgs = array(...$rendererStyleArgs, ...array( 82 | null, 83 | null, 84 | Fill::withForegroundColor( 85 | new Rgb(...$this->backgroundColour), 86 | new Rgb(...$this->foregroundColour), 87 | new EyeFill(null, null), 88 | new EyeFill(null, null), 89 | new EyeFill(null, null) 90 | ), 91 | )); 92 | } 93 | 94 | $writer = new Writer(new ImageRenderer( 95 | new RendererStyle(...$rendererStyleArgs), 96 | $backend 97 | )); 98 | 99 | return $writer->writeString($qrText); 100 | } 101 | 102 | /** 103 | * Ensure colour is an array of three values but also 104 | * accept a string and assume its a 3 or 6 character hex 105 | */ 106 | private function handleColour(array|string $colour): array|string 107 | { 108 | if (is_string($colour) && $colour[0] == '#') { 109 | $hexToRGB = static function ($input) { 110 | // ensure input no longer has a # for more predictable division 111 | // PHP 8.1 does not like implicitly casting a float to an int 112 | $input = trim($input, '#'); 113 | 114 | if (strlen($input) != 3 && strlen($input) != 6) { 115 | throw new RuntimeException('Colour should be a 3 or 6 character value after the #'); 116 | } 117 | 118 | // split the array into three chunks 119 | $split = str_split($input, strlen($input) / 3); 120 | 121 | // cope with three character hex reference 122 | if (strlen($input) == 3) { 123 | array_walk($split, static function (&$character) { 124 | $character = str_repeat($character, 2); 125 | }); 126 | } 127 | 128 | // convert hex to rgb 129 | return array_map('hexdec', $split); 130 | }; 131 | 132 | return $hexToRGB($colour); 133 | } 134 | 135 | if (is_array($colour) && count($colour) == 3) { 136 | return $colour; 137 | } 138 | 139 | throw new RuntimeException('Invalid colour value'); 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /lib/Providers/Qr/BaseHTTPQRCodeProvider.php: -------------------------------------------------------------------------------- 1 | $url, 17 | CURLOPT_RETURNTRANSFER => true, 18 | CURLOPT_CONNECTTIMEOUT => 10, 19 | CURLOPT_DNS_CACHE_TIMEOUT => 10, 20 | CURLOPT_TIMEOUT => 10, 21 | CURLOPT_SSL_VERIFYPEER => $this->verifyssl, 22 | CURLOPT_USERAGENT => 'TwoFactorAuth', 23 | )); 24 | $data = curl_exec($curlhandle); 25 | if ($data === false) { 26 | throw new QRException(curl_error($curlhandle)); 27 | } 28 | 29 | curl_close($curlhandle); 30 | return $data; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /lib/Providers/Qr/EndroidQrCodeProvider.php: -------------------------------------------------------------------------------- 1 | endroid5 = enum_exists(ErrorCorrectionLevel::class); 36 | $this->endroid6 = $this->endroid5 && !method_exists(QrCode::class, 'setSize'); 37 | $this->endroid4 = $this->endroid6 || method_exists(QrCode::class, 'create'); 38 | 39 | $this->bgcolor = $this->handleColor($bgcolor); 40 | $this->color = $this->handleColor($color); 41 | $this->margin = $margin; 42 | $this->errorcorrectionlevel = $this->handleErrorCorrectionLevel($errorcorrectionlevel); 43 | } 44 | 45 | public function getMimeType(): string 46 | { 47 | return 'image/png'; 48 | } 49 | 50 | public function getQRCodeImage(string $qrText, int $size): string 51 | { 52 | if (!$this->endroid4) { 53 | return $this->qrCodeInstance($qrText, $size)->writeString(); 54 | } 55 | 56 | $writer = new PngWriter(); 57 | return $writer->write($this->qrCodeInstance($qrText, $size))->getString(); 58 | } 59 | 60 | protected function qrCodeInstance(string $qrText, int $size): QrCode 61 | { 62 | if ($this->endroid6) { 63 | return new QrCode( 64 | data: $qrText, 65 | errorCorrectionLevel: $this->errorcorrectionlevel, 66 | size: $size, 67 | margin: $this->margin, 68 | foregroundColor: $this->color, 69 | backgroundColor: $this->bgcolor 70 | ); 71 | } 72 | 73 | $qrCode = new QrCode($qrText); 74 | $qrCode->setSize($size); 75 | 76 | $qrCode->setErrorCorrectionLevel($this->errorcorrectionlevel); 77 | $qrCode->setMargin($this->margin); 78 | $qrCode->setBackgroundColor($this->bgcolor); 79 | $qrCode->setForegroundColor($this->color); 80 | return $qrCode; 81 | } 82 | 83 | private function handleColor(string $color): Color|array 84 | { 85 | $split = str_split($color, 2); 86 | $r = hexdec($split[0]); 87 | $g = hexdec($split[1]); 88 | $b = hexdec($split[2]); 89 | 90 | return $this->endroid4 ? new Color($r, $g, $b, 0) : array('r' => $r, 'g' => $g, 'b' => $b, 'a' => 0); 91 | } 92 | 93 | private function handleErrorCorrectionLevel(string $level): ErrorCorrectionLevelInterface|ErrorCorrectionLevel 94 | { 95 | // First check for version 5 (using enums) 96 | if ($this->endroid5) { 97 | return match ($level) { 98 | 'L' => ErrorCorrectionLevel::Low, 99 | 'M' => ErrorCorrectionLevel::Medium, 100 | 'Q' => ErrorCorrectionLevel::Quartile, 101 | default => ErrorCorrectionLevel::High, 102 | }; 103 | } 104 | 105 | // If not check for version 4 (using classes) 106 | if ($this->endroid4) { 107 | return match ($level) { 108 | 'L' => new ErrorCorrectionLevelLow(), 109 | 'M' => new ErrorCorrectionLevelMedium(), 110 | 'Q' => new ErrorCorrectionLevelQuartile(), 111 | default => new ErrorCorrectionLevelHigh(), 112 | }; 113 | } 114 | 115 | // Any other version will be using strings 116 | return match ($level) { 117 | 'L' => ErrorCorrectionLevel::LOW(), 118 | 'M' => ErrorCorrectionLevel::MEDIUM(), 119 | 'Q' => ErrorCorrectionLevel::QUARTILE(), 120 | default => ErrorCorrectionLevel::HIGH(), 121 | }; 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /lib/Providers/Qr/EndroidQrCodeWithLogoProvider.php: -------------------------------------------------------------------------------- 1 | logoPath = $path; 25 | $this->logoSize = (array)$size; 26 | } 27 | 28 | public function getQRCodeImage(string $qrText, int $size): string 29 | { 30 | if (!$this->endroid4) { 31 | return $this->qrCodeInstance($qrText, $size)->writeString(); 32 | } 33 | 34 | $logo = null; 35 | if ($this->logoPath) { 36 | if ($this->endroid6) { 37 | $logo = new Logo($this->logoPath, ...$this->logoSize); 38 | } else { 39 | $logo = Logo::create($this->logoPath); 40 | if ($this->logoSize) { 41 | $logo->setResizeToWidth($this->logoSize[0]); 42 | if (isset($this->logoSize[1])) { 43 | $logo->setResizeToHeight($this->logoSize[1]); 44 | } 45 | } 46 | } 47 | } 48 | $writer = new PngWriter(); 49 | return $writer->write($this->qrCodeInstance($qrText, $size), $logo)->getString(); 50 | } 51 | 52 | protected function qrCodeInstance(string $qrText, int $size): QrCode 53 | { 54 | $qrCode = parent::qrCodeInstance($qrText, $size); 55 | 56 | if (!$this->endroid4 && $this->logoPath) { 57 | $qrCode->setLogoPath($this->logoPath); 58 | if ($this->logoSize) { 59 | $qrCode->setLogoSize($this->logoSize[0], $this->logoSize[1] ?? null); 60 | } 61 | } 62 | 63 | return $qrCode; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/Providers/Qr/GoogleChartsQrCodeProvider.php: -------------------------------------------------------------------------------- 1 | getContent($this->getUrl($qrText, $size)); 22 | } 23 | 24 | public function getUrl(string $qrText, int $size): string 25 | { 26 | $queryParameters = array( 27 | 'chs' => $size . 'x' . $size, 28 | 'chld' => strtoupper($this->errorcorrectionlevel) . '|' . $this->margin, 29 | 'cht' => 'qr', 30 | 'choe' => $this->encoding, 31 | 'chl' => $qrText, 32 | ); 33 | 34 | return 'https://chart.googleapis.com/chart?' . http_build_query($queryParameters); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /lib/Providers/Qr/HandlesDataUri.php: -------------------------------------------------------------------------------- 1 | |null 14 | */ 15 | private function DecodeDataUri(string $datauri): ?array 16 | { 17 | if (preg_match('/data:(?P[\w\.\-\+\/]+);(?P\w+),(?P.*)/', $datauri, $m) === 1) { 18 | return array( 19 | 'mimetype' => $m['mimetype'], 20 | 'encoding' => $m['encoding'], 21 | 'data' => base64_decode($m['data'], true), 22 | ); 23 | } 24 | 25 | return null; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /lib/Providers/Qr/IQRCodeProvider.php: -------------------------------------------------------------------------------- 1 | getContent($this->getUrl($qrText, $size)); 24 | } 25 | 26 | public function getUrl(string $qrText, int $size): string 27 | { 28 | $queryParameters = array( 29 | 'cht' => 'qr', 30 | 'chs' => ceil($size / 2) . 'x' . ceil($size / 2), 31 | 'chld' => $this->errorcorrectionlevel . '|' . $this->margin, 32 | 'chl' => $qrText, 33 | ); 34 | 35 | return 'https://image-charts.com/chart?' . http_build_query($queryParameters); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /lib/Providers/Qr/QRException.php: -------------------------------------------------------------------------------- 1 | format)) { 19 | case 'png': 20 | return 'image/png'; 21 | case 'gif': 22 | return 'image/gif'; 23 | case 'jpg': 24 | case 'jpeg': 25 | return 'image/jpeg'; 26 | case 'svg': 27 | return 'image/svg+xml'; 28 | case 'eps': 29 | return 'application/postscript'; 30 | } 31 | throw new QRException(sprintf('Unknown MIME-type: %s', $this->format)); 32 | } 33 | 34 | public function getQRCodeImage(string $qrText, int $size): string 35 | { 36 | return $this->getContent($this->getUrl($qrText, $size)); 37 | } 38 | 39 | public function getUrl(string $qrText, int $size): string 40 | { 41 | $queryParameters = array( 42 | 'size' => $size . 'x' . $size, 43 | 'ecc' => strtoupper($this->errorcorrectionlevel), 44 | 'margin' => $this->margin, 45 | 'qzone' => $this->qzone, 46 | 'bgcolor' => $this->decodeColor($this->bgcolor), 47 | 'color' => $this->decodeColor($this->color), 48 | 'format' => strtolower($this->format), 49 | 'data' => $qrText, 50 | ); 51 | 52 | return 'https://api.qrserver.com/v1/create-qr-code/?' . http_build_query($queryParameters); 53 | } 54 | 55 | private function decodeColor(string $value): string 56 | { 57 | return vsprintf('%d-%d-%d', sscanf($value, '%02x%02x%02x')); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /lib/Providers/Qr/QRicketProvider.php: -------------------------------------------------------------------------------- 1 | format)) { 19 | case 'p': 20 | return 'image/png'; 21 | case 'g': 22 | return 'image/gif'; 23 | case 'j': 24 | return 'image/jpeg'; 25 | } 26 | throw new QRException(sprintf('Unknown MIME-type: %s', $this->format)); 27 | } 28 | 29 | public function getQRCodeImage(string $qrText, int $size): string 30 | { 31 | return $this->getContent($this->getUrl($qrText, $size)); 32 | } 33 | 34 | public function getUrl(string $qrText, int $size): string 35 | { 36 | $queryParameters = array( 37 | 'qrsize' => $size, 38 | 'e' => strtolower($this->errorcorrectionlevel), 39 | 'bgdcolor' => $this->bgcolor, 40 | 'fgdcolor' => $this->color, 41 | 't' => strtolower($this->format), 42 | 'd' => $qrText, 43 | ); 44 | 45 | return 'https://qrickit.com/api/qr?' . http_build_query($queryParameters); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /lib/Providers/Rng/CSRNGProvider.php: -------------------------------------------------------------------------------- 1 | $options 17 | */ 18 | public function __construct( 19 | public string $url = 'https://google.com', 20 | public string $expectedtimeformat = 'D, d M Y H:i:s O+', 21 | public ?array $options = null, 22 | ) { 23 | if ($this->options === null) { 24 | $this->options = array( 25 | 'http' => array( 26 | 'method' => 'HEAD', 27 | 'follow_location' => false, 28 | 'ignore_errors' => true, 29 | 'max_redirects' => 0, 30 | 'request_fulluri' => true, 31 | 'header' => array( 32 | 'Connection: close', 33 | 'User-agent: TwoFactorAuth HttpTimeProvider (https://github.com/RobThree/TwoFactorAuth)', 34 | 'Cache-Control: no-cache', 35 | ), 36 | ), 37 | ); 38 | } 39 | } 40 | 41 | /** 42 | * {@inheritdoc} 43 | */ 44 | public function getTime() 45 | { 46 | try { 47 | $context = stream_context_create($this->options); 48 | $fd = fopen($this->url, 'rb', false, $context); 49 | $headers = stream_get_meta_data($fd); 50 | fclose($fd); 51 | 52 | foreach ($headers['wrapper_data'] as $h) { 53 | if (strcasecmp(substr($h, 0, 5), 'Date:') === 0) { 54 | return DateTime::createFromFormat($this->expectedtimeformat, trim(substr($h, 5)))->getTimestamp(); 55 | } 56 | } 57 | throw new Exception('Invalid or no "Date:" header found'); 58 | } catch (Exception $ex) { 59 | throw new TimeException(sprintf('Unable to retrieve time from %s (%s)', $this->url, $ex->getMessage())); 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /lib/Providers/Time/ITimeProvider.php: -------------------------------------------------------------------------------- 1 | port <= 0 || $this->port > 65535) { 19 | throw new TimeException('Port must be 0 < port < 65535'); 20 | } 21 | 22 | if ($this->timeout < 0) { 23 | throw new TimeException('Timeout must be >= 0'); 24 | } 25 | } 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | public function getTime() 31 | { 32 | try { 33 | // Create a socket and connect to NTP server 34 | $sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP); 35 | socket_set_option($sock, SOL_SOCKET, SO_RCVTIMEO, array('sec' => $this->timeout, 'usec' => 0)); 36 | socket_connect($sock, $this->host, $this->port); 37 | 38 | // Send request 39 | $msg = "\010" . str_repeat("\0", 47); 40 | socket_send($sock, $msg, strlen($msg), 0); 41 | 42 | // Receive response and close socket 43 | if (socket_recv($sock, $recv, 48, MSG_WAITALL) === false) { 44 | throw new Exception(socket_strerror(socket_last_error($sock))); 45 | } 46 | socket_close($sock); 47 | 48 | // Interpret response 49 | $data = unpack('N12', $recv); 50 | $timestamp = (int)sprintf('%u', $data[9]); 51 | 52 | // NTP is number of seconds since 0000 UT on 1 January 1900 Unix time is seconds since 0000 UT on 1 January 1970 53 | return $timestamp - 2208988800; 54 | } catch (Exception $ex) { 55 | throw new TimeException(sprintf('Unable to retrieve time from %s (%s)', $this->host, $ex->getMessage())); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /lib/Providers/Time/TimeException.php: -------------------------------------------------------------------------------- 1 | */ 25 | private static array $_base32; 26 | 27 | /** @var array */ 28 | private static array $_base32lookup = array(); 29 | 30 | public function __construct( 31 | private IQRCodeProvider $qrcodeprovider, 32 | private readonly ?string $issuer = null, 33 | private readonly int $digits = 6, 34 | private readonly int $period = 30, 35 | private readonly Algorithm $algorithm = Algorithm::Sha1, 36 | private ?IRNGProvider $rngprovider = null, 37 | private ?ITimeProvider $timeprovider = null 38 | ) { 39 | if ($this->digits <= 0) { 40 | throw new TwoFactorAuthException('Digits must be > 0'); 41 | } 42 | 43 | if ($this->period <= 0) { 44 | throw new TwoFactorAuthException('Period must be int > 0'); 45 | } 46 | 47 | self::$_base32 = str_split(self::$_base32dict); 48 | self::$_base32lookup = array_flip(self::$_base32); 49 | } 50 | 51 | /** 52 | * Create a new secret 53 | */ 54 | public function createSecret(int $bits = 160): string 55 | { 56 | $secret = ''; 57 | $bytes = (int)ceil($bits / 5); // We use 5 bits of each byte (since we have a 32-character 'alphabet' / BASE32) 58 | $rngprovider = $this->getRngProvider(); 59 | $rnd = $rngprovider->getRandomBytes($bytes); 60 | for ($i = 0; $i < $bytes; $i++) { 61 | $secret .= self::$_base32[ord($rnd[$i]) & 31]; //Mask out left 3 bits for 0-31 values 62 | } 63 | return $secret; 64 | } 65 | 66 | /** 67 | * Calculate the code with given secret and point in time 68 | */ 69 | public function getCode(#[SensitiveParameter] string $secret, ?int $time = null): string 70 | { 71 | $secretkey = $this->base32Decode($secret); 72 | 73 | $timestamp = "\0\0\0\0" . pack('N*', $this->getTimeSlice($this->getTime($time))); // Pack time into binary string 74 | $hashhmac = hash_hmac($this->algorithm->value, $timestamp, $secretkey, true); // Hash it with users secret key 75 | $hashpart = substr($hashhmac, ord(substr($hashhmac, -1)) & 0x0F, 4); // Use last nibble of result as index/offset and grab 4 bytes of the result 76 | $value = unpack('N', $hashpart); // Unpack binary value 77 | $value = $value[1] & 0x7FFFFFFF; // Drop MSB, keep only 31 bits 78 | 79 | return str_pad((string)($value % 10 ** $this->digits), $this->digits, '0', STR_PAD_LEFT); 80 | } 81 | 82 | /** 83 | * Check if the code is correct. This will accept codes starting from ($discrepancy * $period) sec ago to ($discrepancy * period) sec from now 84 | */ 85 | public function verifyCode(string $secret, string $code, int $discrepancy = 1, ?int $time = null, ?int &$timeslice = 0): bool 86 | { 87 | $timestamp = $this->getTime($time); 88 | 89 | $timeslice = 0; 90 | 91 | // To keep safe from timing-attacks we iterate *all* possible codes even though we already may have 92 | // verified a code is correct. We use the timeslice variable to hold either 0 (no match) or the timeslice 93 | // of the match. Each iteration we either set the timeslice variable to the timeslice of the match 94 | // or set the value to itself. This is an effort to maintain constant execution time for the code. 95 | for ($i = -$discrepancy; $i <= $discrepancy; $i++) { 96 | $ts = $timestamp + ($i * $this->period); 97 | $slice = $this->getTimeSlice($ts); 98 | $timeslice = hash_equals($this->getCode($secret, $ts), $code) ? $slice : $timeslice; 99 | } 100 | 101 | return $timeslice > 0; 102 | } 103 | 104 | /** 105 | * Get data-uri of QRCode 106 | */ 107 | public function getQRCodeImageAsDataUri(string $label, #[SensitiveParameter] string $secret, int $size = 200): string 108 | { 109 | if ($size <= 0) { 110 | throw new TwoFactorAuthException('Size must be > 0'); 111 | } 112 | 113 | return 'data:' 114 | . $this->qrcodeprovider->getMimeType() 115 | . ';base64,' 116 | . base64_encode($this->qrcodeprovider->getQRCodeImage($this->getQRText($label, $secret), $size)); 117 | } 118 | 119 | /** 120 | * Compare default timeprovider with specified timeproviders and ensure the time is within the specified number of seconds (leniency) 121 | * @param array $timeproviders 122 | * @throws TwoFactorAuthException 123 | */ 124 | public function ensureCorrectTime(?array $timeproviders = null, int $leniency = 5): void 125 | { 126 | if ($timeproviders === null) { 127 | $timeproviders = array( 128 | new NTPTimeProvider(), 129 | new HttpTimeProvider(), 130 | ); 131 | } 132 | 133 | // Get default time provider 134 | $timeprovider = $this->getTimeProvider(); 135 | 136 | // Iterate specified time providers 137 | foreach ($timeproviders as $t) { 138 | if (!($t instanceof ITimeProvider)) { 139 | throw new TwoFactorAuthException('Object does not implement ITimeProvider'); 140 | } 141 | 142 | // Get time from default time provider and compare to specific time provider and throw if time difference is more than specified number of seconds leniency 143 | if (abs($timeprovider->getTime() - $t->getTime()) > $leniency) { 144 | throw new TwoFactorAuthException(sprintf('Time for timeprovider is off by more than %d seconds when compared to %s', $leniency, get_class($t))); 145 | } 146 | } 147 | } 148 | 149 | /** 150 | * Builds a string to be encoded in a QR code 151 | */ 152 | public function getQRText(string $label, #[SensitiveParameter] string $secret): string 153 | { 154 | return 'otpauth://totp/' . rawurlencode($label) 155 | . '?secret=' . rawurlencode($secret) 156 | . '&issuer=' . rawurlencode((string)$this->issuer) 157 | . '&period=' . $this->period 158 | . '&algorithm=' . rawurlencode(strtoupper($this->algorithm->value)) 159 | . '&digits=' . $this->digits; 160 | } 161 | 162 | /** 163 | * @throws TwoFactorAuthException 164 | */ 165 | public function getRngProvider(): IRNGProvider 166 | { 167 | return $this->rngprovider ??= new CSRNGProvider(); 168 | } 169 | 170 | public function getTimeProvider(): ITimeProvider 171 | { 172 | // Set default time provider if none was specified 173 | return $this->timeprovider ??= new LocalMachineTimeProvider(); 174 | } 175 | 176 | private function getTime(?int $time = null): int 177 | { 178 | return $time ?? $this->getTimeProvider()->getTime(); 179 | } 180 | 181 | private function getTimeSlice(?int $time = null, int $offset = 0): int 182 | { 183 | return (int)floor($time / $this->period) + ($offset * $this->period); 184 | } 185 | 186 | private function base32Decode(string $value): string 187 | { 188 | if ($value === '') { 189 | return ''; 190 | } 191 | 192 | if (preg_match('/[^' . preg_quote(self::$_base32dict, '/') . ']/', $value) !== 0) { 193 | throw new TwoFactorAuthException('Invalid base32 string'); 194 | } 195 | 196 | $buffer = ''; 197 | foreach (str_split($value) as $char) { 198 | if ($char !== '=') { 199 | $buffer .= str_pad(decbin(self::$_base32lookup[$char]), 5, '0', STR_PAD_LEFT); 200 | } 201 | } 202 | $length = strlen($buffer); 203 | $blocks = trim(chunk_split(substr($buffer, 0, $length - ($length % 8)), 8, ' ')); 204 | 205 | $output = ''; 206 | foreach (explode(' ', $blocks) as $block) { 207 | $output .= chr(bindec(str_pad($block, 8, '0', STR_PAD_RIGHT))); 208 | } 209 | return $output; 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /lib/TwoFactorAuthException.php: -------------------------------------------------------------------------------- 1 |