├── 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 | #  PHP library for Two Factor Authentication
2 |
3 | [](https://github.com/RobThree/TwoFactorAuth/actions?query=branch%3Amaster) [](https://packagist.org/packages/robthree/twofactorauth) [](LICENSE) [](https://packagist.org/packages/robthree/twofactorauth) [](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 |