Merge branch 'QA_5_2'
Signed-off-by: Maurício Meneghini Fauth <mauricio@fauth.dev>
This commit is contained in:
commit
37cf6484eb
@ -79,6 +79,7 @@ phpMyAdmin - ChangeLog
|
||||
- issue #17927 Fix key navigation between select inputs (drop an old Firefox workaround)
|
||||
- issue #17967 Fix missing icon for collapse all button
|
||||
- issue #18006 Fixed UUID columns can't be moved
|
||||
- issue Add `spellcheck="false"` to all password fields and some text fields to avoid spell-jacking data leaks
|
||||
|
||||
5.2.0 (2022-05-10)
|
||||
- issue #16521 Upgrade Bootstrap to version 5
|
||||
|
||||
@ -92,7 +92,8 @@
|
||||
"tecnickcom/tcpdf": "For PDF support",
|
||||
"pragmarx/google2fa-qrcode": "^2.1 - For 2FA authentication",
|
||||
"bacon/bacon-qr-code": "^2.0 - For 2FA authentication",
|
||||
"code-lts/u2f-php-server": "For FIDO U2F authentication"
|
||||
"code-lts/u2f-php-server": "For FIDO U2F authentication",
|
||||
"web-auth/webauthn-lib": "For better WebAuthn/FIDO2 authentication support"
|
||||
},
|
||||
"require-dev": {
|
||||
"bacon/bacon-qr-code": "^2.0",
|
||||
@ -111,7 +112,8 @@
|
||||
"squizlabs/php_codesniffer": "~3.6.0",
|
||||
"symfony/console": "^5.2.3",
|
||||
"tecnickcom/tcpdf": "6.5.0",
|
||||
"vimeo/psalm": "^4.22"
|
||||
"vimeo/psalm": "^4.22",
|
||||
"web-auth/webauthn-lib": "^3.3"
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
|
||||
@ -59,8 +59,8 @@ if (isset($_POST['user'])) {
|
||||
}
|
||||
|
||||
echo '<form action="signon.php" method="post">
|
||||
Username: <input type="text" name="user" autocomplete="username"><br>
|
||||
Password: <input type="password" name="password" autocomplete="current-password"><br>
|
||||
Username: <input type="text" name="user" autocomplete="username" spellcheck="false"><br>
|
||||
Password: <input type="password" name="password" autocomplete="current-password" spellcheck="false"><br>
|
||||
Host: (will use the one from config.inc.php by default)
|
||||
<input type="text" name="host"><br>
|
||||
Port: (will use the one from config.inc.php by default)
|
||||
|
||||
133
js/src/webauthn.js
Normal file
133
js/src/webauthn.js
Normal file
@ -0,0 +1,133 @@
|
||||
/**
|
||||
* @param {ArrayBuffer} buffer
|
||||
*
|
||||
* @return {string}
|
||||
*/
|
||||
const arrayBufferToBase64 = buffer => {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let string = '';
|
||||
for (const byte of bytes) {
|
||||
string += String.fromCharCode(byte);
|
||||
}
|
||||
|
||||
return window.btoa(string);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} string
|
||||
*
|
||||
* @return {Uint8Array}
|
||||
*/
|
||||
const base64ToUint8Array = string => {
|
||||
return Uint8Array.from(window.atob(string), char => char.charCodeAt(0));
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {JQuery<HTMLElement>} $input
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
const handleCreation = $input => {
|
||||
const $form = $input.parents('form');
|
||||
$form.find('input[type=submit]').hide();
|
||||
|
||||
const creationOptionsJson = $input.attr('data-creation-options');
|
||||
const creationOptions = JSON.parse(creationOptionsJson);
|
||||
|
||||
const publicKey = creationOptions;
|
||||
publicKey.challenge = base64ToUint8Array(creationOptions.challenge);
|
||||
publicKey.user.id = base64ToUint8Array(creationOptions.user.id);
|
||||
if (creationOptions.excludeCredentials) {
|
||||
const excludedCredentials = [];
|
||||
for (let value of creationOptions.excludeCredentials) {
|
||||
let excludedCredential = value;
|
||||
excludedCredential.id = base64ToUint8Array(value.id);
|
||||
excludedCredentials.push(excludedCredential);
|
||||
}
|
||||
publicKey.excludeCredentials = excludedCredentials;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line compat/compat
|
||||
navigator.credentials.create({ publicKey: publicKey })
|
||||
.then((credential) => {
|
||||
const credentialJson = JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||
attestationObject: arrayBufferToBase64(credential.response.attestationObject),
|
||||
}
|
||||
});
|
||||
$input.val(credentialJson);
|
||||
$form.trigger('submit');
|
||||
})
|
||||
.catch((error) => Functions.ajaxShowMessage(error, false, 'error'));
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {JQuery<HTMLElement>} $input
|
||||
*
|
||||
* @return {void}
|
||||
*/
|
||||
const handleRequest = $input => {
|
||||
const $form = $input.parents('form');
|
||||
$form.find('input[type=submit]').hide();
|
||||
|
||||
const requestOptionsJson = $input.attr('data-request-options');
|
||||
const requestOptions = JSON.parse(requestOptionsJson);
|
||||
|
||||
const publicKey = requestOptions;
|
||||
publicKey.challenge = base64ToUint8Array(requestOptions.challenge);
|
||||
if (requestOptions.allowCredentials) {
|
||||
const allowedCredentials = [];
|
||||
for (let value of requestOptions.allowCredentials) {
|
||||
let allowedCredential = value;
|
||||
allowedCredential.id = base64ToUint8Array(value.id);
|
||||
allowedCredentials.push(allowedCredential);
|
||||
}
|
||||
publicKey.allowCredentials = allowedCredentials;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line compat/compat
|
||||
navigator.credentials.get({ publicKey: publicKey })
|
||||
.then((credential) => {
|
||||
const credentialJson = JSON.stringify({
|
||||
id: credential.id,
|
||||
rawId: arrayBufferToBase64(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
authenticatorData: arrayBufferToBase64(credential.response.authenticatorData),
|
||||
clientDataJSON: arrayBufferToBase64(credential.response.clientDataJSON),
|
||||
signature: arrayBufferToBase64(credential.response.signature),
|
||||
userHandle: arrayBufferToBase64(credential.response.userHandle),
|
||||
}
|
||||
});
|
||||
$input.val(credentialJson);
|
||||
$form.trigger('submit');
|
||||
})
|
||||
.catch((error) => Functions.ajaxShowMessage(error, false, 'error'));
|
||||
};
|
||||
|
||||
AJAX.registerOnload('webauthn.js', function () {
|
||||
if (
|
||||
! navigator.credentials
|
||||
|| ! navigator.credentials.create
|
||||
|| ! navigator.credentials.get
|
||||
|| ! window.PublicKeyCredential
|
||||
) {
|
||||
Functions.ajaxShowMessage(Messages.webAuthnNotSupported, false, 'error');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const $creationInput = $('#webauthn_creation_response');
|
||||
if ($creationInput.length > 0) {
|
||||
handleCreation($creationInput);
|
||||
}
|
||||
|
||||
const $requestInput = $('#webauthn_request_response');
|
||||
if ($requestInput.length > 0) {
|
||||
handleRequest($requestInput);
|
||||
}
|
||||
});
|
||||
@ -699,6 +699,10 @@ final class JavaScriptMessagesController
|
||||
// l10n: error code 4 (from U2F API) on authanticate
|
||||
'strU2FErrorAuthenticate' => _pgettext('U2F error', 'Invalid security key.'),
|
||||
|
||||
'webAuthnNotSupported' => __(
|
||||
'WebAuthn is not available. Please use a supported browser in a secure context (HTTPS).'
|
||||
),
|
||||
|
||||
/* Designer */
|
||||
'strIndexedDBNotWorking' => __(
|
||||
'You can not open, save or delete your page layout, as IndexedDB is not working'
|
||||
|
||||
@ -215,6 +215,6 @@ class Key extends TwoFactorPlugin
|
||||
*/
|
||||
public static function getDescription()
|
||||
{
|
||||
return __('Provides authentication using hardware security tokens supporting FIDO U2F, such as a Yubikey.');
|
||||
return __('Provides authentication using hardware security tokens supporting FIDO U2F, such as a YubiKey.');
|
||||
}
|
||||
}
|
||||
|
||||
246
libraries/classes/Plugins/TwoFactor/WebAuthn.php
Normal file
246
libraries/classes/Plugins/TwoFactor/WebAuthn.php
Normal file
@ -0,0 +1,246 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpMyAdmin\Plugins\TwoFactor;
|
||||
|
||||
use PhpMyAdmin\Plugins\TwoFactorPlugin;
|
||||
use PhpMyAdmin\ResponseRenderer;
|
||||
use PhpMyAdmin\TwoFactor;
|
||||
use PhpMyAdmin\WebAuthn\CustomServer;
|
||||
use PhpMyAdmin\WebAuthn\Server;
|
||||
use PhpMyAdmin\WebAuthn\WebauthnLibServer;
|
||||
use SodiumException;
|
||||
use Throwable;
|
||||
use Webauthn\Server as WebauthnServer;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
use function __;
|
||||
use function class_exists;
|
||||
use function is_array;
|
||||
use function is_string;
|
||||
use function json_decode;
|
||||
use function json_encode;
|
||||
use function random_bytes;
|
||||
use function sodium_base642bin;
|
||||
use function sodium_bin2base64;
|
||||
|
||||
use const SODIUM_BASE64_VARIANT_ORIGINAL;
|
||||
use const SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING;
|
||||
|
||||
/**
|
||||
* Two-factor authentication plugin for the WebAuthn/FIDO2 protocol.
|
||||
*/
|
||||
class WebAuthn extends TwoFactorPlugin
|
||||
{
|
||||
/** @var string */
|
||||
public static $id = 'WebAuthn';
|
||||
|
||||
/** @var Server */
|
||||
private $server;
|
||||
|
||||
public function __construct(TwoFactor $twofactor)
|
||||
{
|
||||
parent::__construct($twofactor);
|
||||
if (
|
||||
! isset($this->twofactor->config['settings']['userHandle'])
|
||||
|| ! is_string($this->twofactor->config['settings']['userHandle'])
|
||||
) {
|
||||
$this->twofactor->config['settings']['userHandle'] = '';
|
||||
}
|
||||
|
||||
if (
|
||||
! isset($this->twofactor->config['settings']['credentials'])
|
||||
|| ! is_array($this->twofactor->config['settings']['credentials'])
|
||||
) {
|
||||
$this->twofactor->config['settings']['credentials'] = [];
|
||||
}
|
||||
|
||||
$this->server = $this->createServer();
|
||||
}
|
||||
|
||||
private function createServer(): Server
|
||||
{
|
||||
return class_exists(WebauthnServer::class) ? new WebauthnLibServer($this->twofactor) : new CustomServer();
|
||||
}
|
||||
|
||||
public function setServer(Server $server): void
|
||||
{
|
||||
$this->server = $server;
|
||||
}
|
||||
|
||||
public function render(): string
|
||||
{
|
||||
$request = $GLOBALS['request'];
|
||||
$userHandle = sodium_base642bin($this->getUserHandleFromSettings(), SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
|
||||
$requestOptions = $this->server->getCredentialRequestOptions(
|
||||
$this->twofactor->user,
|
||||
$userHandle,
|
||||
$request->getUri()->getHost(),
|
||||
$this->getAllowedCredentials()
|
||||
);
|
||||
$requestOptionsEncoded = json_encode($requestOptions);
|
||||
$_SESSION['WebAuthnCredentialRequestOptions'] = $requestOptionsEncoded;
|
||||
$this->loadScripts();
|
||||
|
||||
return $this->template->render(
|
||||
'login/twofactor/webauthn_request',
|
||||
['request_options' => $requestOptionsEncoded]
|
||||
);
|
||||
}
|
||||
|
||||
public function check(): bool
|
||||
{
|
||||
$this->provided = false;
|
||||
$request = $GLOBALS['request'];
|
||||
$authenticatorResponse = $request->getParsedBodyParam('webauthn_request_response', '');
|
||||
if ($authenticatorResponse === '' || ! isset($_SESSION['WebAuthnCredentialRequestOptions'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->provided = true;
|
||||
|
||||
/** @var mixed $credentialRequestOptions */
|
||||
$credentialRequestOptions = $_SESSION['WebAuthnCredentialRequestOptions'];
|
||||
unset($_SESSION['WebAuthnCredentialRequestOptions']);
|
||||
|
||||
try {
|
||||
Assert::stringNotEmpty($authenticatorResponse);
|
||||
Assert::stringNotEmpty($credentialRequestOptions);
|
||||
$requestOptions = json_decode($credentialRequestOptions, true);
|
||||
Assert::isArray($requestOptions);
|
||||
Assert::keyExists($requestOptions, 'challenge');
|
||||
Assert::stringNotEmpty($requestOptions['challenge']);
|
||||
$this->server->parseAndValidateAssertionResponse(
|
||||
$authenticatorResponse,
|
||||
$this->getAllowedCredentials(),
|
||||
$requestOptions['challenge'],
|
||||
$request
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
$this->message = $exception->getMessage();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function setup(): string
|
||||
{
|
||||
$request = $GLOBALS['request'];
|
||||
$userId = sodium_bin2base64(random_bytes(32), SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
$host = $request->getUri()->getHost();
|
||||
$creationOptions = $this->server->getCredentialCreationOptions($this->twofactor->user, $userId, $host);
|
||||
$creationOptionsEncoded = json_encode($creationOptions);
|
||||
$_SESSION['WebAuthnCredentialCreationOptions'] = $creationOptionsEncoded;
|
||||
$this->loadScripts();
|
||||
|
||||
return $this->template->render(
|
||||
'login/twofactor/webauthn_creation',
|
||||
['creation_options' => $creationOptionsEncoded]
|
||||
);
|
||||
}
|
||||
|
||||
public function configure(): bool
|
||||
{
|
||||
$this->provided = false;
|
||||
$request = $GLOBALS['request'];
|
||||
$authenticatorResponse = $request->getParsedBodyParam('webauthn_creation_response', '');
|
||||
if ($authenticatorResponse === '' || ! isset($_SESSION['WebAuthnCredentialCreationOptions'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->provided = true;
|
||||
|
||||
/** @var mixed $credentialCreationOptions */
|
||||
$credentialCreationOptions = $_SESSION['WebAuthnCredentialCreationOptions'];
|
||||
unset($_SESSION['WebAuthnCredentialCreationOptions']);
|
||||
|
||||
try {
|
||||
Assert::stringNotEmpty($authenticatorResponse);
|
||||
Assert::stringNotEmpty($credentialCreationOptions);
|
||||
$credential = $this->server->parseAndValidateAttestationResponse(
|
||||
$authenticatorResponse,
|
||||
$credentialCreationOptions,
|
||||
$request
|
||||
);
|
||||
$this->saveCredential($credential);
|
||||
} catch (Throwable $exception) {
|
||||
$this->message = $exception->getMessage();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function getName(): string
|
||||
{
|
||||
return __('Hardware Security Key (WebAuthn/FIDO2)');
|
||||
}
|
||||
|
||||
public static function getDescription(): string
|
||||
{
|
||||
return __(
|
||||
'Provides authentication using hardware security tokens supporting the WebAuthn/FIDO2 protocol,'
|
||||
. ' such as a YubiKey.'
|
||||
);
|
||||
}
|
||||
|
||||
private function loadScripts(): void
|
||||
{
|
||||
$response = ResponseRenderer::getInstance();
|
||||
$scripts = $response->getHeader()->getScripts();
|
||||
$scripts->addFile('webauthn.js');
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-return list<array{id: non-empty-string, type: non-empty-string}>
|
||||
*/
|
||||
private function getAllowedCredentials(): array
|
||||
{
|
||||
$allowedCredentials = [];
|
||||
/** @psalm-var array<array<string, mixed>> $credentials */
|
||||
$credentials = $this->twofactor->config['settings']['credentials'];
|
||||
foreach ($credentials as $credential) {
|
||||
if (
|
||||
! is_string($credential['publicKeyCredentialId']) || $credential['publicKeyCredentialId'] === ''
|
||||
|| ! is_string($credential['type']) || $credential['type'] === ''
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$allowedCredentials[] = ['type' => $credential['type'], 'id' => $credential['publicKeyCredentialId']];
|
||||
}
|
||||
|
||||
return $allowedCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-param mixed[] $credential
|
||||
*
|
||||
* @throws SodiumException
|
||||
*/
|
||||
private function saveCredential(array $credential): void
|
||||
{
|
||||
Assert::keyExists($credential, 'publicKeyCredentialId');
|
||||
Assert::stringNotEmpty($credential['publicKeyCredentialId']);
|
||||
Assert::keyExists($credential, 'userHandle');
|
||||
Assert::string($credential['userHandle']);
|
||||
Assert::isArray($this->twofactor->config['settings']['credentials']);
|
||||
$id = sodium_bin2base64(
|
||||
sodium_base642bin($credential['publicKeyCredentialId'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING),
|
||||
SODIUM_BASE64_VARIANT_ORIGINAL
|
||||
);
|
||||
$this->twofactor->config['settings']['credentials'][$id] = $credential;
|
||||
$this->twofactor->config['settings']['userHandle'] = $credential['userHandle'];
|
||||
}
|
||||
|
||||
private function getUserHandleFromSettings(): string
|
||||
{
|
||||
Assert::string($this->twofactor->config['settings']['userHandle']);
|
||||
|
||||
return $this->twofactor->config['settings']['userHandle'];
|
||||
}
|
||||
}
|
||||
@ -137,6 +137,8 @@ class TwoFactor
|
||||
$result[] = 'application';
|
||||
}
|
||||
|
||||
$result[] = 'WebAuthn';
|
||||
|
||||
if (class_exists(U2FServer::class)) {
|
||||
$result[] = 'key';
|
||||
}
|
||||
|
||||
289
libraries/classes/WebAuthn/CBORDecoder.php
Normal file
289
libraries/classes/WebAuthn/CBORDecoder.php
Normal file
@ -0,0 +1,289 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpMyAdmin\WebAuthn;
|
||||
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
use function ord;
|
||||
use function unpack;
|
||||
|
||||
use const INF;
|
||||
use const NAN;
|
||||
|
||||
/**
|
||||
* Concise Binary Object Representation (CBOR) decoder.
|
||||
*
|
||||
* This is not a general purpose CBOR decoder and only implements the CTAP2 canonical CBOR encoding form.
|
||||
*
|
||||
* @see https://www.rfc-editor.org/rfc/rfc7049
|
||||
* @see https://fidoalliance.org/specs/fido-v2.0-ps-20190130/fido-client-to-authenticator-protocol-v2.0-ps-20190130.html#message-encoding
|
||||
*/
|
||||
final class CBORDecoder
|
||||
{
|
||||
/**
|
||||
* @return mixed
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
public function decode(DataStream $stream)
|
||||
{
|
||||
return $this->wellFormed($stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://www.rfc-editor.org/rfc/rfc7049#appendix-C
|
||||
*
|
||||
* @return mixed
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function wellFormed(DataStream $stream)
|
||||
{
|
||||
// process initial bytes
|
||||
$initialByte = ord($stream->take(1));
|
||||
$majorType = $initialByte >> 5;
|
||||
$value = $additionalInformation = $initialByte & 0x1f;
|
||||
switch ($additionalInformation) {
|
||||
case 24:
|
||||
if ($majorType !== 7) {
|
||||
$value = ord($stream->take(1));
|
||||
}
|
||||
|
||||
break;
|
||||
case 25:
|
||||
if ($majorType !== 7) {
|
||||
$unpackedValue = unpack('n', $stream->take(2));
|
||||
Assert::isArray($unpackedValue);
|
||||
Assert::keyExists($unpackedValue, 1);
|
||||
Assert::integer($unpackedValue[1]);
|
||||
$value = $unpackedValue[1];
|
||||
}
|
||||
|
||||
break;
|
||||
case 26:
|
||||
if ($majorType !== 7) {
|
||||
$unpackedValue = unpack('N', $stream->take(4));
|
||||
Assert::isArray($unpackedValue);
|
||||
Assert::keyExists($unpackedValue, 1);
|
||||
Assert::integer($unpackedValue[1]);
|
||||
$value = $unpackedValue[1];
|
||||
}
|
||||
|
||||
break;
|
||||
case 27:
|
||||
if ($majorType !== 7) {
|
||||
$unpackedValue = unpack('J', $stream->take(8));
|
||||
Assert::isArray($unpackedValue);
|
||||
Assert::keyExists($unpackedValue, 1);
|
||||
Assert::integer($unpackedValue[1]);
|
||||
$value = $unpackedValue[1];
|
||||
}
|
||||
|
||||
break;
|
||||
case 28:
|
||||
case 29:
|
||||
case 30:
|
||||
case 31:
|
||||
throw new WebAuthnException();
|
||||
}
|
||||
|
||||
// process content
|
||||
switch ($majorType) {
|
||||
case 0:
|
||||
return $this->getUnsignedInteger($value);
|
||||
|
||||
case 1:
|
||||
return $this->getNegativeInteger($value);
|
||||
|
||||
case 2:
|
||||
return $this->getByteString($stream, $value);
|
||||
|
||||
case 3:
|
||||
return $this->getTextString($stream, $value);
|
||||
|
||||
case 4:
|
||||
return $this->getList($stream, $value);
|
||||
|
||||
case 5:
|
||||
return $this->getMap($stream, $value);
|
||||
|
||||
case 6:
|
||||
return $this->getTag($stream);
|
||||
|
||||
case 7:
|
||||
return $this->getFloatNumberOrSimpleValue($stream, $value, $additionalInformation);
|
||||
|
||||
default:
|
||||
throw new WebAuthnException();
|
||||
}
|
||||
}
|
||||
|
||||
private function getUnsignedInteger(int $value): int
|
||||
{
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function getNegativeInteger(int $value): int
|
||||
{
|
||||
return -1 - $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getByteString(DataStream $stream, int $value): string
|
||||
{
|
||||
return $stream->take($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getTextString(DataStream $stream, int $value): string
|
||||
{
|
||||
return $stream->take($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-return list<mixed>
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getList(DataStream $stream, int $value): array
|
||||
{
|
||||
$list = [];
|
||||
for ($i = 0; $i < $value; $i++) {
|
||||
/** @psalm-suppress MixedAssignment */
|
||||
$list[] = $this->wellFormed($stream);
|
||||
}
|
||||
|
||||
return $list;
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-return array<array-key, mixed>
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getMap(DataStream $stream, int $value): array
|
||||
{
|
||||
$map = [];
|
||||
for ($i = 0; $i < $value; $i++) {
|
||||
/** @psalm-suppress MixedAssignment, MixedArrayOffset */
|
||||
$map[$this->wellFormed($stream)] = $this->wellFormed($stream);
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getTag(DataStream $stream)
|
||||
{
|
||||
// 1 embedded data item
|
||||
return $this->wellFormed($stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getFloatNumberOrSimpleValue(DataStream $stream, int $value, int $additionalInformation)
|
||||
{
|
||||
switch ($additionalInformation) {
|
||||
case 20:
|
||||
return true;
|
||||
|
||||
case 21:
|
||||
return false;
|
||||
|
||||
case 22:
|
||||
return null;
|
||||
|
||||
case 24:
|
||||
// simple value
|
||||
return ord($stream->take(1));
|
||||
|
||||
case 25:
|
||||
return $this->getHalfFloat($stream);
|
||||
|
||||
case 26:
|
||||
return $this->getSingleFloat($stream);
|
||||
|
||||
case 27:
|
||||
return $this->getDoubleFloat($stream);
|
||||
|
||||
case 31:
|
||||
// "break" stop code for indefinite-length items
|
||||
throw new WebAuthnException();
|
||||
|
||||
default:
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* IEEE 754 Half-Precision Float (16 bits follow)
|
||||
*
|
||||
* @see https://www.rfc-editor.org/rfc/rfc7049#appendix-D
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getHalfFloat(DataStream $stream): float
|
||||
{
|
||||
$value = unpack('n', $stream->take(2));
|
||||
Assert::isArray($value);
|
||||
Assert::keyExists($value, 1);
|
||||
Assert::integer($value[1]);
|
||||
|
||||
$half = $value[1];
|
||||
$exp = ($half >> 10) & 0x1f;
|
||||
$mant = $half & 0x3ff;
|
||||
|
||||
if ($exp === 0) {
|
||||
$val = $mant * (2 ** -24);
|
||||
} elseif ($exp !== 31) {
|
||||
$val = ($mant + 1024) * (2 ** ($exp - 25));
|
||||
} else {
|
||||
$val = $mant === 0 ? INF : NAN;
|
||||
}
|
||||
|
||||
return $half & 0x8000 ? -$val : $val;
|
||||
}
|
||||
|
||||
/**
|
||||
* IEEE 754 Single-Precision Float (32 bits follow)
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getSingleFloat(DataStream $stream): float
|
||||
{
|
||||
$value = unpack('G', $stream->take(4));
|
||||
Assert::isArray($value);
|
||||
Assert::keyExists($value, 1);
|
||||
Assert::float($value[1]);
|
||||
|
||||
return $value[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* IEEE 754 Double-Precision Float (64 bits follow)
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getDoubleFloat(DataStream $stream): float
|
||||
{
|
||||
$value = unpack('E', $stream->take(8));
|
||||
Assert::isArray($value);
|
||||
Assert::keyExists($value, 1);
|
||||
Assert::float($value[1]);
|
||||
|
||||
return $value[1];
|
||||
}
|
||||
}
|
||||
497
libraries/classes/WebAuthn/CustomServer.php
Normal file
497
libraries/classes/WebAuthn/CustomServer.php
Normal file
@ -0,0 +1,497 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpMyAdmin\WebAuthn;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use SodiumException;
|
||||
use Throwable;
|
||||
use Webmozart\Assert\Assert;
|
||||
use Webmozart\Assert\InvalidArgumentException;
|
||||
|
||||
use function hash;
|
||||
use function hash_equals;
|
||||
use function json_decode;
|
||||
use function mb_strlen;
|
||||
use function mb_substr;
|
||||
use function ord;
|
||||
use function parse_url;
|
||||
use function random_bytes;
|
||||
use function sodium_base642bin;
|
||||
use function sodium_bin2base64;
|
||||
use function unpack;
|
||||
|
||||
use const PHP_URL_HOST;
|
||||
use const SODIUM_BASE64_VARIANT_ORIGINAL;
|
||||
use const SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING;
|
||||
|
||||
/**
|
||||
* Web Authentication API server.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-3/
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API
|
||||
* @see https://webauthn.guide/
|
||||
*/
|
||||
final class CustomServer implements Server
|
||||
{
|
||||
public function getCredentialCreationOptions(string $userName, string $userId, string $relyingPartyId): array
|
||||
{
|
||||
return [
|
||||
'challenge' => $this->generateChallenge(),
|
||||
'rp' => ['name' => 'phpMyAdmin (' . $relyingPartyId . ')', 'id' => $relyingPartyId],
|
||||
'user' => ['id' => $userId, 'name' => $userName, 'displayName' => $userName],
|
||||
'pubKeyCredParams' => $this->getCredentialParameters(),
|
||||
'authenticatorSelection' => [
|
||||
'authenticatorAttachment' => 'cross-platform',
|
||||
'userVerification' => 'discouraged',
|
||||
],
|
||||
'timeout' => 60000,
|
||||
'attestation' => 'none',
|
||||
];
|
||||
}
|
||||
|
||||
public function getCredentialRequestOptions(
|
||||
string $userName,
|
||||
string $userId,
|
||||
string $relyingPartyId,
|
||||
array $allowedCredentials
|
||||
): array {
|
||||
foreach ($allowedCredentials as $key => $credential) {
|
||||
$allowedCredentials[$key]['id'] = sodium_bin2base64(
|
||||
sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING),
|
||||
SODIUM_BASE64_VARIANT_ORIGINAL
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'challenge' => $this->generateChallenge(),
|
||||
'allowCredentials' => $allowedCredentials,
|
||||
'timeout' => 60000,
|
||||
'attestation' => 'none',
|
||||
'userVerification' => 'discouraged',
|
||||
];
|
||||
}
|
||||
|
||||
public function parseAndValidateAssertionResponse(
|
||||
string $assertionResponseJson,
|
||||
array $allowedCredentials,
|
||||
string $challenge,
|
||||
ServerRequestInterface $request
|
||||
): void {
|
||||
$assertionCredential = $this->getAssertionCredential($assertionResponseJson);
|
||||
|
||||
if ($allowedCredentials !== []) {
|
||||
Assert::true($this->isCredentialIdAllowed($assertionCredential['rawId'], $allowedCredentials));
|
||||
}
|
||||
|
||||
$authenticatorData = $this->getAuthenticatorData($assertionCredential['response']['authenticatorData']);
|
||||
|
||||
$clientData = $this->getCollectedClientData($assertionCredential['response']['clientDataJSON']);
|
||||
Assert::same($clientData['type'], 'webauthn.get');
|
||||
|
||||
try {
|
||||
$knownChallenge = sodium_base642bin($challenge, SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
$cDataChallenge = sodium_base642bin($clientData['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
|
||||
} catch (SodiumException $exception) {
|
||||
throw new WebAuthnException((string) $exception);
|
||||
}
|
||||
|
||||
Assert::true(hash_equals($knownChallenge, $cDataChallenge));
|
||||
|
||||
$host = $request->getUri()->getHost();
|
||||
Assert::same($host, parse_url($clientData['origin'], PHP_URL_HOST));
|
||||
|
||||
$rpIdHash = hash('sha256', $host, true);
|
||||
Assert::true(hash_equals($rpIdHash, $authenticatorData['rpIdHash']));
|
||||
|
||||
$isUserPresent = (ord($authenticatorData['flags']) & 1) !== 0;
|
||||
Assert::true($isUserPresent);
|
||||
}
|
||||
|
||||
public function parseAndValidateAttestationResponse(
|
||||
string $attestationResponse,
|
||||
string $credentialCreationOptions,
|
||||
ServerRequestInterface $request
|
||||
): array {
|
||||
try {
|
||||
$attestationCredential = $this->getAttestationCredential($attestationResponse);
|
||||
} catch (Throwable $exception) {
|
||||
throw new WebAuthnException('Invalid authenticator response.');
|
||||
}
|
||||
|
||||
$creationOptions = json_decode($credentialCreationOptions, true);
|
||||
Assert::isArray($creationOptions);
|
||||
Assert::keyExists($creationOptions, 'challenge');
|
||||
Assert::string($creationOptions['challenge']);
|
||||
Assert::keyExists($creationOptions, 'user');
|
||||
Assert::isArray($creationOptions['user']);
|
||||
Assert::keyExists($creationOptions['user'], 'id');
|
||||
Assert::string($creationOptions['user']['id']);
|
||||
|
||||
$clientData = $this->getCollectedClientData($attestationCredential['response']['clientDataJSON']);
|
||||
|
||||
// Verify that the value of C.type is webauthn.create.
|
||||
Assert::same($clientData['type'], 'webauthn.create');
|
||||
|
||||
// Verify that the value of C.challenge equals the base64url encoding of options.challenge.
|
||||
$optionsChallenge = sodium_base642bin($creationOptions['challenge'], SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
$clientDataChallenge = sodium_base642bin($clientData['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
|
||||
Assert::true(hash_equals($optionsChallenge, $clientDataChallenge));
|
||||
|
||||
// Verify that the value of C.origin matches the Relying Party's origin.
|
||||
$host = $request->getUri()->getHost();
|
||||
Assert::same($host, parse_url($clientData['origin'], PHP_URL_HOST), 'Invalid origin.');
|
||||
|
||||
// Perform CBOR decoding on the attestationObject field.
|
||||
$attestationObject = $this->getAttestationObject($attestationCredential['response']['attestationObject']);
|
||||
|
||||
$authenticatorData = $this->getAuthenticatorData($attestationObject['authData']);
|
||||
Assert::notNull($authenticatorData['attestedCredentialData']);
|
||||
|
||||
// Verify that the rpIdHash in authData is the SHA-256 hash of the RP ID expected by the Relying Party.
|
||||
$rpIdHash = hash('sha256', $host, true);
|
||||
Assert::true(hash_equals($rpIdHash, $authenticatorData['rpIdHash']), 'Invalid rpIdHash.');
|
||||
|
||||
// Verify that the User Present bit of the flags in authData is set.
|
||||
$isUserPresent = (ord($authenticatorData['flags']) & 1) !== 0;
|
||||
Assert::true($isUserPresent);
|
||||
|
||||
Assert::same($attestationObject['fmt'], 'none');
|
||||
Assert::same($attestationObject['attStmt'], []);
|
||||
|
||||
$encodedCredentialId = sodium_bin2base64(
|
||||
$authenticatorData['attestedCredentialData']['credentialId'],
|
||||
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
|
||||
);
|
||||
$encodedCredentialPublicKey = sodium_bin2base64(
|
||||
$authenticatorData['attestedCredentialData']['credentialPublicKey'],
|
||||
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
|
||||
);
|
||||
$userHandle = sodium_bin2base64(
|
||||
sodium_base642bin($creationOptions['user']['id'], SODIUM_BASE64_VARIANT_ORIGINAL),
|
||||
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
|
||||
);
|
||||
|
||||
return [
|
||||
'publicKeyCredentialId' => $encodedCredentialId,
|
||||
'type' => 'public-key',
|
||||
'transports' => [],
|
||||
'attestationType' => $attestationObject['fmt'],
|
||||
'aaguid' => $authenticatorData['attestedCredentialData']['aaguid'],
|
||||
'credentialPublicKey' => $encodedCredentialPublicKey,
|
||||
'userHandle' => $userHandle,
|
||||
'counter' => $authenticatorData['signCount'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* In order to prevent replay attacks, the challenges MUST contain enough entropy to make guessing them infeasible.
|
||||
* Challenges SHOULD therefore be at least 16 bytes long.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-3/#sctn-cryptographic-challenges
|
||||
*
|
||||
* @psalm-return non-empty-string
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function generateChallenge(): string
|
||||
{
|
||||
try {
|
||||
return sodium_bin2base64(random_bytes(32), SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
} catch (Throwable $throwable) { // @codeCoverageIgnore
|
||||
throw new WebAuthnException('Error when generating challenge.'); // @codeCoverageIgnore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://www.w3.org/TR/webauthn-3/#sctn-authenticator-data
|
||||
*
|
||||
* @psalm-return array{
|
||||
* rpIdHash: string,
|
||||
* flags: string,
|
||||
* signCount: int,
|
||||
* attestedCredentialData: array{
|
||||
* aaguid: string,
|
||||
* credentialId: string,
|
||||
* credentialPublicKey: string,
|
||||
* credentialPublicKeyDecoded: mixed[]
|
||||
* }|null,
|
||||
* extensions: string|null
|
||||
* }
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getAuthenticatorData(string $authData): array
|
||||
{
|
||||
$authDataLength = mb_strlen($authData, '8bit');
|
||||
Assert::true($authDataLength >= 37);
|
||||
$authDataStream = new DataStream($authData);
|
||||
|
||||
$rpIdHash = $authDataStream->take(32);
|
||||
$flags = $authDataStream->take(1);
|
||||
|
||||
// 32-bit unsigned big-endian integer
|
||||
$unpackedSignCount = unpack('N', $authDataStream->take(4));
|
||||
Assert::isArray($unpackedSignCount);
|
||||
Assert::keyExists($unpackedSignCount, 1);
|
||||
Assert::integer($unpackedSignCount[1]);
|
||||
$signCount = $unpackedSignCount[1];
|
||||
|
||||
$attestedCredentialData = null;
|
||||
// Bit 6: Attested credential data included (AT).
|
||||
if ((ord($flags) & 64) !== 0) {
|
||||
/** Authenticator Attestation GUID */
|
||||
$aaguid = $authDataStream->take(16);
|
||||
|
||||
// 16-bit unsigned big-endian integer
|
||||
$unpackedCredentialIdLength = unpack('n', $authDataStream->take(2));
|
||||
Assert::isArray($unpackedCredentialIdLength);
|
||||
Assert::keyExists($unpackedCredentialIdLength, 1);
|
||||
Assert::integer($unpackedCredentialIdLength[1]);
|
||||
$credentialIdLength = $unpackedCredentialIdLength[1];
|
||||
|
||||
$credentialId = $authDataStream->take($credentialIdLength);
|
||||
|
||||
$credentialPublicKeyDecoded = (new CBORDecoder())->decode($authDataStream);
|
||||
Assert::isArray($credentialPublicKeyDecoded);
|
||||
$credentialPublicKey = mb_substr(
|
||||
$authData,
|
||||
37 + 18 + $credentialIdLength,
|
||||
$authDataStream->getPosition(),
|
||||
'8bit'
|
||||
);
|
||||
|
||||
$attestedCredentialData = [
|
||||
'aaguid' => $aaguid,
|
||||
'credentialId' => $credentialId,
|
||||
'credentialPublicKey' => $credentialPublicKey,
|
||||
'credentialPublicKeyDecoded' => $credentialPublicKeyDecoded,
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'rpIdHash' => $rpIdHash,
|
||||
'flags' => $flags,
|
||||
'signCount' => $signCount,
|
||||
'attestedCredentialData' => $attestedCredentialData,
|
||||
'extensions' => null,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-param non-empty-string $id
|
||||
* @psalm-param list<array{id: non-empty-string, type: non-empty-string}> $allowedCredentials
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function isCredentialIdAllowed(string $id, array $allowedCredentials): bool
|
||||
{
|
||||
foreach ($allowedCredentials as $credential) {
|
||||
try {
|
||||
$credentialId = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
|
||||
} catch (SodiumException $exception) {
|
||||
throw new WebAuthnException();
|
||||
}
|
||||
|
||||
if (hash_equals($credentialId, $id)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://www.iana.org/assignments/cose/cose.xhtml#algorithms
|
||||
*
|
||||
* @psalm-return list<array{alg: int, type: 'public-key'}>
|
||||
*/
|
||||
private function getCredentialParameters(): array
|
||||
{
|
||||
return [
|
||||
['alg' => -257, 'type' => 'public-key'], // RS256
|
||||
['alg' => -259, 'type' => 'public-key'], // RS512
|
||||
['alg' => -37, 'type' => 'public-key'], // PS256
|
||||
['alg' => -39, 'type' => 'public-key'], // PS512
|
||||
['alg' => -7, 'type' => 'public-key'], // ES256
|
||||
['alg' => -36, 'type' => 'public-key'], // ES512
|
||||
['alg' => -8, 'type' => 'public-key'], // EdDSA
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-param non-empty-string $assertionResponseJson
|
||||
*
|
||||
* @psalm-return array{
|
||||
* id: non-empty-string,
|
||||
* type: 'public-key',
|
||||
* rawId: non-empty-string,
|
||||
* response: array{
|
||||
* clientDataJSON: non-empty-string,
|
||||
* authenticatorData: non-empty-string,
|
||||
* signature: non-empty-string,
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* @throws SodiumException
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function getAssertionCredential(string $assertionResponseJson): array
|
||||
{
|
||||
$credential = json_decode($assertionResponseJson, true);
|
||||
Assert::isArray($credential);
|
||||
Assert::keyExists($credential, 'id');
|
||||
Assert::stringNotEmpty($credential['id']);
|
||||
Assert::keyExists($credential, 'type');
|
||||
Assert::same($credential['type'], 'public-key');
|
||||
Assert::keyExists($credential, 'rawId');
|
||||
Assert::stringNotEmpty($credential['rawId']);
|
||||
Assert::keyExists($credential, 'response');
|
||||
Assert::isArray($credential['response']);
|
||||
Assert::keyExists($credential['response'], 'clientDataJSON');
|
||||
Assert::stringNotEmpty($credential['response']['clientDataJSON']);
|
||||
Assert::keyExists($credential['response'], 'authenticatorData');
|
||||
Assert::stringNotEmpty($credential['response']['authenticatorData']);
|
||||
Assert::keyExists($credential['response'], 'signature');
|
||||
Assert::stringNotEmpty($credential['response']['signature']);
|
||||
|
||||
$id = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
|
||||
$rawId = sodium_base642bin($credential['rawId'], SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
Assert::stringNotEmpty($id);
|
||||
Assert::stringNotEmpty($rawId);
|
||||
Assert::true(hash_equals($rawId, $id));
|
||||
|
||||
$clientDataJSON = sodium_base642bin($credential['response']['clientDataJSON'], SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
Assert::stringNotEmpty($clientDataJSON);
|
||||
$authenticatorData = sodium_base642bin(
|
||||
$credential['response']['authenticatorData'],
|
||||
SODIUM_BASE64_VARIANT_ORIGINAL
|
||||
);
|
||||
Assert::stringNotEmpty($authenticatorData);
|
||||
$signature = sodium_base642bin($credential['response']['signature'], SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
Assert::stringNotEmpty($signature);
|
||||
|
||||
return [
|
||||
'id' => $credential['id'],
|
||||
'type' => 'public-key',
|
||||
'rawId' => $rawId,
|
||||
'response' => [
|
||||
'clientDataJSON' => $clientDataJSON,
|
||||
'authenticatorData' => $authenticatorData,
|
||||
'signature' => $signature,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://www.w3.org/TR/webauthn-3/#iface-authenticatorattestationresponse
|
||||
*
|
||||
* @psalm-param non-empty-string $attestationResponse
|
||||
*
|
||||
* @psalm-return array{
|
||||
* id: non-empty-string,
|
||||
* rawId: non-empty-string,
|
||||
* type: 'public-key',
|
||||
* response: array{clientDataJSON: non-empty-string, attestationObject: non-empty-string}
|
||||
* }
|
||||
*
|
||||
* @throws SodiumException
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
private function getAttestationCredential(string $attestationResponse): array
|
||||
{
|
||||
$credential = json_decode($attestationResponse, true);
|
||||
Assert::isArray($credential);
|
||||
Assert::keyExists($credential, 'id');
|
||||
Assert::stringNotEmpty($credential['id']);
|
||||
Assert::keyExists($credential, 'rawId');
|
||||
Assert::stringNotEmpty($credential['rawId']);
|
||||
Assert::keyExists($credential, 'type');
|
||||
Assert::string($credential['type']);
|
||||
Assert::same($credential['type'], 'public-key');
|
||||
Assert::keyExists($credential, 'response');
|
||||
Assert::isArray($credential['response']);
|
||||
Assert::keyExists($credential['response'], 'clientDataJSON');
|
||||
Assert::stringNotEmpty($credential['response']['clientDataJSON']);
|
||||
Assert::keyExists($credential['response'], 'attestationObject');
|
||||
Assert::stringNotEmpty($credential['response']['attestationObject']);
|
||||
|
||||
$id = sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING);
|
||||
$rawId = sodium_base642bin($credential['rawId'], SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
Assert::stringNotEmpty($id);
|
||||
Assert::stringNotEmpty($rawId);
|
||||
Assert::true(hash_equals($rawId, $id));
|
||||
|
||||
$clientDataJSON = sodium_base642bin($credential['response']['clientDataJSON'], SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||
Assert::stringNotEmpty($clientDataJSON);
|
||||
$attestationObject = sodium_base642bin(
|
||||
$credential['response']['attestationObject'],
|
||||
SODIUM_BASE64_VARIANT_ORIGINAL
|
||||
);
|
||||
Assert::stringNotEmpty($attestationObject);
|
||||
|
||||
return [
|
||||
'id' => $credential['id'],
|
||||
'rawId' => $rawId,
|
||||
'type' => 'public-key',
|
||||
'response' => [
|
||||
'clientDataJSON' => $clientDataJSON,
|
||||
'attestationObject' => $attestationObject,
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://www.w3.org/TR/webauthn-3/#dictionary-client-data
|
||||
*
|
||||
* @psalm-param non-empty-string $clientDataJSON
|
||||
*
|
||||
* @return array{
|
||||
* type: 'webauthn.create'|'webauthn.get',
|
||||
* challenge: non-empty-string,
|
||||
* origin: non-empty-string
|
||||
* }
|
||||
*/
|
||||
private function getCollectedClientData(string $clientDataJSON): array
|
||||
{
|
||||
$clientData = json_decode($clientDataJSON, true);
|
||||
|
||||
Assert::isArray($clientData);
|
||||
Assert::keyExists($clientData, 'type');
|
||||
Assert::stringNotEmpty($clientData['type']);
|
||||
Assert::inArray($clientData['type'], ['webauthn.create', 'webauthn.get']);
|
||||
Assert::keyExists($clientData, 'challenge');
|
||||
Assert::stringNotEmpty($clientData['challenge']);
|
||||
Assert::keyExists($clientData, 'origin');
|
||||
Assert::stringNotEmpty($clientData['origin']);
|
||||
|
||||
return [
|
||||
'type' => $clientData['type'],
|
||||
'challenge' => $clientData['challenge'],
|
||||
'origin' => $clientData['origin'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-param non-empty-string $attestationObjectEncoded
|
||||
*
|
||||
* @psalm-return array{fmt: string, attStmt: mixed[], authData: string}
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
private function getAttestationObject(string $attestationObjectEncoded): array
|
||||
{
|
||||
$decoded = (new CBORDecoder())->decode(new DataStream($attestationObjectEncoded));
|
||||
|
||||
Assert::isArray($decoded);
|
||||
Assert::keyExists($decoded, 'fmt');
|
||||
Assert::string($decoded['fmt']);
|
||||
Assert::keyExists($decoded, 'attStmt');
|
||||
Assert::isArray($decoded['attStmt']);
|
||||
Assert::keyExists($decoded, 'authData');
|
||||
Assert::string($decoded['authData']);
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
}
|
||||
68
libraries/classes/WebAuthn/DataStream.php
Normal file
68
libraries/classes/WebAuthn/DataStream.php
Normal file
@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpMyAdmin\WebAuthn;
|
||||
|
||||
use function fopen;
|
||||
use function fread;
|
||||
use function ftell;
|
||||
use function fwrite;
|
||||
use function rewind;
|
||||
|
||||
final class DataStream
|
||||
{
|
||||
/** @var resource */
|
||||
private $stream;
|
||||
|
||||
/**
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
public function __construct(string $binaryString)
|
||||
{
|
||||
$resource = fopen('php://memory', 'rb+');
|
||||
if ($resource === false || fwrite($resource, $binaryString) === false) {
|
||||
throw new WebAuthnException();
|
||||
}
|
||||
|
||||
if (! rewind($resource)) {
|
||||
throw new WebAuthnException();
|
||||
}
|
||||
|
||||
$this->stream = $resource;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
public function take(int $length): string
|
||||
{
|
||||
if ($length < 0) {
|
||||
throw new WebAuthnException();
|
||||
}
|
||||
|
||||
if ($length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$string = fread($this->stream, $length);
|
||||
if ($string === false) {
|
||||
throw new WebAuthnException();
|
||||
}
|
||||
|
||||
return $string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
public function getPosition(): int
|
||||
{
|
||||
$position = ftell($this->stream);
|
||||
if ($position === false) {
|
||||
throw new WebAuthnException();
|
||||
}
|
||||
|
||||
return $position;
|
||||
}
|
||||
}
|
||||
78
libraries/classes/WebAuthn/Server.php
Normal file
78
libraries/classes/WebAuthn/Server.php
Normal file
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpMyAdmin\WebAuthn;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* Web Authentication API server.
|
||||
*
|
||||
* @see https://www.w3.org/TR/webauthn-3/
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API
|
||||
* @see https://webauthn.guide/
|
||||
*/
|
||||
interface Server
|
||||
{
|
||||
/**
|
||||
* @psalm-return array{
|
||||
* challenge: non-empty-string,
|
||||
* rp: array{name: string, id: string},
|
||||
* user: array{id: string, name: string, displayName: string},
|
||||
* pubKeyCredParams: list<array{alg: int, type: 'public-key'}>,
|
||||
* authenticatorSelection: array<string, string>,
|
||||
* timeout: positive-int,
|
||||
* attestation: non-empty-string
|
||||
* }
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
public function getCredentialCreationOptions(string $userName, string $userId, string $relyingPartyId): array;
|
||||
|
||||
/**
|
||||
* @psalm-param list<array{id: non-empty-string, type: non-empty-string}> $allowedCredentials
|
||||
*
|
||||
* @return array<string, array<int, array<string, string>>|int|string>
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
public function getCredentialRequestOptions(
|
||||
string $userName,
|
||||
string $userId,
|
||||
string $relyingPartyId,
|
||||
array $allowedCredentials
|
||||
): array;
|
||||
|
||||
/**
|
||||
* @see https://www.w3.org/TR/webauthn-3/#sctn-verifying-assertion
|
||||
*
|
||||
* @psalm-param non-empty-string $assertionResponseJson
|
||||
* @psalm-param list<array{id: non-empty-string, type: non-empty-string}> $allowedCredentials
|
||||
* @psalm-param non-empty-string $challenge
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
public function parseAndValidateAssertionResponse(
|
||||
string $assertionResponseJson,
|
||||
array $allowedCredentials,
|
||||
string $challenge,
|
||||
ServerRequestInterface $request
|
||||
): void;
|
||||
|
||||
/**
|
||||
* @see https://www.w3.org/TR/webauthn-3/#sctn-registering-a-new-credential
|
||||
*
|
||||
* @psalm-param non-empty-string $attestationResponse
|
||||
* @psalm-param non-empty-string $credentialCreationOptions
|
||||
*
|
||||
* @return mixed[]
|
||||
*
|
||||
* @throws WebAuthnException
|
||||
*/
|
||||
public function parseAndValidateAttestationResponse(
|
||||
string $attestationResponse,
|
||||
string $credentialCreationOptions,
|
||||
ServerRequestInterface $request
|
||||
): array;
|
||||
}
|
||||
11
libraries/classes/WebAuthn/WebAuthnException.php
Normal file
11
libraries/classes/WebAuthn/WebAuthnException.php
Normal file
@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpMyAdmin\WebAuthn;
|
||||
|
||||
use Exception;
|
||||
|
||||
class WebAuthnException extends Exception
|
||||
{
|
||||
}
|
||||
277
libraries/classes/WebAuthn/WebauthnLibServer.php
Normal file
277
libraries/classes/WebAuthn/WebauthnLibServer.php
Normal file
@ -0,0 +1,277 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpMyAdmin\WebAuthn;
|
||||
|
||||
use PhpMyAdmin\TwoFactor;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Webauthn\AuthenticatorSelectionCriteria;
|
||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||
use Webauthn\PublicKeyCredentialDescriptor;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
use Webauthn\PublicKeyCredentialRpEntity;
|
||||
use Webauthn\PublicKeyCredentialSource;
|
||||
use Webauthn\PublicKeyCredentialSourceRepository;
|
||||
use Webauthn\PublicKeyCredentialUserEntity;
|
||||
use Webauthn\Server as WebauthnServer;
|
||||
use Webauthn\TrustPath\EmptyTrustPath;
|
||||
use Webmozart\Assert\Assert;
|
||||
|
||||
use function array_map;
|
||||
use function base64_encode;
|
||||
use function json_decode;
|
||||
use function sodium_base642bin;
|
||||
use function sodium_bin2base64;
|
||||
|
||||
use const SODIUM_BASE64_VARIANT_ORIGINAL;
|
||||
use const SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING;
|
||||
|
||||
final class WebauthnLibServer implements Server
|
||||
{
|
||||
/** @var TwoFactor */
|
||||
private $twofactor;
|
||||
|
||||
public function __construct(TwoFactor $twofactor)
|
||||
{
|
||||
$this->twofactor = $twofactor;
|
||||
}
|
||||
|
||||
public function getCredentialCreationOptions(string $userName, string $userId, string $relyingPartyId): array
|
||||
{
|
||||
$userEntity = new PublicKeyCredentialUserEntity($userName, $userId, $userName);
|
||||
$relyingPartyEntity = new PublicKeyCredentialRpEntity('phpMyAdmin (' . $relyingPartyId . ')', $relyingPartyId);
|
||||
$publicKeyCredentialSourceRepository = $this->createPublicKeyCredentialSourceRepository();
|
||||
$server = new WebauthnServer($relyingPartyEntity, $publicKeyCredentialSourceRepository);
|
||||
$publicKeyCredentialCreationOptions = $server->generatePublicKeyCredentialCreationOptions(
|
||||
$userEntity,
|
||||
PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE,
|
||||
[],
|
||||
AuthenticatorSelectionCriteria::createFromArray([
|
||||
'authenticatorAttachment' => 'cross-platform',
|
||||
'userVerification' => 'discouraged',
|
||||
])
|
||||
);
|
||||
/** @psalm-var array{
|
||||
* challenge: non-empty-string,
|
||||
* rp: array{name: non-empty-string, id: non-empty-string},
|
||||
* user: array{id: non-empty-string, name: non-empty-string, displayName: non-empty-string},
|
||||
* pubKeyCredParams: list<array{alg: int, type: 'public-key'}>,
|
||||
* authenticatorSelection: array<string, string>,
|
||||
* timeout: positive-int,
|
||||
* attestation: non-empty-string
|
||||
* } $creationOptions */
|
||||
$creationOptions = $publicKeyCredentialCreationOptions->jsonSerialize();
|
||||
$creationOptions['challenge'] = sodium_bin2base64(
|
||||
sodium_base642bin($creationOptions['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING),
|
||||
SODIUM_BASE64_VARIANT_ORIGINAL
|
||||
);
|
||||
Assert::stringNotEmpty($creationOptions['challenge']);
|
||||
|
||||
return $creationOptions;
|
||||
}
|
||||
|
||||
public function getCredentialRequestOptions(
|
||||
string $userName,
|
||||
string $userId,
|
||||
string $relyingPartyId,
|
||||
array $allowedCredentials
|
||||
): array {
|
||||
$userEntity = new PublicKeyCredentialUserEntity($userName, $userId, $userName);
|
||||
$relyingPartyEntity = new PublicKeyCredentialRpEntity('phpMyAdmin (' . $relyingPartyId . ')', $relyingPartyId);
|
||||
$publicKeyCredentialSourceRepository = $this->createPublicKeyCredentialSourceRepository();
|
||||
$server = new WebauthnServer($relyingPartyEntity, $publicKeyCredentialSourceRepository);
|
||||
$credentialSources = $publicKeyCredentialSourceRepository->findAllForUserEntity($userEntity);
|
||||
$allowedCredentials = array_map(
|
||||
static function (PublicKeyCredentialSource $credential): PublicKeyCredentialDescriptor {
|
||||
return $credential->getPublicKeyCredentialDescriptor();
|
||||
},
|
||||
$credentialSources
|
||||
);
|
||||
$publicKeyCredentialRequestOptions = $server->generatePublicKeyCredentialRequestOptions(
|
||||
'discouraged',
|
||||
$allowedCredentials
|
||||
);
|
||||
/**
|
||||
* @psalm-var array{
|
||||
* challenge: string,
|
||||
* allowCredentials?: list<array{id: non-empty-string, type: non-empty-string}>
|
||||
* } $requestOptions
|
||||
*/
|
||||
$requestOptions = $publicKeyCredentialRequestOptions->jsonSerialize();
|
||||
$requestOptions['challenge'] = sodium_bin2base64(
|
||||
sodium_base642bin($requestOptions['challenge'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING),
|
||||
SODIUM_BASE64_VARIANT_ORIGINAL
|
||||
);
|
||||
if (isset($requestOptions['allowCredentials'])) {
|
||||
foreach ($requestOptions['allowCredentials'] as $key => $credential) {
|
||||
$requestOptions['allowCredentials'][$key]['id'] = sodium_bin2base64(
|
||||
sodium_base642bin($credential['id'], SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING),
|
||||
SODIUM_BASE64_VARIANT_ORIGINAL
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return $requestOptions;
|
||||
}
|
||||
|
||||
public function parseAndValidateAssertionResponse(
|
||||
string $assertionResponseJson,
|
||||
array $allowedCredentials,
|
||||
string $challenge,
|
||||
ServerRequestInterface $request
|
||||
): void {
|
||||
Assert::string($this->twofactor->config['settings']['userHandle']);
|
||||
$userHandle = sodium_base642bin(
|
||||
$this->twofactor->config['settings']['userHandle'],
|
||||
SODIUM_BASE64_VARIANT_URLSAFE_NO_PADDING
|
||||
);
|
||||
$userEntity = new PublicKeyCredentialUserEntity(
|
||||
$this->twofactor->user,
|
||||
$userHandle,
|
||||
$this->twofactor->user
|
||||
);
|
||||
$host = $request->getUri()->getHost();
|
||||
$relyingPartyEntity = new PublicKeyCredentialRpEntity('phpMyAdmin (' . $host . ')', $host);
|
||||
$publicKeyCredentialSourceRepository = $this->createPublicKeyCredentialSourceRepository();
|
||||
$server = new WebauthnServer($relyingPartyEntity, $publicKeyCredentialSourceRepository);
|
||||
$requestOptions = PublicKeyCredentialRequestOptions::createFromArray([
|
||||
'challenge' => $challenge,
|
||||
'allowCredentials' => $allowedCredentials,
|
||||
'rpId' => $host,
|
||||
'timeout' => 60000,
|
||||
]);
|
||||
Assert::isInstanceOf($requestOptions, PublicKeyCredentialRequestOptions::class);
|
||||
$server->loadAndCheckAssertionResponse(
|
||||
$assertionResponseJson,
|
||||
$requestOptions,
|
||||
$userEntity,
|
||||
$request
|
||||
);
|
||||
}
|
||||
|
||||
public function parseAndValidateAttestationResponse(
|
||||
string $attestationResponse,
|
||||
string $credentialCreationOptions,
|
||||
ServerRequestInterface $request
|
||||
): array {
|
||||
$creationOptions = json_decode($credentialCreationOptions, true);
|
||||
Assert::isArray($creationOptions);
|
||||
Assert::keyExists($creationOptions, 'challenge');
|
||||
Assert::keyExists($creationOptions, 'user');
|
||||
Assert::isArray($creationOptions['user']);
|
||||
Assert::keyExists($creationOptions['user'], 'id');
|
||||
$host = $request->getUri()->getHost();
|
||||
$relyingPartyEntity = new PublicKeyCredentialRpEntity('phpMyAdmin (' . $host . ')', $host);
|
||||
$publicKeyCredentialSourceRepository = $this->createPublicKeyCredentialSourceRepository();
|
||||
$server = new WebauthnServer($relyingPartyEntity, $publicKeyCredentialSourceRepository);
|
||||
$creationOptionsArray = [
|
||||
'rp' => ['name' => 'phpMyAdmin (' . $host . ')', 'id' => $host],
|
||||
'pubKeyCredParams' => [
|
||||
['alg' => -257, 'type' => 'public-key'], // RS256
|
||||
['alg' => -259, 'type' => 'public-key'], // RS512
|
||||
['alg' => -37, 'type' => 'public-key'], // PS256
|
||||
['alg' => -39, 'type' => 'public-key'], // PS512
|
||||
['alg' => -7, 'type' => 'public-key'], // ES256
|
||||
['alg' => -36, 'type' => 'public-key'], // ES512
|
||||
['alg' => -8, 'type' => 'public-key'], // EdDSA
|
||||
],
|
||||
'challenge' => $creationOptions['challenge'],
|
||||
'attestation' => 'none',
|
||||
'user' => [
|
||||
'name' => $this->twofactor->user,
|
||||
'id' => $creationOptions['user']['id'],
|
||||
'displayName' => $this->twofactor->user,
|
||||
],
|
||||
'authenticatorSelection' => [
|
||||
'authenticatorAttachment' => 'cross-platform',
|
||||
'userVerification' => 'discouraged',
|
||||
],
|
||||
'timeout' => 60000,
|
||||
];
|
||||
$credentialCreationOptions = PublicKeyCredentialCreationOptions::createFromArray($creationOptionsArray);
|
||||
Assert::isInstanceOf($credentialCreationOptions, PublicKeyCredentialCreationOptions::class);
|
||||
$publicKeyCredentialSource = $server->loadAndCheckAttestationResponse(
|
||||
$attestationResponse,
|
||||
$credentialCreationOptions,
|
||||
$request
|
||||
);
|
||||
|
||||
return $publicKeyCredentialSource->jsonSerialize();
|
||||
}
|
||||
|
||||
private function createPublicKeyCredentialSourceRepository(): PublicKeyCredentialSourceRepository
|
||||
{
|
||||
return new class ($this->twofactor) implements PublicKeyCredentialSourceRepository {
|
||||
/** @var TwoFactor */
|
||||
private $twoFactor;
|
||||
|
||||
public function __construct(TwoFactor $twoFactor)
|
||||
{
|
||||
$this->twoFactor = $twoFactor;
|
||||
}
|
||||
|
||||
public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
|
||||
{
|
||||
$data = $this->read();
|
||||
if (isset($data[base64_encode($publicKeyCredentialId)])) {
|
||||
return PublicKeyCredentialSource::createFromArray($data[base64_encode($publicKeyCredentialId)]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return PublicKeyCredentialSource[]
|
||||
*/
|
||||
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
|
||||
{
|
||||
$sources = [];
|
||||
foreach ($this->read() as $data) {
|
||||
$source = PublicKeyCredentialSource::createFromArray($data);
|
||||
if ($source->getUserHandle() !== $publicKeyCredentialUserEntity->getId()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$sources[] = $source;
|
||||
}
|
||||
|
||||
return $sources;
|
||||
}
|
||||
|
||||
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
|
||||
{
|
||||
$data = $this->read();
|
||||
$id = $publicKeyCredentialSource->getPublicKeyCredentialId();
|
||||
$data[base64_encode($id)] = $publicKeyCredentialSource->jsonSerialize();
|
||||
$this->write($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return mixed[][]
|
||||
*/
|
||||
private function read(): array
|
||||
{
|
||||
/** @psalm-var list<mixed[]> $credentials */
|
||||
$credentials = $this->twoFactor->config['settings']['credentials'];
|
||||
foreach ($credentials as &$credential) {
|
||||
if (isset($credential['trustPath'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$credential['trustPath'] = ['type' => EmptyTrustPath::class];
|
||||
}
|
||||
|
||||
return $credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed[] $data
|
||||
*/
|
||||
private function write(array $data): void
|
||||
{
|
||||
$this->twoFactor->config['settings']['credentials'] = $data;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -283,6 +283,7 @@
|
||||
proc_priv: bool,
|
||||
querytime: float|int,
|
||||
read_limit: int,
|
||||
request: PhpMyAdmin\Http\ServerRequest,
|
||||
save_on_server: bool,
|
||||
server: int,
|
||||
SESSION_KEY: string,
|
||||
|
||||
@ -222,7 +222,18 @@ cleanup_composer_vendors() {
|
||||
vendor/phpmyadmin/shapefile/CONTRIBUTING.md \
|
||||
vendor/phpmyadmin/shapefile/CODE_OF_CONDUCT.md \
|
||||
vendor/phpmyadmin/sql-parser/CODE_OF_CONDUCT.md \
|
||||
vendor/phpmyadmin/sql-parser/CONTRIBUTING.md
|
||||
vendor/phpmyadmin/sql-parser/CONTRIBUTING.md \
|
||||
vendor/beberlei/assert/.github/ \
|
||||
vendor/brick/math/SECURITY.md \
|
||||
vendor/brick/math/psalm-baseline.xml \
|
||||
vendor/brick/math/psalm.xml \
|
||||
vendor/ramsey/collection/SECURITY.md \
|
||||
vendor/spomky-labs/base64url/.github/ \
|
||||
vendor/spomky-labs/cbor-php/.php_cs.dist \
|
||||
vendor/spomky-labs/cbor-php/CODE_OF_CONDUCT.md \
|
||||
vendor/spomky-labs/cbor-php/infection.json.dist \
|
||||
vendor/spomky-labs/cbor-php/phpstan.neon \
|
||||
vendor/thecodingmachine/safe/generated/Exceptions/.gitkeep
|
||||
find vendor/tecnickcom/tcpdf/fonts/ -maxdepth 1 -type f \
|
||||
-not -name 'dejavusans.*' \
|
||||
-not -name 'dejavusansb.*' \
|
||||
@ -270,6 +281,10 @@ security_checkup() {
|
||||
echo 'TCPDF should be installed, detection failed !'
|
||||
exit 1;
|
||||
fi
|
||||
if [ ! -f vendor/web-auth/webauthn-lib/src/Server.php ]; then
|
||||
echo 'Webauthn-lib should be installed, detection failed !'
|
||||
exit 1;
|
||||
fi
|
||||
if [ ! -f vendor/code-lts/u2f-php-server/src/U2FServer.php ]; then
|
||||
echo 'U2F-server should be installed, detection failed !'
|
||||
exit 1;
|
||||
@ -478,7 +493,7 @@ composer update --no-interaction --no-dev
|
||||
|
||||
# Parse the required versions from composer.json
|
||||
PACKAGES_VERSIONS=''
|
||||
PACKAGE_LIST='tecnickcom/tcpdf pragmarx/google2fa-qrcode bacon/bacon-qr-code code-lts/u2f-php-server'
|
||||
PACKAGE_LIST='tecnickcom/tcpdf pragmarx/google2fa-qrcode bacon/bacon-qr-code code-lts/u2f-php-server web-auth/webauthn-lib'
|
||||
|
||||
for PACKAGES in $PACKAGE_LIST
|
||||
do
|
||||
|
||||
@ -26,7 +26,7 @@
|
||||
{% if type == 'text' %}
|
||||
<input type="text" name="{{ path }}" id="{{ path }}" value="{{ value }}" class="w-75{{ not value_is_default ? (has_errors ? ' custom field-error' : ' custom') }}">
|
||||
{% elseif type == 'password' %}
|
||||
<input type="password" name="{{ path }}" id="{{ path }}" value="{{ value }}" class="w-75{{ not value_is_default ? (has_errors ? ' custom field-error' : ' custom') }}">
|
||||
<input type="password" name="{{ path }}" id="{{ path }}" value="{{ value }}" class="w-75{{ not value_is_default ? (has_errors ? ' custom field-error' : ' custom') }}" spellcheck="false">
|
||||
{% elseif type == 'short_text' and value is not iterable %}
|
||||
{# https://github.com/phpmyadmin/phpmyadmin/issues/11505 #}
|
||||
<input type="text" size="25" name="{{ path }}" id="{{ path }}" value="{{ value }}" class="{{ not value_is_default ? (has_errors ? 'custom field-error' : 'custom') }}">
|
||||
|
||||
@ -76,7 +76,7 @@
|
||||
{% trans 'Username:' %}
|
||||
</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="text" name="pma_username" id="input_username" value="{{ default_user }}" class="form-control" autocomplete="username" autofocus>
|
||||
<input type="text" name="pma_username" id="input_username" value="{{ default_user }}" class="form-control" autocomplete="username" spellcheck="false" autofocus>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -85,7 +85,7 @@
|
||||
{% trans 'Password:' %}
|
||||
</label>
|
||||
<div class="col-sm-8">
|
||||
<input type="password" name="pma_password" id="input_password" value="" class="form-control" autocomplete="current-password">
|
||||
<input type="password" name="pma_password" id="input_password" value="" class="form-control" autocomplete="current-password" spellcheck="false">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
3
templates/login/twofactor/webauthn_creation.twig
Normal file
3
templates/login/twofactor/webauthn_creation.twig
Normal file
@ -0,0 +1,3 @@
|
||||
<p class="card-text">{{ 'Please connect your WebAuthn/FIDO2 device. Then confirm registration on the device.'|trans }}</p>
|
||||
|
||||
<input type="hidden" id="webauthn_creation_response" name="webauthn_creation_response" value="" data-creation-options="{{ creation_options|e('html_attr') }}">
|
||||
3
templates/login/twofactor/webauthn_request.twig
Normal file
3
templates/login/twofactor/webauthn_request.twig
Normal file
@ -0,0 +1,3 @@
|
||||
<p class="card-text">{{ 'Please connect your WebAuthn/FIDO2 device. Then confirm login on the device.'|trans }}</p>
|
||||
|
||||
<input type="hidden" id="webauthn_request_response" name="webauthn_request_response" value="" data-request-options="{{ request_options|e('html_attr') }}">
|
||||
@ -21,6 +21,17 @@
|
||||
{% else %}
|
||||
<p>{% trans "Two-factor authentication is available, but not configured for this account." %}</p>
|
||||
{% endif %}
|
||||
{% if missing|length > 0 %}
|
||||
<p>
|
||||
{{ 'Please install optional dependencies to enable more authentication backends.'|trans }}
|
||||
{{ 'Following composer packages are missing:'|trans }}
|
||||
</p>
|
||||
<ul>
|
||||
{% for item in missing %}
|
||||
<li><code>{{ item.dep }}</code> ({{ item.class }})</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p>{% trans "Two-factor authentication is not available, enable phpMyAdmin configuration storage to use it." %}</p>
|
||||
|
||||
@ -26,11 +26,11 @@
|
||||
<td>
|
||||
<div class="row">
|
||||
<span class="d-flex align-items-center col-4">{% trans 'Enter:' %}</span>
|
||||
<input type="password" name="pma_pw" id="text_pma_change_pw" size="10" class="textfield col-6">
|
||||
<input type="password" name="pma_pw" id="text_pma_change_pw" size="10" class="textfield col-6" spellcheck="false">
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<span class="d-flex align-items-center col-4">{% trans 'Re-type:' %}</span>
|
||||
<input type="password" name="pma_pw2" id="text_pma_change_pw2" size="10" class="textfield col-6">
|
||||
<input type="password" name="pma_pw2" id="text_pma_change_pw2" size="10" class="textfield col-6" spellcheck="false">
|
||||
</div>
|
||||
</td>
|
||||
<td class="col-2">
|
||||
|
||||
@ -9,7 +9,7 @@
|
||||
<option value="userdefined"{{ pred_username is null or pred_username == 'userdefined' ? ' selected' }}>{% trans 'Use text field' %}</option>
|
||||
</select>
|
||||
</span>
|
||||
<input type="text" name="username" id="pma_username" class="autofocus" maxlength="{{ username_length }}" title="{% trans 'User name' %}"
|
||||
<input type="text" spellcheck="false" name="username" id="pma_username" class="autofocus" maxlength="{{ username_length }}" title="{% trans 'User name' %}"
|
||||
{%- if username is not empty %} value="{{ new_username is not null ? new_username : username }}"{% endif -%}
|
||||
{{- pred_username is null or pred_username == 'userdefined' ? ' required' }}>
|
||||
|
||||
@ -51,7 +51,7 @@
|
||||
<option value="userdefined"{{ username is null ? ' selected' }}>{% trans 'Use text field' %}</option>
|
||||
</select>
|
||||
</span>
|
||||
<input type="password" id="text_pma_pw" name="pma_pw" title="{% trans 'Password' %}"{{ username is null ? ' required' }}>
|
||||
<input type="password" spellcheck="false" id="text_pma_pw" name="pma_pw" title="{% trans 'Password' %}"{{ username is null ? ' required' }}>
|
||||
<span>{% trans %}Strength:{% context %}Password strength{% endtrans %}</span>
|
||||
<meter max="4" id="password_strength_meter" name="pw_meter"></meter>
|
||||
<span id="password_strength" name="pw_strength"></span>
|
||||
@ -60,7 +60,7 @@
|
||||
<div class="item" id="div_element_before_generate_password">
|
||||
<label for="text_pma_pw2">{% trans 'Re-type:' %}</label>
|
||||
<span class="options"> </span>
|
||||
<input type="password" name="pma_pw2" id="text_pma_pw2" title="{% trans 'Re-type' %}"{{ username is null ? ' required' }}>
|
||||
<input type="password" spellcheck="false" name="pma_pw2" id="text_pma_pw2" title="{% trans 'Re-type' %}"{{ username is null ? ' required' }}>
|
||||
</div>
|
||||
|
||||
<div class="item" id="authentication_plugin_div">
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
</div>
|
||||
<div class="item">
|
||||
<label for="text_pma_pw">{% trans 'Password:' %}</label>
|
||||
<input id="text_pma_pw" name="pma_pw" type="password" title="{% trans 'Password' %}" required>
|
||||
<input id="text_pma_pw" name="pma_pw" type="password" spellcheck="false" title="{% trans 'Password' %}" required>
|
||||
</div>
|
||||
<div class="item">
|
||||
<label for="text_hostname">{% trans 'Host:' %}</label>
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
<option value="userdefined"{{ predefined_username == 'userdefined' ? ' selected' }}>{% trans 'Use text field:' %}</option>
|
||||
</select>
|
||||
</span>
|
||||
<input type="text" name="username" id="pma_username" maxlength="{{ username_length }}" title="{% trans 'User name' %}" value="{{ username }}">
|
||||
<input type="text" name="username" id="pma_username" maxlength="{{ username_length }}" title="{% trans 'User name' %}" value="{{ username }}" spellcheck="false">
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
@ -54,7 +54,7 @@
|
||||
<option value="userdefined"{{ not has_username ? ' selected' }}>{% trans 'Use text field:' %}</option>
|
||||
</select>
|
||||
</span>
|
||||
<input type="password" id="text_pma_pw" name="pma_pw" title="{% trans 'Password' %}">
|
||||
<input type="password" id="text_pma_pw" name="pma_pw" title="{% trans 'Password' %}" spellcheck="false">
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
@ -62,7 +62,7 @@
|
||||
{% trans 'Re-type:' %}
|
||||
</label>
|
||||
<span class="options"></span>
|
||||
<input type="password" id="text_pma_pw2" name="pma_pw2" title="{% trans 'Re-type' %}">
|
||||
<input type="password" id="text_pma_pw2" name="pma_pw2" title="{% trans 'Re-type' %}" spellcheck="false">
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
|
||||
@ -211,13 +211,13 @@ class AuthenticationCookieTest extends AbstractNetworkTestCase
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'<input type="text" name="pma_username" id="input_username" ' .
|
||||
'value="pmauser" class="form-control" autocomplete="username" autofocus>',
|
||||
'value="pmauser" class="form-control" autocomplete="username" spellcheck="false" autofocus>',
|
||||
$result
|
||||
);
|
||||
|
||||
$this->assertStringContainsString(
|
||||
'<input type="password" name="pma_password" id="input_password" ' .
|
||||
'value="" class="form-control" autocomplete="current-password">',
|
||||
'value="" class="form-control" autocomplete="current-password" spellcheck="false">',
|
||||
$result
|
||||
);
|
||||
|
||||
|
||||
299
test/classes/Plugins/TwoFactor/WebAuthnTest.php
Normal file
299
test/classes/Plugins/TwoFactor/WebAuthnTest.php
Normal file
@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpMyAdmin\Tests\Plugins\TwoFactor;
|
||||
|
||||
use PhpMyAdmin\Http\ServerRequest;
|
||||
use PhpMyAdmin\Plugins\TwoFactor\WebAuthn;
|
||||
use PhpMyAdmin\ResponseRenderer;
|
||||
use PhpMyAdmin\Tests\AbstractTestCase;
|
||||
use PhpMyAdmin\TwoFactor;
|
||||
use PhpMyAdmin\WebAuthn\Server;
|
||||
use PhpMyAdmin\WebAuthn\WebAuthnException;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
|
||||
use function array_column;
|
||||
use function json_decode;
|
||||
|
||||
/**
|
||||
* @covers \PhpMyAdmin\Plugins\TwoFactor\WebAuthn
|
||||
* @covers \PhpMyAdmin\Plugins\TwoFactorPlugin
|
||||
* @covers \PhpMyAdmin\WebAuthn\WebAuthnException
|
||||
*/
|
||||
class WebAuthnTest extends AbstractTestCase
|
||||
{
|
||||
public function testIdNameAndDescription(): void
|
||||
{
|
||||
$this->assertSame('WebAuthn', WebAuthn::$id);
|
||||
$this->assertSame('Hardware Security Key (WebAuthn/FIDO2)', WebAuthn::getName());
|
||||
$this->assertSame(
|
||||
'Provides authentication using hardware security tokens supporting the WebAuthn/FIDO2 protocol,'
|
||||
. ' such as a YubiKey.',
|
||||
WebAuthn::getDescription()
|
||||
);
|
||||
}
|
||||
|
||||
public function testRender(): void
|
||||
{
|
||||
$GLOBALS['lang'] = 'en';
|
||||
$GLOBALS['server'] = 1;
|
||||
$GLOBALS['text_dir'] = 'ltr';
|
||||
$GLOBALS['PMA_PHP_SELF'] = 'index.php';
|
||||
|
||||
$uri = $this->createStub(UriInterface::class);
|
||||
$uri->method('getHost')->willReturn('test.localhost');
|
||||
$request = $this->createStub(ServerRequest::class);
|
||||
$request->method('getUri')->willReturn($uri);
|
||||
$GLOBALS['request'] = $request;
|
||||
|
||||
$twoFactor = $this->createStub(TwoFactor::class);
|
||||
$twoFactor->user = 'test_user';
|
||||
$twoFactor->config = [
|
||||
'backend' => 'WebAuthn',
|
||||
'settings' => [
|
||||
'credentials' => [
|
||||
// base64 of publicKeyCredentialId1
|
||||
'cHVibGljS2V5Q3JlZGVudGlhbElkMQ==' => [
|
||||
// base64url of publicKeyCredentialId1
|
||||
'publicKeyCredentialId' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ',
|
||||
'type' => 'public-key',
|
||||
],
|
||||
// base64 of publicKeyCredentialId2
|
||||
'cHVibGljS2V5Q3JlZGVudGlhbElkMg==' => ['publicKeyCredentialId' => '', 'type' => ''],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$expectedRequestOptions = [
|
||||
'challenge' => 'challenge',
|
||||
'allowCredentials' => [['type' => 'public-key', 'id' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ']],
|
||||
'timeout' => 60000,
|
||||
];
|
||||
$server = $this->createMock(Server::class);
|
||||
$server->expects($this->once())->method('getCredentialRequestOptions')->with(
|
||||
$this->equalTo('test_user'),
|
||||
$this->anything(),
|
||||
$this->equalTo('test.localhost'),
|
||||
$this->equalTo([['type' => 'public-key', 'id' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ']])
|
||||
)->willReturn($expectedRequestOptions);
|
||||
|
||||
$webAuthn = new WebAuthn($twoFactor);
|
||||
$webAuthn->setServer($server);
|
||||
$actual = $webAuthn->render();
|
||||
|
||||
$optionsFromSession = $_SESSION['WebAuthnCredentialRequestOptions'] ?? null;
|
||||
$this->assertIsString($optionsFromSession);
|
||||
$this->assertJson($optionsFromSession);
|
||||
$this->assertEquals($expectedRequestOptions, json_decode($optionsFromSession, true));
|
||||
|
||||
$this->assertStringContainsString('id="webauthn_request_response"', $actual);
|
||||
$this->assertStringContainsString('name="webauthn_request_response"', $actual);
|
||||
$this->assertStringContainsString('value=""', $actual);
|
||||
$this->assertStringContainsString('data-request-options="', $actual);
|
||||
$this->assertSame('', $webAuthn->getError());
|
||||
|
||||
$files = ResponseRenderer::getInstance()->getHeader()->getScripts()->getFiles();
|
||||
$this->assertContains('webauthn.js', array_column($files, 'name'));
|
||||
}
|
||||
|
||||
public function testSetup(): void
|
||||
{
|
||||
$GLOBALS['lang'] = 'en';
|
||||
$GLOBALS['server'] = 1;
|
||||
$GLOBALS['text_dir'] = 'ltr';
|
||||
$GLOBALS['PMA_PHP_SELF'] = 'index.php';
|
||||
|
||||
$uri = $this->createStub(UriInterface::class);
|
||||
$uri->method('getHost')->willReturn('test.localhost');
|
||||
$request = $this->createStub(ServerRequest::class);
|
||||
$request->method('getUri')->willReturn($uri);
|
||||
$GLOBALS['request'] = $request;
|
||||
|
||||
$twoFactor = $this->createStub(TwoFactor::class);
|
||||
$twoFactor->user = 'test_user';
|
||||
|
||||
$expectedCreationOptions = [
|
||||
'challenge' => 'challenge',
|
||||
'rp' => ['name' => 'phpMyAdmin (test.localhost)', 'id' => 'test.localhost'],
|
||||
'user' => ['id' => 'user_id', 'name' => 'test_user', 'displayName' => 'test_user'],
|
||||
'pubKeyCredParams' => [['alg' => -8, 'type' => 'public-key']],
|
||||
'authenticatorSelection' => ['authenticatorAttachment' => 'cross-platform'],
|
||||
'timeout' => 60000,
|
||||
'attestation' => 'none',
|
||||
];
|
||||
$server = $this->createMock(Server::class);
|
||||
$server->expects($this->once())->method('getCredentialCreationOptions')->with(
|
||||
$this->equalTo('test_user'),
|
||||
$this->anything(),
|
||||
$this->equalTo('test.localhost')
|
||||
)->willReturn($expectedCreationOptions);
|
||||
|
||||
$webAuthn = new WebAuthn($twoFactor);
|
||||
$webAuthn->setServer($server);
|
||||
$actual = $webAuthn->setup();
|
||||
|
||||
$optionsFromSession = $_SESSION['WebAuthnCredentialCreationOptions'] ?? null;
|
||||
$this->assertIsString($optionsFromSession);
|
||||
$this->assertJson($optionsFromSession);
|
||||
$this->assertEquals($expectedCreationOptions, json_decode($optionsFromSession, true));
|
||||
|
||||
$this->assertStringContainsString('id="webauthn_creation_response"', $actual);
|
||||
$this->assertStringContainsString('name="webauthn_creation_response"', $actual);
|
||||
$this->assertStringContainsString('value=""', $actual);
|
||||
$this->assertStringContainsString('data-creation-options="', $actual);
|
||||
$this->assertSame('', $webAuthn->getError());
|
||||
|
||||
$files = ResponseRenderer::getInstance()->getHeader()->getScripts()->getFiles();
|
||||
$this->assertContains('webauthn.js', array_column($files, 'name'));
|
||||
}
|
||||
|
||||
public function testConfigure(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
$request = $this->createStub(ServerRequest::class);
|
||||
$request->method('getParsedBodyParam')->willReturnMap([['webauthn_creation_response', '', '']]);
|
||||
$GLOBALS['request'] = $request;
|
||||
$webAuthn = new WebAuthn($this->createStub(TwoFactor::class));
|
||||
$this->assertFalse($webAuthn->configure());
|
||||
$this->assertSame('', $webAuthn->getError());
|
||||
}
|
||||
|
||||
public function testConfigure2(): void
|
||||
{
|
||||
$_SESSION['WebAuthnCredentialCreationOptions'] = '';
|
||||
$request = $this->createStub(ServerRequest::class);
|
||||
$request->method('getParsedBodyParam')->willReturnMap([['webauthn_creation_response', '', '{}']]);
|
||||
$GLOBALS['request'] = $request;
|
||||
$webAuthn = new WebAuthn($this->createStub(TwoFactor::class));
|
||||
$this->assertFalse($webAuthn->configure());
|
||||
$this->assertStringContainsString('Two-factor authentication failed:', $webAuthn->getError());
|
||||
}
|
||||
|
||||
public function testConfigure3(): void
|
||||
{
|
||||
$_SESSION['WebAuthnCredentialCreationOptions'] = '{}';
|
||||
$request = $this->createStub(ServerRequest::class);
|
||||
$request->method('getParsedBodyParam')->willReturnMap([['webauthn_creation_response', '', '{}']]);
|
||||
$GLOBALS['request'] = $request;
|
||||
|
||||
$server = $this->createMock(Server::class);
|
||||
$server->expects($this->once())->method('parseAndValidateAttestationResponse')
|
||||
->willThrowException(new WebAuthnException());
|
||||
|
||||
$webAuthn = new WebAuthn($this->createStub(TwoFactor::class));
|
||||
$webAuthn->setServer($server);
|
||||
$this->assertFalse($webAuthn->configure());
|
||||
$this->assertStringContainsString('Two-factor authentication failed.', $webAuthn->getError());
|
||||
}
|
||||
|
||||
public function testConfigure4(): void
|
||||
{
|
||||
$_SESSION['WebAuthnCredentialCreationOptions'] = '{}';
|
||||
$request = $this->createStub(ServerRequest::class);
|
||||
$request->method('getParsedBodyParam')->willReturnMap([['webauthn_creation_response', '', '{}']]);
|
||||
$GLOBALS['request'] = $request;
|
||||
|
||||
$twoFactor = $this->createStub(TwoFactor::class);
|
||||
$twoFactor->config = ['backend' => '', 'settings' => []];
|
||||
|
||||
// base64url of publicKeyCredentialId1
|
||||
$credential = ['publicKeyCredentialId' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ', 'userHandle' => 'userHandle'];
|
||||
$server = $this->createMock(Server::class);
|
||||
$server->expects($this->once())->method('parseAndValidateAttestationResponse')->with(
|
||||
$this->equalTo('{}'),
|
||||
$this->equalTo('{}'),
|
||||
$this->equalTo($request)
|
||||
)->willReturn($credential);
|
||||
|
||||
$webAuthn = new WebAuthn($twoFactor);
|
||||
$webAuthn->setServer($server);
|
||||
$this->assertTrue($webAuthn->configure());
|
||||
/** @psalm-var array{backend: string, settings: mixed[]} $config */
|
||||
$config = $twoFactor->config;
|
||||
$this->assertSame(
|
||||
[
|
||||
'backend' => '',
|
||||
'settings' => [
|
||||
'userHandle' => 'userHandle',
|
||||
'credentials' => ['cHVibGljS2V5Q3JlZGVudGlhbElkMQ==' => $credential],
|
||||
],
|
||||
],
|
||||
$config
|
||||
);
|
||||
}
|
||||
|
||||
public function testCheck(): void
|
||||
{
|
||||
$_SESSION = [];
|
||||
$request = $this->createStub(ServerRequest::class);
|
||||
$request->method('getParsedBodyParam')->willReturnMap([['webauthn_request_response', '', '']]);
|
||||
$GLOBALS['request'] = $request;
|
||||
$webAuthn = new WebAuthn($this->createStub(TwoFactor::class));
|
||||
$this->assertFalse($webAuthn->check());
|
||||
$this->assertSame('', $webAuthn->getError());
|
||||
}
|
||||
|
||||
public function testCheck2(): void
|
||||
{
|
||||
$_SESSION['WebAuthnCredentialRequestOptions'] = '';
|
||||
$request = $this->createStub(ServerRequest::class);
|
||||
$request->method('getParsedBodyParam')->willReturnMap([['webauthn_request_response', '', '{}']]);
|
||||
$GLOBALS['request'] = $request;
|
||||
$webAuthn = new WebAuthn($this->createStub(TwoFactor::class));
|
||||
$this->assertFalse($webAuthn->check());
|
||||
$this->assertStringContainsString('Two-factor authentication failed:', $webAuthn->getError());
|
||||
}
|
||||
|
||||
public function testCheck3(): void
|
||||
{
|
||||
$_SESSION['WebAuthnCredentialRequestOptions'] = '{"challenge":"challenge"}';
|
||||
$request = $this->createStub(ServerRequest::class);
|
||||
$request->method('getParsedBodyParam')->willReturnMap([['webauthn_request_response', '', '{}']]);
|
||||
$GLOBALS['request'] = $request;
|
||||
|
||||
$server = $this->createMock(Server::class);
|
||||
$server->expects($this->once())->method('parseAndValidateAssertionResponse')
|
||||
->willThrowException(new WebAuthnException());
|
||||
|
||||
$webAuthn = new WebAuthn($this->createStub(TwoFactor::class));
|
||||
$webAuthn->setServer($server);
|
||||
$this->assertFalse($webAuthn->check());
|
||||
$this->assertStringContainsString('Two-factor authentication failed.', $webAuthn->getError());
|
||||
}
|
||||
|
||||
public function testCheck4(): void
|
||||
{
|
||||
$_SESSION['WebAuthnCredentialRequestOptions'] = '{"challenge":"challenge"}';
|
||||
$request = $this->createStub(ServerRequest::class);
|
||||
$request->method('getParsedBodyParam')->willReturnMap([['webauthn_request_response', '', '{}']]);
|
||||
$GLOBALS['request'] = $request;
|
||||
|
||||
$twoFactor = $this->createStub(TwoFactor::class);
|
||||
$twoFactor->config = [
|
||||
'backend' => 'WebAuthn',
|
||||
'settings' => [
|
||||
'credentials' => [
|
||||
// base64 of publicKeyCredentialId1
|
||||
'cHVibGljS2V5Q3JlZGVudGlhbElkMQ==' => [
|
||||
// base64url of publicKeyCredentialId1
|
||||
'publicKeyCredentialId' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ',
|
||||
'type' => 'public-key',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$server = $this->createMock(Server::class);
|
||||
$server->expects($this->once())->method('parseAndValidateAssertionResponse')->with(
|
||||
$this->equalTo('{}'),
|
||||
$this->equalTo([['type' => 'public-key', 'id' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ']]),
|
||||
$this->equalTo('challenge'),
|
||||
$this->equalTo($request)
|
||||
);
|
||||
|
||||
$webAuthn = new WebAuthn($twoFactor);
|
||||
$webAuthn->setServer($server);
|
||||
$this->assertTrue($webAuthn->check());
|
||||
}
|
||||
}
|
||||
198
test/classes/WebAuthn/CBORDecoderTest.php
Normal file
198
test/classes/WebAuthn/CBORDecoderTest.php
Normal file
@ -0,0 +1,198 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpMyAdmin\Tests\WebAuthn;
|
||||
|
||||
use PhpMyAdmin\WebAuthn\CBORDecoder;
|
||||
use PhpMyAdmin\WebAuthn\DataStream;
|
||||
use PhpMyAdmin\WebAuthn\WebAuthnException;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
use function base64_decode;
|
||||
use function hex2bin;
|
||||
|
||||
use const INF;
|
||||
|
||||
/**
|
||||
* @covers \PhpMyAdmin\WebAuthn\CBORDecoder
|
||||
* @covers \PhpMyAdmin\WebAuthn\DataStream
|
||||
*/
|
||||
class CBORDecoderTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @param mixed $expected
|
||||
*
|
||||
* @dataProvider dataProviderForTestDecode
|
||||
*/
|
||||
public function testDecode(string $encoded, $expected): void
|
||||
{
|
||||
$decoder = new CBORDecoder();
|
||||
$data = hex2bin($encoded);
|
||||
$this->assertNotFalse($data);
|
||||
$this->assertSame($expected, $decoder->decode(new DataStream($data)));
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-return iterable<int, array{string, mixed}>
|
||||
*/
|
||||
public function dataProviderForTestDecode(): iterable
|
||||
{
|
||||
return [
|
||||
['00', 0],
|
||||
['01', 1],
|
||||
['0a', 10],
|
||||
['17', 23],
|
||||
['1818', 24],
|
||||
['1819', 25],
|
||||
['1864', 100],
|
||||
['1903e8', 1000],
|
||||
['1a000f4240', 1000000],
|
||||
//['1b000000e8d4a51000', 1000000000000],
|
||||
//['1bffffffffffffffff', 18446744073709551615],
|
||||
//['c249010000000000000000', 18446744073709551616],
|
||||
//['3bffffffffffffffff', -18446744073709551616],
|
||||
//['c349010000000000000000', -18446744073709551617],
|
||||
['20', -1],
|
||||
['29', -10],
|
||||
['3863', -100],
|
||||
['3903e7', -1000],
|
||||
['f90000', 0.0],
|
||||
['f98000', -0.0],
|
||||
['f93c00', 1.0],
|
||||
['fb3ff199999999999a', 1.1],
|
||||
['f93e00', 1.5],
|
||||
['f97bff', 65504.0],
|
||||
['fa47c35000', 100000.0],
|
||||
['fa7f7fffff', 3.4028234663852886e+38],
|
||||
['fb7e37e43c8800759c', 1.0e+300],
|
||||
['f90001', 5.960464477539063e-8],
|
||||
['f90400', 0.00006103515625],
|
||||
['f9c400', -4.0],
|
||||
['fbc010666666666666', -4.1],
|
||||
['f97c00', INF],
|
||||
['f9fc00', -INF],
|
||||
['fa7f800000', INF],
|
||||
['faff800000', -INF],
|
||||
['fb7ff0000000000000', INF],
|
||||
['fbfff0000000000000', -INF],
|
||||
['f4', true],
|
||||
['f5', false],
|
||||
['f6', null],
|
||||
//['f7', 'undefined'],
|
||||
['f0', 16],
|
||||
['f818', 24],
|
||||
['f8ff', 255],
|
||||
['c074323031332d30332d32315432303a30343a30305a', '2013-03-21T20:04:00Z'],
|
||||
['c11a514b67b0', 1363896240],
|
||||
['c1fb41d452d9ec200000', 1363896240.5],
|
||||
['d74401020304', hex2bin('01020304')],
|
||||
['d818456449455446', hex2bin('6449455446')],
|
||||
['d82076687474703a2f2f7777772e6578616d706c652e636f6d', 'http://www.example.com'],
|
||||
['40', hex2bin('')],
|
||||
['4401020304', hex2bin('01020304')],
|
||||
['60', ''],
|
||||
['6161', 'a'],
|
||||
['6449455446', 'IETF'],
|
||||
['62225c', '"\\'],
|
||||
['62c3bc', "\u{00fc}"],
|
||||
['63e6b0b4', "\u{6c34}"],
|
||||
['64f0908591', "\u{10151}"], // "\u{d800}\u{dd51}"
|
||||
['80', []],
|
||||
['83010203', [1, 2, 3]],
|
||||
['8301820203820405', [1, [2, 3], [4, 5]]],
|
||||
[
|
||||
'98190102030405060708090a0b0c0d0e0f101112131415161718181819',
|
||||
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25],
|
||||
],
|
||||
['a0', []],
|
||||
['a201020304', [1 => 2, 3 => 4]],
|
||||
['a26161016162820203', ['a' => 1, 'b' => [2, 3]]],
|
||||
['826161a161626163', ['a', ['b' => 'c']]],
|
||||
[
|
||||
'a56161614161626142616361436164614461656145',
|
||||
['a' => 'A', 'b' => 'B', 'c' => 'C', 'd' => 'D', 'e' => 'E'],
|
||||
],
|
||||
['a1646e616d656441636d65', ['name' => 'Acme']],
|
||||
[
|
||||
'a462696458203082019330820138a0030201023082019330820138a003020102'
|
||||
. '3082019330826469636f6e782b68747470733a2f2f706963732e6578616d706c'
|
||||
. '652e636f6d2f30302f702f61426a6a6a707150622e706e67646e616d65766a6f'
|
||||
. '686e70736d697468406578616d706c652e636f6d6b646973706c61794e616d65'
|
||||
. '6d4a6f686e20502e20536d697468a462696458203082019330820138a0030201'
|
||||
. '023082019330820138a0030201023082019330826469636f6e782b6874747073'
|
||||
. '3a2f2f706963732e6578616d706c652e636f6d2f30302f702f61426a6a6a7071'
|
||||
. '50622e706e67646e616d65766a6f686e70736d697468406578616d706c652e63'
|
||||
. '6f6d6b646973706c61794e616d656d4a6f686e20502e20536d697468',
|
||||
[
|
||||
'id' => base64_decode('MIIBkzCCATigAwIBAjCCAZMwggE4oAMCAQIwggGTMII='),
|
||||
'icon' => 'https://pics.example.com/00/p/aBjjjpqPb.png',
|
||||
'name' => 'johnpsmith@example.com',
|
||||
'displayName' => 'John P. Smith',
|
||||
],
|
||||
],
|
||||
[
|
||||
'82a263616c672664747970656a7075626C69632D6B6579a263616c6739010064747970656a7075626C69632D6B6579',
|
||||
[
|
||||
['alg' => -7, 'type' => 'public-key'],
|
||||
['alg' => -257, 'type' => 'public-key'],
|
||||
],
|
||||
],
|
||||
[
|
||||
'A501020326200121582065eda5a12577c2bae829437fe338701a10aaa375e1bb5b5de108de439c08551d'
|
||||
. '2258201e52ed75701163f7f9e40ddf9f341b3dc9ba860af7e0ca7ca7e9eecd0084d19c',
|
||||
[
|
||||
1 => 2,
|
||||
3 => -7,
|
||||
-1 => 1,
|
||||
-2 => hex2bin('65eda5a12577c2bae829437fe338701a10aaa375e1bb5b5de108de439c08551d'),
|
||||
-3 => hex2bin('1e52ed75701163f7f9e40ddf9f341b3dc9ba860af7e0ca7ca7e9eecd0084d19c'),
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function testDecodeForNaNValues(): void
|
||||
{
|
||||
$decoder = new CBORDecoder();
|
||||
$nanValues = ['f97e00', 'fa7fc00000', 'fb7ff8000000000000'];
|
||||
foreach ($nanValues as $value) {
|
||||
$data = hex2bin($value);
|
||||
$this->assertNotFalse($data);
|
||||
$this->assertNan($decoder->decode(new DataStream($data)));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider indefiniteLengthValuesProvider
|
||||
*/
|
||||
public function testDecodeForNotSupportedValues(string $encoded): void
|
||||
{
|
||||
$decoder = new CBORDecoder();
|
||||
$data = hex2bin($encoded);
|
||||
$this->assertNotFalse($data);
|
||||
$this->expectException(WebAuthnException::class);
|
||||
$decoder->decode(new DataStream($data));
|
||||
}
|
||||
|
||||
/**
|
||||
* @psalm-return iterable<int, array{string}>
|
||||
*/
|
||||
public function indefiniteLengthValuesProvider(): iterable
|
||||
{
|
||||
return [
|
||||
['5f42010243030405ff'], // (_ h'0102', h'030405')
|
||||
['7f657374726561646d696e67ff'], // (_ "strea", "ming")
|
||||
['9fff'], // [_ ]
|
||||
['9f018202039f0405ffff'], // [_ 1, [2, 3], [_ 4, 5]]
|
||||
['9f01820203820405ff'], // [_ 1, [2, 3], [4, 5]]
|
||||
['83018202039f0405ff'], // [1, [2, 3], [_ 4, 5]]
|
||||
['83019f0203ff820405'], // [1, [_ 2, 3], [4, 5]]
|
||||
// [_ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]
|
||||
['9f0102030405060708090a0b0c0d0e0f101112131415161718181819ff'],
|
||||
['bf61610161629f0203ffff'], // {_ "a": 1, "b": [_ 2, 3]}
|
||||
['826161bf61626163ff'], // ["a", {_ "b": "c"}]
|
||||
['bf6346756ef563416d7421ff'], // {_ "Fun": true, "Amt": -2}
|
||||
];
|
||||
}
|
||||
}
|
||||
83
test/classes/WebAuthn/WebauthnLibServerTest.php
Normal file
83
test/classes/WebAuthn/WebauthnLibServerTest.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace PhpMyAdmin\Tests\WebAuthn;
|
||||
|
||||
use PhpMyAdmin\TwoFactor;
|
||||
use PhpMyAdmin\WebAuthn\WebauthnLibServer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Webauthn\Server as WebauthnServer;
|
||||
|
||||
use function base64_encode;
|
||||
use function class_exists;
|
||||
|
||||
/**
|
||||
* @covers \PhpMyAdmin\WebAuthn\WebauthnLibServer
|
||||
*/
|
||||
class WebauthnLibServerTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
if (class_exists(WebauthnServer::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markTestSkipped('Package "web-auth/webauthn-lib" is required.');
|
||||
}
|
||||
|
||||
public function testGetCredentialCreationOptions(): void
|
||||
{
|
||||
$server = new WebauthnLibServer($this->createStub(TwoFactor::class));
|
||||
$options = $server->getCredentialCreationOptions('user_name', 'user_id', 'test.localhost');
|
||||
$this->assertArrayHasKey('challenge', $options);
|
||||
$this->assertNotEmpty($options['challenge']);
|
||||
$this->assertArrayHasKey('pubKeyCredParams', $options);
|
||||
$this->assertNotEmpty($options['pubKeyCredParams']);
|
||||
$this->assertArrayHasKey('attestation', $options);
|
||||
$this->assertNotEmpty($options['attestation']);
|
||||
$this->assertSame('phpMyAdmin (test.localhost)', $options['rp']['name']);
|
||||
$this->assertSame('test.localhost', $options['rp']['id']);
|
||||
$this->assertSame('user_name', $options['user']['name']);
|
||||
$this->assertSame('user_name', $options['user']['displayName']);
|
||||
$this->assertSame(base64_encode('user_id'), $options['user']['id']);
|
||||
$this->assertArrayHasKey('authenticatorAttachment', $options['authenticatorSelection']);
|
||||
$this->assertSame('cross-platform', $options['authenticatorSelection']['authenticatorAttachment']);
|
||||
}
|
||||
|
||||
public function testGetCredentialRequestOptions(): void
|
||||
{
|
||||
$twoFactor = $this->createStub(TwoFactor::class);
|
||||
$twoFactor->config = [
|
||||
'backend' => 'WebAuthn',
|
||||
'settings' => [
|
||||
'credentials' => [
|
||||
// base64 of publicKeyCredentialId1
|
||||
'cHVibGljS2V5Q3JlZGVudGlhbElkMQ==' => [
|
||||
// base64url for publicKeyCredentialId1
|
||||
'publicKeyCredentialId' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ',
|
||||
'type' => 'public-key',
|
||||
'transports' => [],
|
||||
'attestationType' => 'none',
|
||||
'trustPath' => ['type' => 'Webauthn\\TrustPath\\EmptyTrustPath'],
|
||||
'aaguid' => '00000000-0000-0000-0000-000000000000',
|
||||
'credentialPublicKey' => 'Y3JlZGVudGlhbFB1YmxpY0tleTE', // base64url for credentialPublicKey1
|
||||
'userHandle' => 'dXNlckhhbmRsZTE=', // base64 for userHandle1
|
||||
'counter' => 0,
|
||||
'otherUI' => null,
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$server = new WebauthnLibServer($twoFactor);
|
||||
$options = $server->getCredentialRequestOptions('user_name', 'userHandle1', 'test.localhost', []);
|
||||
$this->assertNotEmpty($options['challenge']);
|
||||
$this->assertSame('test.localhost', $options['rpId']);
|
||||
$this->assertEquals(
|
||||
[['type' => 'public-key', 'id' => 'cHVibGljS2V5Q3JlZGVudGlhbElkMQ==']],
|
||||
$options['allowCredentials']
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user