v1.0 Initial commit of project

This commit is contained in:
2026-01-01 10:54:18 +01:00
commit 768cf78b57
990 changed files with 241213 additions and 0 deletions

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,32 @@
<?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));
}
curl_close($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
{
}