feat: dist/vendor pridan do repa pro server deploy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 09:23:35 +01:00
parent b2a2937a35
commit d70620eb05
123 changed files with 17389 additions and 0 deletions

View File

@@ -0,0 +1,71 @@
# RobThree\TwoFactorAuth changelog
# Version 3.x
## Breaking changes
### PHP Version
Version 3.x requires at least PHP 8.2.
### Constructor signature change
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.
If you didn't provide one explicitly before, you can get the old behavior with:
~~~php
use RobThree\Auth\TwoFactorAuth;
use RobThree\Auth\Providers\Qr\QRServerProvider;
$tfa = new TwoFactorAuth(new QRServerProvider());
~~~
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.
Documentation on selecting a QR Code Provider is available here: [QR Code Provider documentation](https://robthree.github.io/TwoFactorAuth/qr-codes.html).
### Default secret length
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.
Previously a secret was 16 characters, now it needs to be stored in a 32 characters width column.
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).
## Other changes
* 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.
* 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).
* 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).
* 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).
# Version 2.x
## Breaking changes
### PHP Version
Version 2.x requires at least PHP 8.1.
### Constructor signature
With version 2.x, the `algorithm` parameter of `RobThree\Auth\TwoFactorAuth` constructor is now an `enum`.
On version 1.x:
~~~php
use RobThree\Auth\TwoFactorAuth;
$lib = new TwoFactorAuth('issuer-name', 6, 30, 'sha1');
~~~
On version 2.x, simple change the algorithm from a `string` to the correct `enum`:
~~~php
use RobThree\Auth\TwoFactorAuth;
use RobThree\Auth\Algorithm;
$lib = new TwoFactorAuth('issuer-name', 6, 30, Algorithm::Sha1);
~~~
See the [Algorithm.php](./lib/Algorithm.php) file to see available algorithms.

View File

@@ -0,0 +1,22 @@
The MIT License (MIT)
Copyright (c) 2014-2021 Rob Janssen and contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,43 @@
# ![Logo](https://raw.githubusercontent.com/RobThree/TwoFactorAuth/master/logo.png) PHP library for Two Factor Authentication
[![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")
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.
<p align="center">
<img src="https://raw.githubusercontent.com/RobThree/TwoFactorAuth/master/multifactorauthforeveryone.png">
</p>
## Requirements
* Requires PHP version >=8.2
Optionally, you may need:
* [sockets](https://www.php.net/manual/en/book.sockets.php) if you are using `NTPTimeProvider`
* [endroid/qr-code](https://github.com/endroid/qr-code) if using `EndroidQrCodeProvider` or `EndroidQrCodeWithLogoProvider`.
* [bacon/bacon-qr-code](https://github.com/Bacon/BaconQrCode) if using `BaconQrCodeProvider`.
* [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.
## Installation
The best way of installing this library is with composer:
`php composer.phar require robthree/twofactorauth`
## Usage
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).
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).
## Integrations
- [CakePHP plugin](https://github.com/andrej-griniuk/cakephp-two-factor-auth)
- [CI4-Auth: a user, group, role and permission management library for Codeigniter 4](https://github.com/glewe/ci4-auth)
## License
Licensed under MIT license. See [LICENSE](./LICENSE) for details.
[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))

View File

@@ -0,0 +1,65 @@
{
"name": "robthree/twofactorauth",
"description": "Two Factor Authentication",
"type": "library",
"keywords": [ "Authentication", "Two Factor Authentication", "Multi Factor Authentication", "TFA", "MFA", "PHP", "Authenticator", "Authy" ],
"homepage": "https://github.com/RobThree/TwoFactorAuth",
"license": "MIT",
"authors": [
{
"name": "Rob Janssen",
"homepage": "http://robiii.me",
"role": "Developer"
},
{
"name": "Nicolas CARPi",
"homepage": "https://github.com/NicolasCARPi",
"role": "Developer"
},
{
"name": "Will Power",
"homepage": "https://github.com/willpower232",
"role": "Developer"
}
],
"support": {
"issues": "https://github.com/RobThree/TwoFactorAuth/issues",
"source": "https://github.com/RobThree/TwoFactorAuth"
},
"require": {
"php": ">=8.2.0"
},
"require-dev": {
"phpunit/phpunit": "^9",
"friendsofphp/php-cs-fixer": "^3.13",
"phpstan/phpstan": "^1.9"
},
"suggest": {
"bacon/bacon-qr-code": "Needed for BaconQrCodeProvider provider",
"endroid/qr-code": "Needed for EndroidQrCodeProvider"
},
"autoload": {
"psr-4": {
"RobThree\\Auth\\": "lib"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"phpstan": [
"phpstan analyze --xdebug lib tests testsDependency"
],
"lint": [
"php-cs-fixer fix -v"
],
"lint-ci": [
"PHP_CS_FIXER_IGNORE_ENV=1 php-cs-fixer fix -v --dry-run --stop-on-violation"
],
"test": [
"XDEBUG_MODE=coverage phpunit"
]
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth;
/**
* List of supported cryptographic algorithms
*/
enum Algorithm: string
{
case Md5 = 'md5';
case Sha1 = 'sha1';
case Sha256 = 'sha256';
case Sha512 = 'sha512';
}

View File

@@ -0,0 +1,141 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
use BaconQrCode\Renderer\Color\Rgb;
use BaconQrCode\Renderer\Image\EpsImageBackEnd;
use BaconQrCode\Renderer\Image\ImageBackEndInterface;
use BaconQrCode\Renderer\Image\ImagickImageBackEnd;
use BaconQrCode\Renderer\Image\SvgImageBackEnd;
use BaconQrCode\Renderer\ImageRenderer;
use BaconQrCode\Renderer\RendererStyle\EyeFill;
use BaconQrCode\Renderer\RendererStyle\Fill;
use BaconQrCode\Renderer\RendererStyle\RendererStyle;
use BaconQrCode\Writer;
use RuntimeException;
class BaconQrCodeProvider implements IQRCodeProvider
{
/**
* Ensure we using the latest Bacon QR Code and specify default options
*/
public function __construct(
private readonly int $borderWidth = 4,
private string|array $backgroundColour = '#ffffff',
private string|array $foregroundColour = '#000000',
private string $format = 'png',
) {
$this->backgroundColour = $this->handleColour($this->backgroundColour);
$this->foregroundColour = $this->handleColour($this->foregroundColour);
$this->format = strtolower($this->format);
}
public function getMimeType(): string
{
switch ($this->format) {
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'svg':
return 'image/svg+xml';
case 'eps':
return 'application/postscript';
}
throw new RuntimeException(sprintf('Unknown MIME-type: %s', $this->format));
}
public function getQRCodeImage(string $qrText, int $size): string
{
$backend = match ($this->format) {
'svg' => new SvgImageBackEnd(),
'eps' => new EpsImageBackEnd(),
default => new ImagickImageBackEnd($this->format),
};
$output = $this->getQRCodeByBackend($qrText, $size, $backend);
if ($this->format === 'svg') {
$svg = explode("\n", $output);
return $svg[1];
}
return $output;
}
/**
* Abstract QR code generation function
* providing colour changing support
*/
private function getQRCodeByBackend($qrText, $size, ImageBackEndInterface $backend)
{
$rendererStyleArgs = array($size, $this->borderWidth);
if (is_array($this->foregroundColour) && is_array($this->backgroundColour)) {
$rendererStyleArgs = array(...$rendererStyleArgs, ...array(
null,
null,
Fill::withForegroundColor(
new Rgb(...$this->backgroundColour),
new Rgb(...$this->foregroundColour),
new EyeFill(null, null),
new EyeFill(null, null),
new EyeFill(null, null)
),
));
}
$writer = new Writer(new ImageRenderer(
new RendererStyle(...$rendererStyleArgs),
$backend
));
return $writer->writeString($qrText);
}
/**
* Ensure colour is an array of three values but also
* accept a string and assume its a 3 or 6 character hex
*/
private function handleColour(array|string $colour): array|string
{
if (is_string($colour) && $colour[0] == '#') {
$hexToRGB = static function ($input) {
// ensure input no longer has a # for more predictable division
// PHP 8.1 does not like implicitly casting a float to an int
$input = trim($input, '#');
if (strlen($input) != 3 && strlen($input) != 6) {
throw new RuntimeException('Colour should be a 3 or 6 character value after the #');
}
// split the array into three chunks
$split = str_split($input, strlen($input) / 3);
// cope with three character hex reference
if (strlen($input) == 3) {
array_walk($split, static function (&$character) {
$character = str_repeat($character, 2);
});
}
// convert hex to rgb
return array_map('hexdec', $split);
};
return $hexToRGB($colour);
}
if (is_array($colour) && count($colour) == 3) {
return $colour;
}
throw new RuntimeException('Invalid colour value');
}
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
abstract class BaseHTTPQRCodeProvider implements IQRCodeProvider
{
protected bool $verifyssl = true;
protected function getContent(string $url): string
{
$curlhandle = curl_init();
curl_setopt_array($curlhandle, array(
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_DNS_CACHE_TIMEOUT => 10,
CURLOPT_TIMEOUT => 10,
CURLOPT_SSL_VERIFYPEER => $this->verifyssl,
CURLOPT_USERAGENT => 'TwoFactorAuth',
));
$data = curl_exec($curlhandle);
if ($data === false) {
throw new QRException(curl_error($curlhandle));
}
return $data;
}
}

View File

@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
use Endroid\QrCode\Color\Color;
use Endroid\QrCode\ErrorCorrectionLevel;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelHigh;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelInterface;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelLow;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelMedium;
use Endroid\QrCode\ErrorCorrectionLevel\ErrorCorrectionLevelQuartile;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
class EndroidQrCodeProvider implements IQRCodeProvider
{
public $bgcolor;
public $color;
public $margin;
public $errorcorrectionlevel;
protected $endroid4 = false;
protected $endroid5 = false;
protected $endroid6 = false;
public function __construct($bgcolor = 'ffffff', $color = '000000', $margin = 0, $errorcorrectionlevel = 'H')
{
$this->endroid5 = enum_exists(ErrorCorrectionLevel::class);
$this->endroid6 = $this->endroid5 && !method_exists(QrCode::class, 'setSize');
$this->endroid4 = $this->endroid6 || method_exists(QrCode::class, 'create');
$this->bgcolor = $this->handleColor($bgcolor);
$this->color = $this->handleColor($color);
$this->margin = $margin;
$this->errorcorrectionlevel = $this->handleErrorCorrectionLevel($errorcorrectionlevel);
}
public function getMimeType(): string
{
return 'image/png';
}
public function getQRCodeImage(string $qrText, int $size): string
{
if (!$this->endroid4) {
return $this->qrCodeInstance($qrText, $size)->writeString();
}
$writer = new PngWriter();
return $writer->write($this->qrCodeInstance($qrText, $size))->getString();
}
protected function qrCodeInstance(string $qrText, int $size): QrCode
{
if ($this->endroid6) {
return new QrCode(
data: $qrText,
errorCorrectionLevel: $this->errorcorrectionlevel,
size: $size,
margin: $this->margin,
foregroundColor: $this->color,
backgroundColor: $this->bgcolor
);
}
$qrCode = new QrCode($qrText);
$qrCode->setSize($size);
$qrCode->setErrorCorrectionLevel($this->errorcorrectionlevel);
$qrCode->setMargin($this->margin);
$qrCode->setBackgroundColor($this->bgcolor);
$qrCode->setForegroundColor($this->color);
return $qrCode;
}
private function handleColor(string $color): Color|array
{
$split = str_split($color, 2);
$r = hexdec($split[0]);
$g = hexdec($split[1]);
$b = hexdec($split[2]);
return $this->endroid4 ? new Color($r, $g, $b, 0) : array('r' => $r, 'g' => $g, 'b' => $b, 'a' => 0);
}
private function handleErrorCorrectionLevel(string $level): ErrorCorrectionLevelInterface|ErrorCorrectionLevel
{
// First check for version 5 (using enums)
if ($this->endroid5) {
return match ($level) {
'L' => ErrorCorrectionLevel::Low,
'M' => ErrorCorrectionLevel::Medium,
'Q' => ErrorCorrectionLevel::Quartile,
default => ErrorCorrectionLevel::High,
};
}
// If not check for version 4 (using classes)
if ($this->endroid4) {
return match ($level) {
'L' => new ErrorCorrectionLevelLow(),
'M' => new ErrorCorrectionLevelMedium(),
'Q' => new ErrorCorrectionLevelQuartile(),
default => new ErrorCorrectionLevelHigh(),
};
}
// Any other version will be using strings
return match ($level) {
'L' => ErrorCorrectionLevel::LOW(),
'M' => ErrorCorrectionLevel::MEDIUM(),
'Q' => ErrorCorrectionLevel::QUARTILE(),
default => ErrorCorrectionLevel::HIGH(),
};
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
use Endroid\QrCode\Logo\Logo;
use Endroid\QrCode\QrCode;
use Endroid\QrCode\Writer\PngWriter;
class EndroidQrCodeWithLogoProvider extends EndroidQrCodeProvider
{
protected $logoPath;
protected $logoSize;
/**
* Adds an image to the middle of the QR Code.
* @param string $path Path to an image file
* @param array|int $size Just the width, or [width, height]
*/
public function setLogo($path, $size = null)
{
$this->logoPath = $path;
$this->logoSize = (array)$size;
}
public function getQRCodeImage(string $qrText, int $size): string
{
if (!$this->endroid4) {
return $this->qrCodeInstance($qrText, $size)->writeString();
}
$logo = null;
if ($this->logoPath) {
if ($this->endroid6) {
$logo = new Logo($this->logoPath, ...$this->logoSize);
} else {
$logo = Logo::create($this->logoPath);
if ($this->logoSize) {
$logo->setResizeToWidth($this->logoSize[0]);
if (isset($this->logoSize[1])) {
$logo->setResizeToHeight($this->logoSize[1]);
}
}
}
}
$writer = new PngWriter();
return $writer->write($this->qrCodeInstance($qrText, $size), $logo)->getString();
}
protected function qrCodeInstance(string $qrText, int $size): QrCode
{
$qrCode = parent::qrCodeInstance($qrText, $size);
if (!$this->endroid4 && $this->logoPath) {
$qrCode->setLogoPath($this->logoPath);
if ($this->logoSize) {
$qrCode->setLogoSize($this->logoSize[0], $this->logoSize[1] ?? null);
}
}
return $qrCode;
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
// https://developers.google.com/chart/infographics/docs/qr_codes
class GoogleChartsQrCodeProvider extends BaseHTTPQRCodeProvider
{
public function __construct(protected bool $verifyssl = true, public string $errorcorrectionlevel = 'L', public int $margin = 4, public string $encoding = 'UTF-8')
{
}
public function getMimeType(): string
{
return 'image/png';
}
public function getQRCodeImage(string $qrText, int $size): string
{
return $this->getContent($this->getUrl($qrText, $size));
}
public function getUrl(string $qrText, int $size): string
{
$queryParameters = array(
'chs' => $size . 'x' . $size,
'chld' => strtoupper($this->errorcorrectionlevel) . '|' . $this->margin,
'cht' => 'qr',
'choe' => $this->encoding,
'chl' => $qrText,
);
return 'https://chart.googleapis.com/chart?' . http_build_query($queryParameters);
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
use function base64_decode;
use function preg_match;
trait HandlesDataUri
{
/**
* @return array<string, string>|null
*/
private function DecodeDataUri(string $datauri): ?array
{
if (preg_match('/data:(?P<mimetype>[\w\.\-\+\/]+);(?P<encoding>\w+),(?P<data>.*)/', $datauri, $m) === 1) {
return array(
'mimetype' => $m['mimetype'],
'encoding' => $m['encoding'],
'data' => base64_decode($m['data'], true),
);
}
return null;
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
interface IQRCodeProvider
{
/**
* Generate and return the QR code to embed in a web page
*
* @param string $qrText the value to encode in the QR code
* @param int $size the desired size of the QR code
*
* @return string file contents of the QR code
*/
public function getQRCodeImage(string $qrText, int $size): string;
/**
* Returns the appropriate mime type for the QR code
* that will be generated
*/
public function getMimeType(): string;
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
/**
* Use https://image-charts.com to provide a QR code
*/
class ImageChartsQRCodeProvider extends BaseHTTPQRCodeProvider
{
public function __construct(protected bool $verifyssl = true, public string $errorcorrectionlevel = 'L', public int $margin = 1)
{
}
public function getMimeType(): string
{
return 'image/png';
}
public function getQRCodeImage(string $qrText, int $size): string
{
return $this->getContent($this->getUrl($qrText, $size));
}
public function getUrl(string $qrText, int $size): string
{
$queryParameters = array(
'cht' => 'qr',
'chs' => ceil($size / 2) . 'x' . ceil($size / 2),
'chld' => $this->errorcorrectionlevel . '|' . $this->margin,
'chl' => $qrText,
);
return 'https://image-charts.com/chart?' . http_build_query($queryParameters);
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
use RobThree\Auth\TwoFactorAuthException;
class QRException extends TwoFactorAuthException
{
}

View File

@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
/**
* Use https://goqr.me/api/doc/create-qr-code/ to get QR code
*/
class QRServerProvider extends BaseHTTPQRCodeProvider
{
public function __construct(protected bool $verifyssl = true, public string $errorcorrectionlevel = 'L', public int $margin = 4, public int $qzone = 1, public string $bgcolor = 'ffffff', public string $color = '000000', public string $format = 'png')
{
}
public function getMimeType(): string
{
switch (strtolower($this->format)) {
case 'png':
return 'image/png';
case 'gif':
return 'image/gif';
case 'jpg':
case 'jpeg':
return 'image/jpeg';
case 'svg':
return 'image/svg+xml';
case 'eps':
return 'application/postscript';
}
throw new QRException(sprintf('Unknown MIME-type: %s', $this->format));
}
public function getQRCodeImage(string $qrText, int $size): string
{
return $this->getContent($this->getUrl($qrText, $size));
}
public function getUrl(string $qrText, int $size): string
{
$queryParameters = array(
'size' => $size . 'x' . $size,
'ecc' => strtoupper($this->errorcorrectionlevel),
'margin' => $this->margin,
'qzone' => $this->qzone,
'bgcolor' => $this->decodeColor($this->bgcolor),
'color' => $this->decodeColor($this->color),
'format' => strtolower($this->format),
'data' => $qrText,
);
return 'https://api.qrserver.com/v1/create-qr-code/?' . http_build_query($queryParameters);
}
private function decodeColor(string $value): string
{
return vsprintf('%d-%d-%d', sscanf($value, '%02x%02x%02x'));
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Qr;
/**
* Use http://qrickit.com/qrickit_apps/qrickit_api.php to provide a QR code
*/
class QRicketProvider extends BaseHTTPQRCodeProvider
{
public function __construct(protected bool $verifyssl = true, public string $errorcorrectionlevel = 'L', public string $bgcolor = 'ffffff', public string $color = '000000', public string $format = 'p')
{
}
public function getMimeType(): string
{
switch (strtolower($this->format)) {
case 'p':
return 'image/png';
case 'g':
return 'image/gif';
case 'j':
return 'image/jpeg';
}
throw new QRException(sprintf('Unknown MIME-type: %s', $this->format));
}
public function getQRCodeImage(string $qrText, int $size): string
{
return $this->getContent($this->getUrl($qrText, $size));
}
public function getUrl(string $qrText, int $size): string
{
$queryParameters = array(
'qrsize' => $size,
'e' => strtolower($this->errorcorrectionlevel),
'bgdcolor' => $this->bgcolor,
'fgdcolor' => $this->color,
't' => strtolower($this->format),
'd' => $qrText,
);
return 'https://qrickit.com/api/qr?' . http_build_query($queryParameters);
}
}

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Rng;
class CSRNGProvider implements IRNGProvider
{
/**
* {@inheritdoc}
*/
public function getRandomBytes(int $bytecount): string
{
return random_bytes($bytecount); // PHP7+
}
}

View File

@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Rng;
interface IRNGProvider
{
public function getRandomBytes(int $bytecount): string;
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Rng;
use RobThree\Auth\TwoFactorAuthException;
class RNGException extends TwoFactorAuthException
{
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Time;
use DateTime;
use Exception;
/**
* Takes the time from any webserver by doing a HEAD request on the specified URL and extracting the 'Date:' header
*/
class HttpTimeProvider implements ITimeProvider
{
/**
* @param array<string, mixed> $options
*/
public function __construct(
public string $url = 'https://google.com',
public string $expectedtimeformat = 'D, d M Y H:i:s O+',
public ?array $options = null,
) {
if ($this->options === null) {
$this->options = array(
'http' => array(
'method' => 'HEAD',
'follow_location' => false,
'ignore_errors' => true,
'max_redirects' => 0,
'request_fulluri' => true,
'header' => array(
'Connection: close',
'User-agent: TwoFactorAuth HttpTimeProvider (https://github.com/RobThree/TwoFactorAuth)',
'Cache-Control: no-cache',
),
),
);
}
}
/**
* {@inheritdoc}
*/
public function getTime()
{
try {
$context = stream_context_create($this->options);
$fd = fopen($this->url, 'rb', false, $context);
$headers = stream_get_meta_data($fd);
fclose($fd);
foreach ($headers['wrapper_data'] as $h) {
if (strcasecmp(substr($h, 0, 5), 'Date:') === 0) {
return DateTime::createFromFormat($this->expectedtimeformat, trim(substr($h, 5)))->getTimestamp();
}
}
throw new Exception('Invalid or no "Date:" header found');
} catch (Exception $ex) {
throw new TimeException(sprintf('Unable to retrieve time from %s (%s)', $this->url, $ex->getMessage()));
}
}
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Time;
interface ITimeProvider
{
/**
* @return int the current timestamp according to this provider
*/
public function getTime();
}

View File

@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Time;
class LocalMachineTimeProvider implements ITimeProvider
{
public function getTime()
{
return time();
}
}

View File

@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Time;
use Exception;
use function socket_create;
/**
* Takes the time from any NTP server
*/
class NTPTimeProvider implements ITimeProvider
{
public function __construct(public string $host = 'time.google.com', public int $port = 123, public int $timeout = 1)
{
if ($this->port <= 0 || $this->port > 65535) {
throw new TimeException('Port must be 0 < port < 65535');
}
if ($this->timeout < 0) {
throw new TimeException('Timeout must be >= 0');
}
}
/**
* {@inheritdoc}
*/
public function getTime()
{
try {
// Create a socket and connect to NTP server
$sock = socket_create(AF_INET, SOCK_DGRAM, SOL_UDP);
socket_set_option($sock, SOL_SOCKET, SO_RCVTIMEO, array('sec' => $this->timeout, 'usec' => 0));
socket_connect($sock, $this->host, $this->port);
// Send request
$msg = "\010" . str_repeat("\0", 47);
socket_send($sock, $msg, strlen($msg), 0);
// Receive response and close socket
if (socket_recv($sock, $recv, 48, MSG_WAITALL) === false) {
throw new Exception(socket_strerror(socket_last_error($sock)));
}
socket_close($sock);
// Interpret response
$data = unpack('N12', $recv);
$timestamp = (int)sprintf('%u', $data[9]);
// NTP is number of seconds since 0000 UT on 1 January 1900 Unix time is seconds since 0000 UT on 1 January 1970
return $timestamp - 2208988800;
} catch (Exception $ex) {
throw new TimeException(sprintf('Unable to retrieve time from %s (%s)', $this->host, $ex->getMessage()));
}
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth\Providers\Time;
use RobThree\Auth\TwoFactorAuthException;
class TimeException extends TwoFactorAuthException
{
}

View File

@@ -0,0 +1,211 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth;
use function hash_equals;
use RobThree\Auth\Providers\Qr\IQRCodeProvider;
use RobThree\Auth\Providers\Rng\CSRNGProvider;
use RobThree\Auth\Providers\Rng\IRNGProvider;
use RobThree\Auth\Providers\Time\HttpTimeProvider;
use RobThree\Auth\Providers\Time\ITimeProvider;
use RobThree\Auth\Providers\Time\LocalMachineTimeProvider;
use RobThree\Auth\Providers\Time\NTPTimeProvider;
use SensitiveParameter;
// Based on / inspired by: https://github.com/PHPGangsta/GoogleAuthenticator
// Algorithms, digits, period etc. explained: https://github.com/google/google-authenticator/wiki/Key-Uri-Format
class TwoFactorAuth
{
private static string $_base32dict = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567=';
/** @var array<string> */
private static array $_base32;
/** @var array<string, int> */
private static array $_base32lookup = array();
public function __construct(
private IQRCodeProvider $qrcodeprovider,
private readonly ?string $issuer = null,
private readonly int $digits = 6,
private readonly int $period = 30,
private readonly Algorithm $algorithm = Algorithm::Sha1,
private ?IRNGProvider $rngprovider = null,
private ?ITimeProvider $timeprovider = null
) {
if ($this->digits <= 0) {
throw new TwoFactorAuthException('Digits must be > 0');
}
if ($this->period <= 0) {
throw new TwoFactorAuthException('Period must be int > 0');
}
self::$_base32 = str_split(self::$_base32dict);
self::$_base32lookup = array_flip(self::$_base32);
}
/**
* Create a new secret
*/
public function createSecret(int $bits = 160): string
{
$secret = '';
$bytes = (int)ceil($bits / 5); // We use 5 bits of each byte (since we have a 32-character 'alphabet' / BASE32)
$rngprovider = $this->getRngProvider();
$rnd = $rngprovider->getRandomBytes($bytes);
for ($i = 0; $i < $bytes; $i++) {
$secret .= self::$_base32[ord($rnd[$i]) & 31]; //Mask out left 3 bits for 0-31 values
}
return $secret;
}
/**
* Calculate the code with given secret and point in time
*/
public function getCode(#[SensitiveParameter] string $secret, ?int $time = null): string
{
$secretkey = $this->base32Decode($secret);
$timestamp = "\0\0\0\0" . pack('N*', $this->getTimeSlice($this->getTime($time))); // Pack time into binary string
$hashhmac = hash_hmac($this->algorithm->value, $timestamp, $secretkey, true); // Hash it with users secret key
$hashpart = substr($hashhmac, ord(substr($hashhmac, -1)) & 0x0F, 4); // Use last nibble of result as index/offset and grab 4 bytes of the result
$value = unpack('N', $hashpart); // Unpack binary value
$value = $value[1] & 0x7FFFFFFF; // Drop MSB, keep only 31 bits
return str_pad((string)($value % 10 ** $this->digits), $this->digits, '0', STR_PAD_LEFT);
}
/**
* Check if the code is correct. This will accept codes starting from ($discrepancy * $period) sec ago to ($discrepancy * period) sec from now
*/
public function verifyCode(string $secret, string $code, int $discrepancy = 1, ?int $time = null, ?int &$timeslice = 0): bool
{
$timestamp = $this->getTime($time);
$timeslice = 0;
// To keep safe from timing-attacks we iterate *all* possible codes even though we already may have
// verified a code is correct. We use the timeslice variable to hold either 0 (no match) or the timeslice
// of the match. Each iteration we either set the timeslice variable to the timeslice of the match
// or set the value to itself. This is an effort to maintain constant execution time for the code.
for ($i = -$discrepancy; $i <= $discrepancy; $i++) {
$ts = $timestamp + ($i * $this->period);
$slice = $this->getTimeSlice($ts);
$timeslice = hash_equals($this->getCode($secret, $ts), $code) ? $slice : $timeslice;
}
return $timeslice > 0;
}
/**
* Get data-uri of QRCode
*/
public function getQRCodeImageAsDataUri(string $label, #[SensitiveParameter] string $secret, int $size = 200): string
{
if ($size <= 0) {
throw new TwoFactorAuthException('Size must be > 0');
}
return 'data:'
. $this->qrcodeprovider->getMimeType()
. ';base64,'
. base64_encode($this->qrcodeprovider->getQRCodeImage($this->getQRText($label, $secret), $size));
}
/**
* Compare default timeprovider with specified timeproviders and ensure the time is within the specified number of seconds (leniency)
* @param array<ITimeProvider> $timeproviders
* @throws TwoFactorAuthException
*/
public function ensureCorrectTime(?array $timeproviders = null, int $leniency = 5): void
{
if ($timeproviders === null) {
$timeproviders = array(
new NTPTimeProvider(),
new HttpTimeProvider(),
);
}
// Get default time provider
$timeprovider = $this->getTimeProvider();
// Iterate specified time providers
foreach ($timeproviders as $t) {
if (!($t instanceof ITimeProvider)) {
throw new TwoFactorAuthException('Object does not implement ITimeProvider');
}
// 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
if (abs($timeprovider->getTime() - $t->getTime()) > $leniency) {
throw new TwoFactorAuthException(sprintf('Time for timeprovider is off by more than %d seconds when compared to %s', $leniency, get_class($t)));
}
}
}
/**
* Builds a string to be encoded in a QR code
*/
public function getQRText(string $label, #[SensitiveParameter] string $secret): string
{
return 'otpauth://totp/' . rawurlencode($label)
. '?secret=' . rawurlencode($secret)
. '&issuer=' . rawurlencode((string)$this->issuer)
. '&period=' . $this->period
. '&algorithm=' . rawurlencode(strtoupper($this->algorithm->value))
. '&digits=' . $this->digits;
}
/**
* @throws TwoFactorAuthException
*/
public function getRngProvider(): IRNGProvider
{
return $this->rngprovider ??= new CSRNGProvider();
}
public function getTimeProvider(): ITimeProvider
{
// Set default time provider if none was specified
return $this->timeprovider ??= new LocalMachineTimeProvider();
}
private function getTime(?int $time = null): int
{
return $time ?? $this->getTimeProvider()->getTime();
}
private function getTimeSlice(?int $time = null, int $offset = 0): int
{
return (int)floor($time / $this->period) + ($offset * $this->period);
}
private function base32Decode(string $value): string
{
if ($value === '') {
return '';
}
if (preg_match('/[^' . preg_quote(self::$_base32dict, '/') . ']/', $value) !== 0) {
throw new TwoFactorAuthException('Invalid base32 string');
}
$buffer = '';
foreach (str_split($value) as $char) {
if ($char !== '=') {
$buffer .= str_pad(decbin(self::$_base32lookup[$char]), 5, '0', STR_PAD_LEFT);
}
}
$length = strlen($buffer);
$blocks = trim(chunk_split(substr($buffer, 0, $length - ($length % 8)), 8, ' '));
$output = '';
foreach (explode(' ', $blocks) as $block) {
$output .= chr(bindec(str_pad($block, 8, '0', STR_PAD_RIGHT)));
}
return $output;
}
}

View File

@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace RobThree\Auth;
use Exception;
class TwoFactorAuthException extends Exception
{
}