first commit
This commit is contained in:
1
plugins/system/webauthn/fido.jwt
Normal file
1
plugins/system/webauthn/fido.jwt
Normal file
File diff suppressed because one or more lines are too long
18
plugins/system/webauthn/forms/webauthn.xml
Normal file
18
plugins/system/webauthn/forms/webauthn.xml
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<form>
|
||||
<fields name="webauthn" addfieldprefix="Joomla\Plugin\System\Webauthn\Field">
|
||||
<fieldset name="webauthn"
|
||||
label="PLG_SYSTEM_WEBAUTHN_HEADER"
|
||||
>
|
||||
<field
|
||||
name="webauthn"
|
||||
type="webauthn"
|
||||
label="PLG_SYSTEM_WEBAUTHN_FIELD_LABEL"
|
||||
description="PLG_SYSTEM_WEBAUTHN_FIELD_DESC"
|
||||
required="false"
|
||||
readonly="false"
|
||||
default=""
|
||||
/>
|
||||
</fieldset>
|
||||
</fields>
|
||||
</form>
|
||||
91
plugins/system/webauthn/services/provider.php
Normal file
91
plugins/system/webauthn/services/provider.php
Normal file
@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
\defined('_JEXEC') || die;
|
||||
|
||||
use Joomla\Application\ApplicationInterface;
|
||||
use Joomla\Application\SessionAwareWebApplicationInterface;
|
||||
use Joomla\CMS\Extension\PluginInterface;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
use Joomla\DI\Container;
|
||||
use Joomla\DI\ServiceProviderInterface;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Joomla\Plugin\System\Webauthn\Authentication;
|
||||
use Joomla\Plugin\System\Webauthn\CredentialRepository;
|
||||
use Joomla\Plugin\System\Webauthn\Extension\Webauthn;
|
||||
use Joomla\Plugin\System\Webauthn\MetadataRepository;
|
||||
use Joomla\Registry\Registry;
|
||||
use Webauthn\MetadataService\MetadataStatementRepository;
|
||||
use Webauthn\PublicKeyCredentialSourceRepository;
|
||||
|
||||
return new class () implements ServiceProviderInterface {
|
||||
/**
|
||||
* Registers the service provider with a DI container.
|
||||
*
|
||||
* @param Container $container The DI container.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function register(Container $container)
|
||||
{
|
||||
$container->set(
|
||||
PluginInterface::class,
|
||||
function (Container $container) {
|
||||
$app = Factory::getApplication();
|
||||
$session = $container->has('session') ? $container->get('session') : $this->getSession($app);
|
||||
|
||||
$db = $container->get(DatabaseInterface::class);
|
||||
$credentialsRepository = $container->has(PublicKeyCredentialSourceRepository::class)
|
||||
? $container->get(PublicKeyCredentialSourceRepository::class)
|
||||
: new CredentialRepository($db);
|
||||
|
||||
$metadataRepository = null;
|
||||
$params = new Registry($config['params'] ?? '{}');
|
||||
|
||||
if ($params->get('attestationSupport', 0) == 1) {
|
||||
$metadataRepository = $container->has(MetadataStatementRepository::class)
|
||||
? $container->get(MetadataStatementRepository::class)
|
||||
: new MetadataRepository();
|
||||
}
|
||||
|
||||
$authenticationHelper = $container->has(Authentication::class)
|
||||
? $container->get(Authentication::class)
|
||||
: new Authentication($app, $session, $credentialsRepository, $metadataRepository);
|
||||
|
||||
$plugin = new Webauthn(
|
||||
$container->get(DispatcherInterface::class),
|
||||
(array) PluginHelper::getPlugin('system', 'webauthn'),
|
||||
$authenticationHelper
|
||||
);
|
||||
$plugin->setApplication($app);
|
||||
|
||||
return $plugin;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current application session object
|
||||
*
|
||||
* @param ApplicationInterface $app The application we are running in
|
||||
*
|
||||
* @return \Joomla\Session\SessionInterface|null
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function getSession(ApplicationInterface $app)
|
||||
{
|
||||
return $app instanceof SessionAwareWebApplicationInterface ? $app->getSession() : null;
|
||||
}
|
||||
};
|
||||
549
plugins/system/webauthn/src/Authentication.php
Normal file
549
plugins/system/webauthn/src/Authentication.php
Normal file
@ -0,0 +1,549 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn;
|
||||
|
||||
use Exception;
|
||||
use Joomla\Application\ApplicationInterface;
|
||||
use Joomla\CMS\Application\CMSApplication;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\CMS\User\User;
|
||||
use Joomla\CMS\WebAuthn\Server;
|
||||
use Joomla\Session\SessionInterface;
|
||||
use Laminas\Diactoros\ServerRequestFactory;
|
||||
use Webauthn\AuthenticationExtensions\AuthenticationExtensionsClientInputs;
|
||||
use Webauthn\AuthenticatorSelectionCriteria;
|
||||
use Webauthn\MetadataService\MetadataStatementRepository;
|
||||
use Webauthn\PublicKeyCredentialCreationOptions;
|
||||
use Webauthn\PublicKeyCredentialDescriptor;
|
||||
use Webauthn\PublicKeyCredentialRequestOptions;
|
||||
use Webauthn\PublicKeyCredentialRpEntity;
|
||||
use Webauthn\PublicKeyCredentialSource;
|
||||
use Webauthn\PublicKeyCredentialSourceRepository;
|
||||
use Webauthn\PublicKeyCredentialUserEntity;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Helper class to aid in credentials creation (link an authenticator to a user account)
|
||||
*
|
||||
* @since 4.2.0
|
||||
* @internal
|
||||
*/
|
||||
final class Authentication
|
||||
{
|
||||
/**
|
||||
* The credentials repository
|
||||
*
|
||||
* @var CredentialRepository
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private $credentialsRepository;
|
||||
|
||||
/**
|
||||
* The application we are running in.
|
||||
*
|
||||
* @var CMSApplication
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private $app;
|
||||
|
||||
/**
|
||||
* The application session
|
||||
*
|
||||
* @var SessionInterface
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private $session;
|
||||
|
||||
/**
|
||||
* A simple metadata statement repository
|
||||
*
|
||||
* @var MetadataStatementRepository
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private $metadataRepository;
|
||||
|
||||
/**
|
||||
* Should I permit attestation support if a Metadata Statement Repository object is present and
|
||||
* non-empty?
|
||||
*
|
||||
* @var boolean
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private $attestationSupport = true;
|
||||
|
||||
/**
|
||||
* Public constructor.
|
||||
*
|
||||
* @param ApplicationInterface|null $app The app we are running in
|
||||
* @param SessionInterface|null $session The app session object
|
||||
* @param PublicKeyCredentialSourceRepository|null $credRepo Credentials repo
|
||||
* @param MetadataStatementRepository|null $mdsRepo Authenticator metadata repo
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function __construct(
|
||||
ApplicationInterface $app = null,
|
||||
SessionInterface $session = null,
|
||||
PublicKeyCredentialSourceRepository $credRepo = null,
|
||||
?MetadataStatementRepository $mdsRepo = null
|
||||
) {
|
||||
$this->app = $app;
|
||||
$this->session = $session;
|
||||
$this->credentialsRepository = $credRepo;
|
||||
$this->metadataRepository = $mdsRepo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the known FIDO authenticators and their metadata
|
||||
*
|
||||
* @return object[]
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function getKnownAuthenticators(): array
|
||||
{
|
||||
$return = (!empty($this->metadataRepository) && method_exists($this->metadataRepository, 'getKnownAuthenticators'))
|
||||
? $this->metadataRepository->getKnownAuthenticators()
|
||||
: [];
|
||||
|
||||
// Add a generic authenticator entry
|
||||
$image = HTMLHelper::_('image', 'plg_system_webauthn/fido.png', '', '', true, true);
|
||||
$image = $image ? JPATH_ROOT . substr($image, \strlen(Uri::root(true))) : (JPATH_BASE . '/media/plg_system_webauthn/images/fido.png');
|
||||
$image = file_exists($image) ? file_get_contents($image) : '';
|
||||
|
||||
$return[''] = (object) [
|
||||
'description' => Text::_('PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR'),
|
||||
'icon' => 'data:image/png;base64,' . base64_encode($image),
|
||||
];
|
||||
|
||||
return $return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Public Key credential source repository object
|
||||
*
|
||||
* @return PublicKeyCredentialSourceRepository|null
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function getCredentialsRepository(): ?PublicKeyCredentialSourceRepository
|
||||
{
|
||||
return $this->credentialsRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authenticator metadata repository object
|
||||
*
|
||||
* @return MetadataStatementRepository|null
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function getMetadataRepository(): ?MetadataStatementRepository
|
||||
{
|
||||
return $this->metadataRepository;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the public key creation options.
|
||||
*
|
||||
* This is used for the first step of attestation (key registration).
|
||||
*
|
||||
* The PK creation options and the user ID are stored in the session.
|
||||
*
|
||||
* @param User $user The Joomla user to create the public key for
|
||||
*
|
||||
* @return PublicKeyCredentialCreationOptions
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function getPubKeyCreationOptions(User $user): PublicKeyCredentialCreationOptions
|
||||
{
|
||||
/**
|
||||
* We will only ask for attestation information if our MDS is guaranteed not empty.
|
||||
*
|
||||
* We check that by trying to load a known good AAGUID (Yubico Security Key NFC). If it's
|
||||
* missing, we have failed to load the MDS data e.g. we could not contact the server, it
|
||||
* was taking too long, the cache is unwritable etc. In this case asking for attestation
|
||||
* conveyance would cause the attestation to fail (since we cannot verify its signature).
|
||||
* Therefore we have to ask for no attestation to be conveyed. The downside is that in this
|
||||
* case we do not have any information about the make and model of the authenticator. So be
|
||||
* it! After all, that's a convenience feature for us.
|
||||
*/
|
||||
$attestationMode = $this->hasAttestationSupport()
|
||||
? PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_DIRECT
|
||||
: PublicKeyCredentialCreationOptions::ATTESTATION_CONVEYANCE_PREFERENCE_NONE;
|
||||
|
||||
$publicKeyCredentialCreationOptions = $this->getWebauthnServer()->generatePublicKeyCredentialCreationOptions(
|
||||
$this->getUserEntity($user),
|
||||
$attestationMode,
|
||||
$this->getPubKeyDescriptorsForUser($user),
|
||||
new AuthenticatorSelectionCriteria(
|
||||
AuthenticatorSelectionCriteria::AUTHENTICATOR_ATTACHMENT_NO_PREFERENCE,
|
||||
false,
|
||||
AuthenticatorSelectionCriteria::USER_VERIFICATION_REQUIREMENT_PREFERRED
|
||||
),
|
||||
new AuthenticationExtensionsClientInputs()
|
||||
);
|
||||
|
||||
// Save data in the session
|
||||
$this->session->set('plg_system_webauthn.publicKeyCredentialCreationOptions', base64_encode(serialize($publicKeyCredentialCreationOptions)));
|
||||
$this->session->set('plg_system_webauthn.registration_user_id', $user->id);
|
||||
|
||||
return $publicKeyCredentialCreationOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the public key request options.
|
||||
*
|
||||
* This is used in the first step of the assertion (login) flow.
|
||||
*
|
||||
* @param User $user The Joomla user to get the PK request options for
|
||||
*
|
||||
* @return PublicKeyCredentialRequestOptions
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function getPubkeyRequestOptions(User $user): ?PublicKeyCredentialRequestOptions
|
||||
{
|
||||
Log::add('Creating PK request options', Log::DEBUG, 'webauthn.system');
|
||||
$publicKeyCredentialRequestOptions = $this->getWebauthnServer()->generatePublicKeyCredentialRequestOptions(
|
||||
PublicKeyCredentialRequestOptions::USER_VERIFICATION_REQUIREMENT_PREFERRED,
|
||||
$this->getPubKeyDescriptorsForUser($user)
|
||||
);
|
||||
|
||||
// Save in session. This is used during the verification stage to prevent replay attacks.
|
||||
$this->session->set('plg_system_webauthn.publicKeyCredentialRequestOptions', base64_encode(serialize($publicKeyCredentialRequestOptions)));
|
||||
|
||||
return $publicKeyCredentialRequestOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the authenticator assertion.
|
||||
*
|
||||
* This is used in the second step of the assertion (login) flow. The server verifies that the
|
||||
* assertion generated by the authenticator has not been tampered with.
|
||||
*
|
||||
* @param string $data The data
|
||||
* @param User $user The user we are trying to log in
|
||||
*
|
||||
* @return PublicKeyCredentialSource
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function validateAssertionResponse(string $data, User $user): PublicKeyCredentialSource
|
||||
{
|
||||
// Make sure the public key credential request options in the session are valid
|
||||
$encodedPkOptions = $this->session->get('plg_system_webauthn.publicKeyCredentialRequestOptions', null);
|
||||
$serializedOptions = base64_decode($encodedPkOptions);
|
||||
$publicKeyCredentialRequestOptions = unserialize($serializedOptions);
|
||||
|
||||
if (
|
||||
!\is_object($publicKeyCredentialRequestOptions)
|
||||
|| empty($publicKeyCredentialRequestOptions)
|
||||
|| !($publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions)
|
||||
) {
|
||||
Log::add('Cannot retrieve valid plg_system_webauthn.publicKeyCredentialRequestOptions from the session', Log::NOTICE, 'webauthn.system');
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
|
||||
}
|
||||
|
||||
$data = base64_decode($data);
|
||||
|
||||
if (empty($data)) {
|
||||
Log::add('No or invalid assertion data received from the browser', Log::NOTICE, 'webauthn.system');
|
||||
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
|
||||
}
|
||||
|
||||
return $this->getWebauthnServer()->loadAndCheckAssertionResponse(
|
||||
$data,
|
||||
$this->getPKCredentialRequestOptions(),
|
||||
$this->getUserEntity($user),
|
||||
ServerRequestFactory::fromGlobals()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the authenticator attestation.
|
||||
*
|
||||
* This is used for the second step of attestation (key registration), when the user has
|
||||
* interacted with the authenticator and we need to validate the legitimacy of its response.
|
||||
*
|
||||
* An exception will be returned on error. Also, under very rare conditions, you may receive
|
||||
* NULL instead of a PublicKeyCredentialSource object which means that something was off in the
|
||||
* returned data from the browser.
|
||||
*
|
||||
* @param string $data The data
|
||||
*
|
||||
* @return PublicKeyCredentialSource|null
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function validateAttestationResponse(string $data): PublicKeyCredentialSource
|
||||
{
|
||||
// Retrieve the PublicKeyCredentialCreationOptions object created earlier and perform sanity checks
|
||||
$encodedOptions = $this->session->get('plg_system_webauthn.publicKeyCredentialCreationOptions', null);
|
||||
|
||||
if (empty($encodedOptions)) {
|
||||
Log::add('Cannot retrieve plg_system_webauthn.publicKeyCredentialCreationOptions from the session', Log::NOTICE, 'webauthn.system');
|
||||
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_PK'));
|
||||
}
|
||||
|
||||
/** @var PublicKeyCredentialCreationOptions|null $publicKeyCredentialCreationOptions */
|
||||
try {
|
||||
$publicKeyCredentialCreationOptions = unserialize(base64_decode($encodedOptions));
|
||||
} catch (\Exception $e) {
|
||||
Log::add('The plg_system_webauthn.publicKeyCredentialCreationOptions in the session is invalid', Log::NOTICE, 'webauthn.system');
|
||||
$publicKeyCredentialCreationOptions = null;
|
||||
}
|
||||
|
||||
if (!\is_object($publicKeyCredentialCreationOptions) || !($publicKeyCredentialCreationOptions instanceof PublicKeyCredentialCreationOptions)) {
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_PK'));
|
||||
}
|
||||
|
||||
// Retrieve the stored user ID and make sure it's the same one in the request.
|
||||
$storedUserId = $this->session->get('plg_system_webauthn.registration_user_id', 0);
|
||||
$myUser = $this->app->getIdentity() ?? new User();
|
||||
$myUserId = $myUser->id;
|
||||
|
||||
if (($myUser->guest) || ($myUserId != $storedUserId)) {
|
||||
$message = sprintf('Invalid user! We asked the authenticator to attest user ID %d, the current user ID is %d', $storedUserId, $myUserId);
|
||||
Log::add($message, Log::NOTICE, 'webauthn.system');
|
||||
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_USER'));
|
||||
}
|
||||
|
||||
// We init the PSR-7 request object using Diactoros
|
||||
return $this->getWebauthnServer()->loadAndCheckAttestationResponse(
|
||||
base64_decode($data),
|
||||
$publicKeyCredentialCreationOptions,
|
||||
ServerRequestFactory::fromGlobals()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentiactor attestation support.
|
||||
*
|
||||
* @return boolean
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function hasAttestationSupport(): bool
|
||||
{
|
||||
return $this->attestationSupport
|
||||
&& ($this->metadataRepository instanceof MetadataStatementRepository)
|
||||
&& $this->metadataRepository->findOneByAAGUID('6d44ba9b-f6ec-2e49-b930-0c8fe920cb73');
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the authenticator attestation support.
|
||||
*
|
||||
* @param bool $attestationSupport The desired setting
|
||||
*
|
||||
* @return void
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function setAttestationSupport(bool $attestationSupport): void
|
||||
{
|
||||
$this->attestationSupport = $attestationSupport;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to find the site's favicon in the site's root, images, media, templates or current
|
||||
* template directory.
|
||||
*
|
||||
* @return string|null
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function getSiteIcon(): ?string
|
||||
{
|
||||
$filenames = [
|
||||
'apple-touch-icon.png',
|
||||
'apple_touch_icon.png',
|
||||
'favicon.ico',
|
||||
'favicon.png',
|
||||
'favicon.gif',
|
||||
'favicon.bmp',
|
||||
'favicon.jpg',
|
||||
'favicon.svg',
|
||||
];
|
||||
|
||||
try {
|
||||
$paths = [
|
||||
'/',
|
||||
'/images/',
|
||||
'/media/',
|
||||
'/templates/',
|
||||
'/templates/' . $this->app->getTemplate(),
|
||||
];
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($paths as $path) {
|
||||
foreach ($filenames as $filename) {
|
||||
$relFile = $path . $filename;
|
||||
$filePath = JPATH_BASE . $relFile;
|
||||
|
||||
if (is_file($filePath)) {
|
||||
break 2;
|
||||
}
|
||||
|
||||
$relFile = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($relFile) || \is_null($relFile)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rtrim(Uri::base(), '/') . '/' . ltrim($relFile, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a User Entity object given a Joomla user
|
||||
*
|
||||
* @param User $user The Joomla user to get the user entity for
|
||||
*
|
||||
* @return PublicKeyCredentialUserEntity
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function getUserEntity(User $user): PublicKeyCredentialUserEntity
|
||||
{
|
||||
$repository = $this->credentialsRepository;
|
||||
|
||||
return new PublicKeyCredentialUserEntity(
|
||||
$user->username,
|
||||
$repository->getHandleFromUserId($user->id),
|
||||
$user->name,
|
||||
$this->getAvatar($user, 64)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's avatar (through Gravatar)
|
||||
*
|
||||
* @param User $user The Joomla user object
|
||||
* @param int $size The dimensions of the image to fetch (default: 64 pixels)
|
||||
*
|
||||
* @return string The URL to the user's avatar
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function getAvatar(User $user, int $size = 64)
|
||||
{
|
||||
$scheme = Uri::getInstance()->getScheme();
|
||||
$subdomain = ($scheme == 'https') ? 'secure' : 'www';
|
||||
|
||||
return sprintf('%s://%s.gravatar.com/avatar/%s.jpg?s=%u&d=mm', $scheme, $subdomain, md5($user->email), $size);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of the PK credential descriptors (registered authenticators) for the given
|
||||
* user.
|
||||
*
|
||||
* @param User $user The Joomla user to get the PK descriptors for
|
||||
*
|
||||
* @return PublicKeyCredentialDescriptor[]
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function getPubKeyDescriptorsForUser(User $user): array
|
||||
{
|
||||
$userEntity = $this->getUserEntity($user);
|
||||
$repository = $this->credentialsRepository;
|
||||
$descriptors = [];
|
||||
$records = $repository->findAllForUserEntity($userEntity);
|
||||
|
||||
foreach ($records as $record) {
|
||||
$descriptors[] = $record->getPublicKeyCredentialDescriptor();
|
||||
}
|
||||
|
||||
return $descriptors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the public key credential request options saved in the session.
|
||||
*
|
||||
* If they do not exist or are corrupt it is a hacking attempt and we politely tell the
|
||||
* attacker to go away.
|
||||
*
|
||||
* @return PublicKeyCredentialRequestOptions
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function getPKCredentialRequestOptions(): PublicKeyCredentialRequestOptions
|
||||
{
|
||||
$encodedOptions = $this->session->get('plg_system_webauthn.publicKeyCredentialRequestOptions', null);
|
||||
|
||||
if (empty($encodedOptions)) {
|
||||
Log::add('Cannot retrieve plg_system_webauthn.publicKeyCredentialRequestOptions from the session', Log::NOTICE, 'webauthn.system');
|
||||
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
|
||||
}
|
||||
|
||||
try {
|
||||
$publicKeyCredentialRequestOptions = unserialize(base64_decode($encodedOptions));
|
||||
} catch (\Exception $e) {
|
||||
Log::add('Invalid plg_system_webauthn.publicKeyCredentialRequestOptions in the session', Log::NOTICE, 'webauthn.system');
|
||||
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
|
||||
}
|
||||
|
||||
if (!\is_object($publicKeyCredentialRequestOptions) || !($publicKeyCredentialRequestOptions instanceof PublicKeyCredentialRequestOptions)) {
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
|
||||
}
|
||||
|
||||
return $publicKeyCredentialRequestOptions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the WebAuthn library's Server object which facilitates WebAuthn operations
|
||||
*
|
||||
* @return Server
|
||||
* @throws \Exception
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function getWebauthnServer(): Server
|
||||
{
|
||||
$siteName = $this->app->get('sitename');
|
||||
|
||||
// Credentials repository
|
||||
$repository = $this->credentialsRepository;
|
||||
|
||||
// Relaying Party -- Our site
|
||||
$rpEntity = new PublicKeyCredentialRpEntity(
|
||||
$siteName,
|
||||
Uri::getInstance()->toString(['host']),
|
||||
$this->getSiteIcon()
|
||||
);
|
||||
|
||||
$server = new Server($rpEntity, $repository, $this->metadataRepository);
|
||||
|
||||
// Ed25519 is only available with libsodium
|
||||
if (!\function_exists('sodium_crypto_sign_seed_keypair')) {
|
||||
$server->setSelectedAlgorithms(['RS256', 'RS512', 'PS256', 'PS512', 'ES256', 'ES512']);
|
||||
}
|
||||
|
||||
return $server;
|
||||
}
|
||||
}
|
||||
648
plugins/system/webauthn/src/CredentialRepository.php
Normal file
648
plugins/system/webauthn/src/CredentialRepository.php
Normal file
@ -0,0 +1,648 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn;
|
||||
|
||||
use Joomla\CMS\Date\Date;
|
||||
use Joomla\CMS\Encrypt\Aes;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\User\UserFactoryInterface;
|
||||
use Joomla\Database\DatabaseAwareInterface;
|
||||
use Joomla\Database\DatabaseAwareTrait;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
use Joomla\Plugin\System\Webauthn\Extension\Webauthn;
|
||||
use Joomla\Registry\Registry;
|
||||
use Webauthn\PublicKeyCredentialSource;
|
||||
use Webauthn\PublicKeyCredentialSourceRepository;
|
||||
use Webauthn\PublicKeyCredentialUserEntity;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Handles the storage of WebAuthn credentials in the database
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
final class CredentialRepository implements PublicKeyCredentialSourceRepository, DatabaseAwareInterface
|
||||
{
|
||||
use DatabaseAwareTrait;
|
||||
|
||||
/**
|
||||
* Public constructor.
|
||||
*
|
||||
* @param DatabaseInterface|null $db The database driver object to use for persistence.
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function __construct(DatabaseInterface $db = null)
|
||||
{
|
||||
$this->setDatabase($db);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a PublicKeyCredentialSource object given the public key credential ID
|
||||
*
|
||||
* @param string $publicKeyCredentialId The identified of the public key credential we're searching for
|
||||
*
|
||||
* @return PublicKeyCredentialSource|null
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
|
||||
{
|
||||
/** @var DatabaseInterface $db */
|
||||
$db = $this->getDatabase();
|
||||
$credentialId = base64_encode($publicKeyCredentialId);
|
||||
$query = $db->getQuery(true)
|
||||
->select($db->quoteName('credential'))
|
||||
->from($db->quoteName('#__webauthn_credentials'))
|
||||
->where($db->quoteName('id') . ' = :credentialId')
|
||||
->bind(':credentialId', $credentialId);
|
||||
|
||||
$encrypted = $db->setQuery($query)->loadResult();
|
||||
|
||||
if (empty($encrypted)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = $this->decryptCredential($encrypted);
|
||||
|
||||
try {
|
||||
return PublicKeyCredentialSource::createFromArray(json_decode($json, true));
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all PublicKeyCredentialSource objects given a user entity. We only use the `id` property of the user
|
||||
* entity, cast to integer, as the Joomla user ID by which records are keyed in the database table.
|
||||
*
|
||||
* @param PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity Public key credential user entity record
|
||||
*
|
||||
* @return PublicKeyCredentialSource[]
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
|
||||
{
|
||||
/** @var DatabaseInterface $db */
|
||||
$db = $this->getDatabase();
|
||||
$userHandle = $publicKeyCredentialUserEntity->getId();
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__webauthn_credentials'))
|
||||
->where($db->quoteName('user_id') . ' = :user_id')
|
||||
->bind(':user_id', $userHandle);
|
||||
|
||||
try {
|
||||
$records = $db->setQuery($query)->loadAssocList();
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts invalid credential records to PublicKeyCredentialSource objects, or null if they
|
||||
* are invalid.
|
||||
*
|
||||
* This closure is defined as a variable to prevent PHP-CS from getting a stoke trying to
|
||||
* figure out the correct indentation :)
|
||||
*
|
||||
* @param array $record The record to convert
|
||||
*
|
||||
* @return PublicKeyCredentialSource|null
|
||||
*/
|
||||
$recordsMapperClosure = function ($record) {
|
||||
try {
|
||||
$json = $this->decryptCredential($record['credential']);
|
||||
$data = json_decode($json, true);
|
||||
} catch (\JsonException $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($data)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return PublicKeyCredentialSource::createFromArray($data);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
$records = array_map($recordsMapperClosure, $records);
|
||||
|
||||
/**
|
||||
* Filters the list of records to only keep valid entries.
|
||||
*
|
||||
* Only array members that are PublicKeyCredentialSource objects survive the filter.
|
||||
*
|
||||
* This closure is defined as a variable to prevent PHP-CS from getting a stoke trying to
|
||||
* figure out the correct indentation :)
|
||||
*
|
||||
* @param PublicKeyCredentialSource|mixed $record The record to filter
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
$filterClosure = function ($record) {
|
||||
return !\is_null($record) && \is_object($record) && ($record instanceof PublicKeyCredentialSource);
|
||||
};
|
||||
|
||||
return array_filter($records, $filterClosure);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update an attested credential for a given user.
|
||||
*
|
||||
* @param PublicKeyCredentialSource $publicKeyCredentialSource The public key credential
|
||||
* source to store
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
|
||||
{
|
||||
// Default values for saving a new credential source
|
||||
/** @var Webauthn $plugin */
|
||||
$plugin = Factory::getApplication()->bootPlugin('webauthn', 'system');
|
||||
$knownAuthenticators = $plugin->getAuthenticationHelper()->getKnownAuthenticators();
|
||||
$aaguid = (string) ($publicKeyCredentialSource->getAaguid() ?? '');
|
||||
$defaultName = ($knownAuthenticators[$aaguid] ?? $knownAuthenticators[''])->description;
|
||||
$credentialId = base64_encode($publicKeyCredentialSource->getPublicKeyCredentialId());
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
$o = (object) [
|
||||
'id' => $credentialId,
|
||||
'user_id' => $this->getHandleFromUserId($user->id),
|
||||
'label' => Text::sprintf(
|
||||
'PLG_SYSTEM_WEBAUTHN_LBL_DEFAULT_AUTHENTICATOR_LABEL',
|
||||
$defaultName,
|
||||
$this->formatDate('now')
|
||||
),
|
||||
'credential' => json_encode($publicKeyCredentialSource),
|
||||
];
|
||||
$update = false;
|
||||
|
||||
/** @var DatabaseInterface $db */
|
||||
$db = $this->getDatabase();
|
||||
|
||||
// Try to find an existing record
|
||||
try {
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__webauthn_credentials'))
|
||||
->where($db->quoteName('id') . ' = :credentialId')
|
||||
->bind(':credentialId', $credentialId);
|
||||
$oldRecord = $db->setQuery($query)->loadObject();
|
||||
|
||||
if (\is_null($oldRecord)) {
|
||||
throw new \Exception('This is a new record');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanity check. The existing credential source must have the same user handle as the one I am trying to
|
||||
* save. Otherwise something fishy is going on.
|
||||
*/
|
||||
if ($oldRecord->user_id != $publicKeyCredentialSource->getUserHandle()) {
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREDENTIAL_ID_ALREADY_IN_USE'));
|
||||
}
|
||||
|
||||
$o->user_id = $oldRecord->user_id;
|
||||
$o->label = $oldRecord->label;
|
||||
$update = true;
|
||||
} catch (\Exception $e) {
|
||||
}
|
||||
|
||||
$o->credential = $this->encryptCredential($o->credential);
|
||||
|
||||
if ($update) {
|
||||
$db->updateObject('#__webauthn_credentials', $o, ['id']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* This check is deliberately skipped for updates. When logging in the underlying library will try to save the
|
||||
* credential source. This is necessary to update the last known authenticator signature counter which prevents
|
||||
* replay attacks. When we are saving a new record, though, we have to make sure we are not a guest user. Hence
|
||||
* the check below.
|
||||
*/
|
||||
if ((\is_null($user) || $user->guest)) {
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CANT_STORE_FOR_GUEST'));
|
||||
}
|
||||
|
||||
$db->insertObject('#__webauthn_credentials', $o);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all credential information for a given user ID. This is meant to only be used for displaying records.
|
||||
*
|
||||
* @param int $userId The user ID
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function getAll(int $userId): array
|
||||
{
|
||||
/** @var DatabaseInterface $db */
|
||||
$db = $this->getDatabase();
|
||||
$userHandle = $this->getHandleFromUserId($userId);
|
||||
$query = $db->getQuery(true)
|
||||
->select('*')
|
||||
->from($db->quoteName('#__webauthn_credentials'))
|
||||
->where($db->quoteName('user_id') . ' = :user_id')
|
||||
->bind(':user_id', $userHandle);
|
||||
|
||||
try {
|
||||
$results = $db->setQuery($query)->loadAssocList();
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (empty($results)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the credentials on each record.
|
||||
*
|
||||
* @param array $record The record to convert
|
||||
*
|
||||
* @return array
|
||||
* @since 4.2.0
|
||||
*/
|
||||
$recordsMapperClosure = function ($record) {
|
||||
try {
|
||||
$json = $this->decryptCredential($record['credential']);
|
||||
$data = json_decode($json, true);
|
||||
} catch (\JsonException $e) {
|
||||
$record['credential'] = null;
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
if (empty($data)) {
|
||||
$record['credential'] = null;
|
||||
|
||||
return $record;
|
||||
}
|
||||
|
||||
try {
|
||||
$record['credential'] = PublicKeyCredentialSource::createFromArray($data);
|
||||
|
||||
return $record;
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$record['credential'] = null;
|
||||
|
||||
return $record;
|
||||
}
|
||||
};
|
||||
|
||||
return array_map($recordsMapperClosure, $results);
|
||||
}
|
||||
|
||||
/**
|
||||
* Do we have stored credentials under the specified Credential ID?
|
||||
*
|
||||
* @param string $credentialId The ID of the credential to check for existence
|
||||
*
|
||||
* @return boolean
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function has(string $credentialId): bool
|
||||
{
|
||||
/** @var DatabaseInterface $db */
|
||||
$db = $this->getDatabase();
|
||||
$credentialId = base64_encode($credentialId);
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__webauthn_credentials'))
|
||||
->where($db->quoteName('id') . ' = :credentialId')
|
||||
->bind(':credentialId', $credentialId);
|
||||
|
||||
try {
|
||||
$count = $db->setQuery($query)->loadResult();
|
||||
|
||||
return $count > 0;
|
||||
} catch (\Exception $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the human readable label of a credential
|
||||
*
|
||||
* @param string $credentialId The credential ID
|
||||
* @param string $label The human readable label to set
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function setLabel(string $credentialId, string $label): void
|
||||
{
|
||||
/** @var DatabaseInterface $db */
|
||||
$db = $this->getDatabase();
|
||||
$credentialId = base64_encode($credentialId);
|
||||
$o = (object) [
|
||||
'id' => $credentialId,
|
||||
'label' => $label,
|
||||
];
|
||||
|
||||
$db->updateObject('#__webauthn_credentials', $o, ['id'], false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove stored credentials
|
||||
*
|
||||
* @param string $credentialId The credentials ID to remove
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function remove(string $credentialId): void
|
||||
{
|
||||
if (!$this->has($credentialId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var DatabaseInterface $db */
|
||||
$db = $this->getDatabase();
|
||||
$credentialId = base64_encode($credentialId);
|
||||
$query = $db->getQuery(true)
|
||||
->delete($db->quoteName('#__webauthn_credentials'))
|
||||
->where($db->quoteName('id') . ' = :credentialId')
|
||||
->bind(':credentialId', $credentialId);
|
||||
|
||||
$db->setQuery($query)->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the user handle for the stored credential given its ID.
|
||||
*
|
||||
* The user handle must not be personally identifiable. Per https://w3c.github.io/webauthn/#user-handle it is
|
||||
* acceptable to have a salted hash with a salt private to our server, e.g. Joomla's secret. The only immutable
|
||||
* information in Joomla is the user ID so that's what we will be using.
|
||||
*
|
||||
* @param string $credentialId The credential ID to get the user handle for
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function getUserHandleFor(string $credentialId): string
|
||||
{
|
||||
$publicKeyCredentialSource = $this->findOneByCredentialId($credentialId);
|
||||
|
||||
if (empty($publicKeyCredentialSource)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $publicKeyCredentialSource->getUserHandle();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a user handle given an integer Joomla user ID. We use the HMAC-SHA-256 of the user ID with the site's
|
||||
* secret as the key. Using it instead of SHA-512 is on purpose! WebAuthn only allows user handles up to 64 bytes
|
||||
* long.
|
||||
*
|
||||
* @param int $id The user ID to convert
|
||||
*
|
||||
* @return string The user handle (HMAC-SHA-256 of the user ID)
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function getHandleFromUserId(int $id): string
|
||||
{
|
||||
$key = $this->getEncryptionKey();
|
||||
$data = sprintf('%010u', $id);
|
||||
|
||||
return hash_hmac('sha256', $data, $key, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user ID from the user handle
|
||||
*
|
||||
* This is a VERY inefficient method. Since the user handle is an HMAC-SHA-256 of the user ID we can't just go
|
||||
* directly from a handle back to an ID. We have to iterate all user IDs, calculate their handles and compare them
|
||||
* to the given handle.
|
||||
*
|
||||
* To prevent a lengthy infinite loop in case of an invalid user handle we don't iterate the entire 2+ billion valid
|
||||
* 32-bit integer range. We load the user IDs of active users (not blocked, not pending activation) and iterate
|
||||
* through them.
|
||||
*
|
||||
* To avoid memory outage on large sites with thousands of active user records we load up to 10000 users at a time.
|
||||
* Each block of 10,000 user IDs takes about 60-80 msec to iterate. On a site with 200,000 active users this method
|
||||
* will take less than 1.5 seconds. This is slow but not impractical, even on crowded shared hosts with a quarter of
|
||||
* the performance of my test subject (a mid-range, shared hosting server).
|
||||
*
|
||||
* @param string|null $userHandle The user handle which will be converted to a user ID.
|
||||
*
|
||||
* @return integer|null
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function getUserIdFromHandle(?string $userHandle): ?int
|
||||
{
|
||||
if (empty($userHandle)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var DatabaseInterface $db */
|
||||
$db = $this->getDatabase();
|
||||
|
||||
// Check that the userHandle does exist in the database
|
||||
$query = $db->getQuery(true)
|
||||
->select('COUNT(*)')
|
||||
->from($db->quoteName('#__webauthn_credentials'))
|
||||
->where($db->quoteName('user_id') . ' = ' . $db->q($userHandle));
|
||||
|
||||
try {
|
||||
$numRecords = $db->setQuery($query)->loadResult();
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (\is_null($numRecords) || ($numRecords < 1)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prepare the query
|
||||
$query = $db->getQuery(true)
|
||||
->select([$db->quoteName('id')])
|
||||
->from($db->quoteName('#__users'))
|
||||
->where($db->quoteName('block') . ' = 0')
|
||||
->where(
|
||||
'(' .
|
||||
$db->quoteName('activation') . ' IS NULL OR ' .
|
||||
$db->quoteName('activation') . ' = 0 OR ' .
|
||||
$db->quoteName('activation') . ' = ' . $db->q('') .
|
||||
')'
|
||||
);
|
||||
|
||||
$key = $this->getEncryptionKey();
|
||||
$start = 0;
|
||||
$limit = 10000;
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
$ids = $db->setQuery($query, $start, $limit)->loadColumn();
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (empty($ids)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach ($ids as $userId) {
|
||||
$data = sprintf('%010u', $userId);
|
||||
$thisHandle = hash_hmac('sha256', $data, $key, false);
|
||||
|
||||
if ($thisHandle == $userHandle) {
|
||||
return $userId;
|
||||
}
|
||||
}
|
||||
|
||||
$start += $limit;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt the credential source before saving it to the database
|
||||
*
|
||||
* @param string $credential The unencrypted, JSON-encoded credential source
|
||||
*
|
||||
* @return string The encrypted credential source, base64 encoded
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
private function encryptCredential(string $credential): string
|
||||
{
|
||||
$key = $this->getEncryptionKey();
|
||||
|
||||
if (empty($key)) {
|
||||
return $credential;
|
||||
}
|
||||
|
||||
$aes = new Aes($key, 256);
|
||||
|
||||
return $aes->encryptString($credential);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt the credential source if it was already encrypted in the database
|
||||
*
|
||||
* @param string $credential The encrypted credential source, base64 encoded
|
||||
*
|
||||
* @return string The decrypted, JSON-encoded credential source
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
private function decryptCredential(string $credential): string
|
||||
{
|
||||
$key = $this->getEncryptionKey();
|
||||
|
||||
if (empty($key)) {
|
||||
return $credential;
|
||||
}
|
||||
|
||||
// Was the credential stored unencrypted (e.g. the site's secret was empty)?
|
||||
if ((strpos($credential, '{') !== false) && (strpos($credential, '"publicKeyCredentialId"') !== false)) {
|
||||
return $credential;
|
||||
}
|
||||
|
||||
$aes = new Aes($key, 256);
|
||||
|
||||
return $aes->decryptString($credential);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the site's secret, used as an encryption key
|
||||
*
|
||||
* @return string
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
private function getEncryptionKey(): string
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
/** @var Registry $config */
|
||||
$config = $app->getConfig();
|
||||
$secret = $config->get('secret', '');
|
||||
} catch (\Exception $e) {
|
||||
$secret = '';
|
||||
}
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date for display.
|
||||
*
|
||||
* The $tzAware parameter defines whether the formatted date will be timezone-aware. If set to false the formatted
|
||||
* date will be rendered in the UTC timezone. If set to true the code will automatically try to use the logged in
|
||||
* user's timezone or, if none is set, the site's default timezone (Server Timezone). If set to a positive integer
|
||||
* the same thing will happen but for the specified user ID instead of the currently logged in user.
|
||||
*
|
||||
* @param string|\DateTime $date The date to format
|
||||
* @param string|null $format The format string, default is Joomla's DATE_FORMAT_LC6 (usually "Y-m-d
|
||||
* H:i:s")
|
||||
* @param bool $tzAware Should the format be timezone aware? See notes above.
|
||||
*
|
||||
* @return string
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function formatDate($date, ?string $format = null, bool $tzAware = true): string
|
||||
{
|
||||
$utcTimeZone = new \DateTimeZone('UTC');
|
||||
$jDate = new Date($date, $utcTimeZone);
|
||||
|
||||
// Which timezone should I use?
|
||||
$tz = null;
|
||||
|
||||
if ($tzAware !== false) {
|
||||
$userId = \is_bool($tzAware) ? null : (int) $tzAware;
|
||||
|
||||
try {
|
||||
$tzDefault = Factory::getApplication()->get('offset');
|
||||
} catch (\Exception $e) {
|
||||
$tzDefault = 'GMT';
|
||||
}
|
||||
|
||||
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId ?? 0);
|
||||
$tz = $user->getParam('timezone', $tzDefault);
|
||||
}
|
||||
|
||||
if (!empty($tz)) {
|
||||
try {
|
||||
$userTimeZone = new \DateTimeZone($tz);
|
||||
|
||||
$jDate->setTimezone($userTimeZone);
|
||||
} catch (\Exception $e) {
|
||||
// Nothing. Fall back to UTC.
|
||||
}
|
||||
}
|
||||
|
||||
if (empty($format)) {
|
||||
$format = Text::_('DATE_FORMAT_LC6');
|
||||
}
|
||||
|
||||
return $jDate->format($format, true);
|
||||
}
|
||||
}
|
||||
180
plugins/system/webauthn/src/Extension/Webauthn.php
Normal file
180
plugins/system/webauthn/src/Extension/Webauthn.php
Normal file
@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\Extension;
|
||||
|
||||
use Joomla\CMS\Event\CoreEventAware;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\CMSPlugin;
|
||||
use Joomla\Event\DispatcherInterface;
|
||||
use Joomla\Event\SubscriberInterface;
|
||||
use Joomla\Plugin\System\Webauthn\Authentication;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\AdditionalLoginButtons;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandler;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerChallenge;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerCreate;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerDelete;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerInitCreate;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerLogin;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\AjaxHandlerSaveLabel;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\EventReturnAware;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\UserDeletion;
|
||||
use Joomla\Plugin\System\Webauthn\PluginTraits\UserProfileFields;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* WebAuthn Passwordless Login plugin
|
||||
*
|
||||
* The plugin features are broken down into Traits for the sole purpose of making an otherwise
|
||||
* supermassive class somewhat manageable. You can find the Traits inside the Webauthn/PluginTraits
|
||||
* folder.
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
final class Webauthn extends CMSPlugin implements SubscriberInterface
|
||||
{
|
||||
// Add WebAuthn buttons
|
||||
use AdditionalLoginButtons;
|
||||
|
||||
// AJAX request handlers
|
||||
use AjaxHandler;
|
||||
use AjaxHandlerInitCreate;
|
||||
use AjaxHandlerCreate;
|
||||
use AjaxHandlerSaveLabel;
|
||||
use AjaxHandlerDelete;
|
||||
use AjaxHandlerChallenge;
|
||||
use AjaxHandlerLogin;
|
||||
|
||||
// Utility methods for setting the events' return values
|
||||
use EventReturnAware;
|
||||
use CoreEventAware;
|
||||
|
||||
// Custom user profile fields
|
||||
use UserProfileFields;
|
||||
|
||||
// Handle user profile deletion
|
||||
use UserDeletion;
|
||||
|
||||
/**
|
||||
* Should I try to detect and register legacy event listeners, i.e. methods which accept unwrapped arguments? While
|
||||
* this maintains a great degree of backwards compatibility to Joomla! 3.x-style plugins it is much slower. You are
|
||||
* advised to implement your plugins using proper Listeners, methods accepting an AbstractEvent as their sole
|
||||
* parameter, for best performance. Also bear in mind that Joomla! 5.x onwards will only allow proper listeners,
|
||||
* removing support for legacy Listeners.
|
||||
*
|
||||
* @var boolean
|
||||
* @since 4.2.0
|
||||
*
|
||||
* @deprecated 4.3 will be removed in 6.0
|
||||
* Implement your plugin methods accepting an AbstractEvent object
|
||||
* Example:
|
||||
* onEventTriggerName(AbstractEvent $event) {
|
||||
* $context = $event->getArgument(...);
|
||||
* }
|
||||
*/
|
||||
protected $allowLegacyListeners = false;
|
||||
|
||||
/**
|
||||
* The WebAuthn authentication helper object
|
||||
*
|
||||
* @var Authentication
|
||||
* @since 4.2.0
|
||||
*/
|
||||
protected $authenticationHelper;
|
||||
|
||||
/**
|
||||
* Constructor. Loads the language files as well.
|
||||
*
|
||||
* @param DispatcherInterface $dispatcher The object to observe
|
||||
* @param array $config An optional associative array of configuration
|
||||
* settings. Recognized key values include 'name',
|
||||
* 'group', 'params', 'language (this list is not meant
|
||||
* to be comprehensive).
|
||||
* @param Authentication|null $authHelper The WebAuthn helper object
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function __construct(DispatcherInterface $dispatcher, array $config = [], Authentication $authHelper = null)
|
||||
{
|
||||
parent::__construct($dispatcher, $config);
|
||||
|
||||
/**
|
||||
* Note: Do NOT try to load the language in the constructor. This is called before Joomla initializes the
|
||||
* application language. Therefore the temporary Joomla language object and all loaded strings in it will be
|
||||
* destroyed on application initialization. As a result we need to call loadLanguage() in each method
|
||||
* individually, even though all methods make use of language strings.
|
||||
*/
|
||||
|
||||
// Register a debug log file writer
|
||||
$logLevels = Log::ERROR | Log::CRITICAL | Log::ALERT | Log::EMERGENCY;
|
||||
|
||||
if (\defined('JDEBUG') && JDEBUG) {
|
||||
$logLevels = Log::ALL;
|
||||
}
|
||||
|
||||
Log::addLogger([
|
||||
'text_file' => "webauthn_system.php",
|
||||
'text_entry_format' => '{DATETIME} {PRIORITY} {CLIENTIP} {MESSAGE}',
|
||||
], $logLevels, ["webauthn.system"]);
|
||||
|
||||
$this->authenticationHelper = $authHelper ?? (new Authentication());
|
||||
$this->authenticationHelper->setAttestationSupport($this->params->get('attestationSupport', 0) == 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the Authentication helper object
|
||||
*
|
||||
* @return Authentication
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function getAuthenticationHelper(): Authentication
|
||||
{
|
||||
return $this->authenticationHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of events this subscriber will listen to.
|
||||
*
|
||||
* @return array
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
try {
|
||||
$app = Factory::getApplication();
|
||||
} catch (\Exception $e) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$app->isClient('site') && !$app->isClient('administrator')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
'onAjaxWebauthn' => 'onAjaxWebauthn',
|
||||
'onAjaxWebauthnChallenge' => 'onAjaxWebauthnChallenge',
|
||||
'onAjaxWebauthnCreate' => 'onAjaxWebauthnCreate',
|
||||
'onAjaxWebauthnDelete' => 'onAjaxWebauthnDelete',
|
||||
'onAjaxWebauthnInitcreate' => 'onAjaxWebauthnInitcreate',
|
||||
'onAjaxWebauthnLogin' => 'onAjaxWebauthnLogin',
|
||||
'onAjaxWebauthnSavelabel' => 'onAjaxWebauthnSavelabel',
|
||||
'onUserAfterDelete' => 'onUserAfterDelete',
|
||||
'onUserLoginButtons' => 'onUserLoginButtons',
|
||||
'onContentPrepareForm' => 'onContentPrepareForm',
|
||||
'onContentPrepareData' => 'onContentPrepareData',
|
||||
];
|
||||
}
|
||||
}
|
||||
83
plugins/system/webauthn/src/Field/WebauthnField.php
Normal file
83
plugins/system/webauthn/src/Field/WebauthnField.php
Normal file
@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\Field;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\FormField;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Layout\FileLayout;
|
||||
use Joomla\CMS\User\UserFactoryInterface;
|
||||
use Joomla\Plugin\System\Webauthn\Extension\Webauthn;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Custom Joomla Form Field to display the WebAuthn interface
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
class WebauthnField extends FormField
|
||||
{
|
||||
/**
|
||||
* Element name
|
||||
*
|
||||
* @var string
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
protected $type = 'Webauthn';
|
||||
|
||||
/**
|
||||
* Returns the input field's HTML
|
||||
*
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function getInput()
|
||||
{
|
||||
$userId = $this->form->getData()->get('id', null);
|
||||
|
||||
if (\is_null($userId)) {
|
||||
return Text::_('PLG_SYSTEM_WEBAUTHN_ERR_NOUSER');
|
||||
}
|
||||
|
||||
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_NO_BROWSER_SUPPORT', true);
|
||||
Text::script('PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_SAVE_LABEL', true);
|
||||
Text::script('PLG_SYSTEM_WEBAUTHN_MANAGE_BTN_CANCEL_LABEL', true);
|
||||
Text::script('PLG_SYSTEM_WEBAUTHN_MSG_SAVED_LABEL', true);
|
||||
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_LABEL_NOT_SAVED', true);
|
||||
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_XHR_INITCREATE', true);
|
||||
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_NOT_DELETED', true);
|
||||
|
||||
$app = Factory::getApplication();
|
||||
/** @var Webauthn $plugin */
|
||||
$plugin = $app->bootPlugin('webauthn', 'system');
|
||||
|
||||
$app->getDocument()->getWebAssetManager()
|
||||
->registerAndUseScript('plg_system_webauthn.management', 'plg_system_webauthn/management.js', [], ['defer' => true], ['core']);
|
||||
|
||||
$layoutFile = new FileLayout('plugins.system.webauthn.manage');
|
||||
|
||||
return $layoutFile->render([
|
||||
'user' => Factory::getContainer()
|
||||
->get(UserFactoryInterface::class)
|
||||
->loadUserById($userId),
|
||||
'allow_add' => $userId == $app->getIdentity()->id,
|
||||
'credentials' => $plugin->getAuthenticationHelper()->getCredentialsRepository()->getAll($userId),
|
||||
'knownAuthenticators' => $plugin->getAuthenticationHelper()->getKnownAuthenticators(),
|
||||
'attestationSupport' => $plugin->getAuthenticationHelper()->hasAttestationSupport(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
179
plugins/system/webauthn/src/MetadataRepository.php
Normal file
179
plugins/system/webauthn/src/MetadataRepository.php
Normal file
@ -0,0 +1,179 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn;
|
||||
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Token\Plain;
|
||||
use Webauthn\MetadataService\MetadataStatementRepository;
|
||||
use Webauthn\MetadataService\Statement\MetadataStatement;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Authenticator metadata repository.
|
||||
*
|
||||
* This repository contains the metadata of all FIDO authenticators as published by the FIDO
|
||||
* Alliance in their MDS version 3.0.
|
||||
*
|
||||
* @link https://fidoalliance.org/metadata/
|
||||
* @since 4.2.0
|
||||
*/
|
||||
final class MetadataRepository implements MetadataStatementRepository
|
||||
{
|
||||
/**
|
||||
* Cache of authenticator metadata statements
|
||||
*
|
||||
* @var MetadataStatement[]
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private $mdsCache = [];
|
||||
|
||||
/**
|
||||
* Map of AAGUID to $mdsCache index
|
||||
*
|
||||
* @var array
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private $mdsMap = [];
|
||||
|
||||
/**
|
||||
* Have I already tried to load the metadata cache?
|
||||
*
|
||||
* @var bool
|
||||
* @since 4.2.2
|
||||
*/
|
||||
private $loaded = false;
|
||||
|
||||
/**
|
||||
* Find an authenticator metadata statement given an AAGUID
|
||||
*
|
||||
* @param string $aaguid The AAGUID to find
|
||||
*
|
||||
* @return MetadataStatement|null The metadata statement; null if the AAGUID is unknown
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function findOneByAAGUID(string $aaguid): ?MetadataStatement
|
||||
{
|
||||
$this->load();
|
||||
|
||||
$idx = $this->mdsMap[$aaguid] ?? null;
|
||||
|
||||
return $idx ? $this->mdsCache[$idx] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get basic information of the known FIDO authenticators by AAGUID
|
||||
*
|
||||
* @return object[]
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function getKnownAuthenticators(): array
|
||||
{
|
||||
$this->load();
|
||||
|
||||
$mapKeys = function (MetadataStatement $meta) {
|
||||
return $meta->getAaguid();
|
||||
};
|
||||
$mapvalues = function (MetadataStatement $meta) {
|
||||
return $meta->getAaguid() ? (object) [
|
||||
'description' => $meta->getDescription(),
|
||||
'icon' => $meta->getIcon(),
|
||||
] : null;
|
||||
};
|
||||
$keys = array_map($mapKeys, $this->mdsCache);
|
||||
$values = array_map($mapvalues, $this->mdsCache);
|
||||
$return = array_combine($keys, $values) ?: [];
|
||||
|
||||
$filter = function ($x) {
|
||||
return !empty($x);
|
||||
};
|
||||
|
||||
return array_filter($return, $filter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the authenticator metadata cache
|
||||
*
|
||||
* @return void
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function load(): void
|
||||
{
|
||||
if ($this->loaded) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->loaded = true;
|
||||
|
||||
$this->mdsCache = [];
|
||||
$this->mdsMap = [];
|
||||
|
||||
$jwtFilename = JPATH_PLUGINS . '/system/webauthn/fido.jwt';
|
||||
$rawJwt = file_get_contents($jwtFilename);
|
||||
|
||||
if (!\is_string($rawJwt) || \strlen($rawJwt) < 1024) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$jwtConfig = Configuration::forUnsecuredSigner();
|
||||
$token = $jwtConfig->parser()->parse($rawJwt);
|
||||
} catch (\Exception $e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!($token instanceof Plain)) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($rawJwt);
|
||||
|
||||
$entriesMapper = function (object $entry) {
|
||||
try {
|
||||
$array = json_decode(json_encode($entry->metadataStatement), true);
|
||||
|
||||
/**
|
||||
* This prevents an error when we're asking for attestation on authenticators which
|
||||
* don't allow it. We are really not interested in the attestation per se, but
|
||||
* requiring an attestation is the only way we can get the AAGUID of the
|
||||
* authenticator.
|
||||
*/
|
||||
if (isset($array['attestationTypes'])) {
|
||||
unset($array['attestationTypes']);
|
||||
}
|
||||
|
||||
return MetadataStatement::createFromArray($array);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
$entries = array_map($entriesMapper, $token->claims()->get('entries', []));
|
||||
|
||||
unset($token);
|
||||
|
||||
$entriesFilter = function ($x) {
|
||||
return !empty($x);
|
||||
};
|
||||
$this->mdsCache = array_filter($entries, $entriesFilter);
|
||||
|
||||
foreach ($this->mdsCache as $idx => $meta) {
|
||||
$aaguid = $meta->getAaguid();
|
||||
|
||||
if (empty($aaguid)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->mdsMap[$aaguid] = $idx;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,202 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Joomla\CMS\Application\CMSApplication;
|
||||
use Joomla\CMS\Document\HtmlDocument;
|
||||
use Joomla\CMS\Helper\AuthenticationHelper;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\CMS\User\UserHelper;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Inserts Webauthn buttons into login modules
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
trait AdditionalLoginButtons
|
||||
{
|
||||
/**
|
||||
* Do I need to inject buttons? Automatically detected (i.e. disabled if I'm already logged
|
||||
* in).
|
||||
*
|
||||
* @var boolean|null
|
||||
* @since 4.0.0
|
||||
*/
|
||||
protected $allowButtonDisplay = null;
|
||||
|
||||
/**
|
||||
* Have I already injected CSS and JavaScript? Prevents double inclusion of the same files.
|
||||
*
|
||||
* @var boolean
|
||||
* @since 4.0.0
|
||||
*/
|
||||
private $injectedCSSandJS = false;
|
||||
|
||||
/**
|
||||
* Creates additional login buttons
|
||||
*
|
||||
* @param Event $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @see AuthenticationHelper::getLoginButtons()
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function onUserLoginButtons(Event $event): void
|
||||
{
|
||||
/** @var string $form The HTML ID of the form we are enclosed in */
|
||||
[$form] = array_values($event->getArguments());
|
||||
|
||||
// If we determined we should not inject a button return early
|
||||
if (!$this->mustDisplayButton()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load plugin language files
|
||||
$this->loadLanguage();
|
||||
|
||||
// Load necessary CSS and Javascript files
|
||||
$this->addLoginCSSAndJavascript();
|
||||
|
||||
// Unique ID for this button (allows display of multiple modules on the page)
|
||||
$randomId = 'plg_system_webauthn-' .
|
||||
UserHelper::genRandomPassword(12) . '-' . UserHelper::genRandomPassword(8);
|
||||
|
||||
// Get local path to image
|
||||
$image = HTMLHelper::_('image', 'plg_system_webauthn/fido-passkey-black.svg', '', '', true, true);
|
||||
|
||||
// If you can't find the image then skip it
|
||||
$image = $image ? JPATH_ROOT . substr($image, \strlen(Uri::root(true))) : '';
|
||||
|
||||
// Extract image if it exists
|
||||
$image = file_exists($image) ? file_get_contents($image) : '';
|
||||
|
||||
$this->returnFromEvent($event, [
|
||||
[
|
||||
'label' => 'PLG_SYSTEM_WEBAUTHN_LOGIN_LABEL',
|
||||
'tooltip' => 'PLG_SYSTEM_WEBAUTHN_LOGIN_DESC',
|
||||
'id' => $randomId,
|
||||
'data-webauthn-form' => $form,
|
||||
'svg' => $image,
|
||||
'class' => 'plg_system_webauthn_login_button',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Should I allow this plugin to add a WebAuthn login button?
|
||||
*
|
||||
* @return boolean
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
private function mustDisplayButton(): bool
|
||||
{
|
||||
// We must have a valid application
|
||||
if (!($this->getApplication() instanceof CMSApplication)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// This plugin only applies to the frontend and administrator applications
|
||||
if (!$this->getApplication()->isClient('site') && !$this->getApplication()->isClient('administrator')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// We must have a valid user
|
||||
if (empty($this->getApplication()->getIdentity())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (\is_null($this->allowButtonDisplay)) {
|
||||
$this->allowButtonDisplay = false;
|
||||
|
||||
/**
|
||||
* Do not add a WebAuthn login button if we are already logged in
|
||||
*/
|
||||
if (!$this->getApplication()->getIdentity()->guest) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Only display a button on HTML output
|
||||
*/
|
||||
try {
|
||||
$document = $this->getApplication()->getDocument();
|
||||
} catch (\Exception $e) {
|
||||
$document = null;
|
||||
}
|
||||
|
||||
if (!($document instanceof HtmlDocument)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebAuthn only works on HTTPS. This is a security-related limitation of the W3C Web Authentication
|
||||
* specification, not an issue with this plugin :)
|
||||
*/
|
||||
if (!Uri::getInstance()->isSsl()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// All checks passed; we should allow displaying a WebAuthn login button
|
||||
$this->allowButtonDisplay = true;
|
||||
}
|
||||
|
||||
return $this->allowButtonDisplay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the WebAuthn CSS and Javascript for frontend logins, but only once per page load.
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
private function addLoginCSSAndJavascript(): void
|
||||
{
|
||||
if ($this->injectedCSSandJS) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set the "don't load again" flag
|
||||
$this->injectedCSSandJS = true;
|
||||
|
||||
/** @var \Joomla\CMS\WebAsset\WebAssetManager $wa */
|
||||
$wa = $this->getApplication()->getDocument()->getWebAssetManager();
|
||||
|
||||
if (!$wa->assetExists('style', 'plg_system_webauthn.button')) {
|
||||
$wa->registerStyle('plg_system_webauthn.button', 'plg_system_webauthn/button.css');
|
||||
}
|
||||
|
||||
if (!$wa->assetExists('script', 'plg_system_webauthn.login')) {
|
||||
$wa->registerScript('plg_system_webauthn.login', 'plg_system_webauthn/login.js', [], ['defer' => true], ['core']);
|
||||
}
|
||||
|
||||
$wa->useStyle('plg_system_webauthn.button')
|
||||
->useScript('plg_system_webauthn.login');
|
||||
|
||||
// Load language strings client-side
|
||||
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_CANNOT_FIND_USERNAME');
|
||||
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_EMPTY_USERNAME');
|
||||
Text::script('PLG_SYSTEM_WEBAUTHN_ERR_INVALID_USERNAME');
|
||||
|
||||
// Store the current URL as the default return URL after login (or failure)
|
||||
$this->getApplication()->getSession()->set('plg_system_webauthn.returnUrl', Uri::current());
|
||||
}
|
||||
}
|
||||
186
plugins/system/webauthn/src/PluginTraits/AjaxHandler.php
Normal file
186
plugins/system/webauthn/src/PluginTraits/AjaxHandler.php
Normal file
@ -0,0 +1,186 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Joomla\CMS\Application\CMSApplication;
|
||||
use Joomla\CMS\Event\AbstractEvent;
|
||||
use Joomla\CMS\Event\GenericEvent;
|
||||
use Joomla\CMS\Event\Plugin\AjaxEvent;
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\Ajax as PlgSystemWebauthnAjax;
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxChallenge as PlgSystemWebauthnAjaxChallenge;
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxCreate as PlgSystemWebauthnAjaxCreate;
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxDelete as PlgSystemWebauthnAjaxDelete;
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxInitCreate as PlgSystemWebauthnAjaxInitCreate;
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxLogin as PlgSystemWebauthnAjaxLogin;
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxSaveLabel as PlgSystemWebauthnAjaxSaveLabel;
|
||||
use Joomla\CMS\Event\Result\ResultAwareInterface;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Allows the plugin to handle AJAX requests in the backend of the site, where com_ajax is not
|
||||
* available when we are not logged in.
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
trait AjaxHandler
|
||||
{
|
||||
/**
|
||||
* Processes the callbacks from the passwordless login views.
|
||||
*
|
||||
* Note: this method is called from Joomla's com_ajax or, in the case of backend logins,
|
||||
* through the special onAfterInitialize handler we have created to work around com_ajax usage
|
||||
* limitations in the backend.
|
||||
*
|
||||
* @param AjaxEvent $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function onAjaxWebauthn(AjaxEvent $event): void
|
||||
{
|
||||
$input = $this->getApplication()->getInput();
|
||||
|
||||
// Get the return URL from the session
|
||||
$returnURL = $this->getApplication()->getSession()->get('plg_system_webauthn.returnUrl', Uri::base());
|
||||
$result = null;
|
||||
|
||||
// Load plugin language files
|
||||
$this->loadLanguage();
|
||||
|
||||
try {
|
||||
Log::add("Received AJAX callback.", Log::DEBUG, 'webauthn.system');
|
||||
|
||||
if (!($this->getApplication() instanceof CMSApplication)) {
|
||||
Log::add("This is not a CMS application", Log::NOTICE, 'webauthn.system');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$akaction = $input->getCmd('akaction');
|
||||
|
||||
if (!$this->getApplication()->checkToken('request')) {
|
||||
throw new \RuntimeException(Text::_('JERROR_ALERTNOAUTHOR'));
|
||||
}
|
||||
|
||||
// Empty action? No bueno.
|
||||
if (empty($akaction)) {
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_AJAX_INVALIDACTION'));
|
||||
}
|
||||
|
||||
// Call the plugin event onAjaxWebauthnSomething where Something is the akaction param.
|
||||
/** @var AbstractEvent|ResultAwareInterface $triggerEvent */
|
||||
$eventName = 'onAjaxWebauthn' . ucfirst($akaction);
|
||||
|
||||
switch ($eventName) {
|
||||
case 'onAjaxWebauthn':
|
||||
$eventClass = PlgSystemWebauthnAjax::class;
|
||||
break;
|
||||
|
||||
case 'onAjaxWebauthnChallenge':
|
||||
$eventClass = PlgSystemWebauthnAjaxChallenge::class;
|
||||
break;
|
||||
|
||||
case 'onAjaxWebauthnCreate':
|
||||
$eventClass = PlgSystemWebauthnAjaxCreate::class;
|
||||
break;
|
||||
|
||||
case 'onAjaxWebauthnDelete':
|
||||
$eventClass = PlgSystemWebauthnAjaxDelete::class;
|
||||
break;
|
||||
|
||||
case 'onAjaxWebauthnInitcreate':
|
||||
$eventClass = PlgSystemWebauthnAjaxInitCreate::class;
|
||||
break;
|
||||
|
||||
case 'onAjaxWebauthnLogin':
|
||||
$eventClass = PlgSystemWebauthnAjaxLogin::class;
|
||||
break;
|
||||
|
||||
case 'onAjaxWebauthnSavelabel':
|
||||
$eventClass = PlgSystemWebauthnAjaxSaveLabel::class;
|
||||
break;
|
||||
|
||||
default:
|
||||
$eventClass = GenericEvent::class;
|
||||
break;
|
||||
}
|
||||
|
||||
$triggerEvent = new $eventClass($eventName, []);
|
||||
$result = $this->getApplication()->getDispatcher()->dispatch($eventName, $triggerEvent);
|
||||
$results = ($result instanceof ResultAwareInterface) ? ($result['result'] ?? []) : [];
|
||||
$result = array_reduce(
|
||||
$results,
|
||||
function ($carry, $result) {
|
||||
return $carry ?? $result;
|
||||
},
|
||||
null
|
||||
);
|
||||
} catch (\Exception $e) {
|
||||
Log::add("Callback failure, redirecting to $returnURL.", Log::DEBUG, 'webauthn.system');
|
||||
$this->getApplication()->getSession()->set('plg_system_webauthn.returnUrl', null);
|
||||
$this->getApplication()->enqueueMessage($e->getMessage(), 'error');
|
||||
$this->getApplication()->redirect($returnURL);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!\is_null($result)) {
|
||||
switch ($input->getCmd('encoding', 'json')) {
|
||||
case 'raw':
|
||||
Log::add("Callback complete, returning raw response.", Log::DEBUG, 'webauthn.system');
|
||||
echo $result;
|
||||
|
||||
break;
|
||||
|
||||
case 'redirect':
|
||||
$modifiers = '';
|
||||
|
||||
if (isset($result['message'])) {
|
||||
$type = $result['type'] ?? 'info';
|
||||
$this->getApplication()->enqueueMessage($result['message'], $type);
|
||||
|
||||
$modifiers = " and setting a system message of type $type";
|
||||
}
|
||||
|
||||
if (isset($result['url'])) {
|
||||
Log::add("Callback complete, performing redirection to {$result['url']}{$modifiers}.", Log::DEBUG, 'webauthn.system');
|
||||
$this->getApplication()->redirect($result['url']);
|
||||
}
|
||||
|
||||
Log::add("Callback complete, performing redirection to {$result}{$modifiers}.", Log::DEBUG, 'webauthn.system');
|
||||
$this->getApplication()->redirect($result);
|
||||
|
||||
return;
|
||||
|
||||
default:
|
||||
Log::add("Callback complete, returning JSON.", Log::DEBUG, 'webauthn.system');
|
||||
echo json_encode($result);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
$this->getApplication()->close(200);
|
||||
}
|
||||
|
||||
Log::add("Null response from AJAX callback, redirecting to $returnURL", Log::DEBUG, 'webauthn.system');
|
||||
$this->getApplication()->getSession()->set('plg_system_webauthn.returnUrl', null);
|
||||
|
||||
$this->getApplication()->redirect($returnURL);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxChallenge;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\CMS\User\User;
|
||||
use Joomla\CMS\User\UserFactoryInterface;
|
||||
use Joomla\CMS\User\UserHelper;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Ajax handler for akaction=challenge
|
||||
*
|
||||
* Generates the public key and challenge which is used by the browser when logging in with Webauthn. This is the bit
|
||||
* which prevents tampering with the login process and replay attacks.
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
trait AjaxHandlerChallenge
|
||||
{
|
||||
/**
|
||||
* Returns the public key set for the user and a unique challenge in a Public Key Credential Request encoded as
|
||||
* JSON.
|
||||
*
|
||||
* @param AjaxChallenge $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function onAjaxWebauthnChallenge(AjaxChallenge $event): void
|
||||
{
|
||||
// Initialize objects
|
||||
$session = $this->getApplication()->getSession();
|
||||
$input = $this->getApplication()->getInput();
|
||||
|
||||
// Load plugin language files
|
||||
$this->loadLanguage();
|
||||
|
||||
// Retrieve data from the request
|
||||
$username = $input->getUsername('username', '');
|
||||
$returnUrl = base64_encode(
|
||||
$session->get('plg_system_webauthn.returnUrl', Uri::current())
|
||||
);
|
||||
$returnUrl = $input->getBase64('returnUrl', $returnUrl);
|
||||
$returnUrl = base64_decode($returnUrl);
|
||||
|
||||
// For security reasons the post-login redirection URL must be internal to the site.
|
||||
if (!Uri::isInternal($returnUrl)) {
|
||||
// If the URL wasn't internal redirect to the site's root.
|
||||
$returnUrl = Uri::base();
|
||||
}
|
||||
|
||||
$session->set('plg_system_webauthn.returnUrl', $returnUrl);
|
||||
|
||||
// Do I have a username?
|
||||
if (empty($username)) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Is the username valid?
|
||||
try {
|
||||
$userId = UserHelper::getUserId($username);
|
||||
} catch (\Exception $e) {
|
||||
$userId = 0;
|
||||
}
|
||||
|
||||
if ($userId <= 0) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$myUser = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
|
||||
} catch (\Exception $e) {
|
||||
$myUser = new User();
|
||||
}
|
||||
|
||||
if ($myUser->id != $userId || $myUser->guest) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$publicKeyCredentialRequestOptions = $this->authenticationHelper->getPubkeyRequestOptions($myUser);
|
||||
|
||||
$session->set('plg_system_webauthn.userId', $userId);
|
||||
|
||||
// Return the JSON encoded data to the caller
|
||||
$event->addResult(json_encode($publicKeyCredentialRequestOptions, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE));
|
||||
}
|
||||
}
|
||||
116
plugins/system/webauthn/src/PluginTraits/AjaxHandlerCreate.php
Normal file
116
plugins/system/webauthn/src/PluginTraits/AjaxHandlerCreate.php
Normal file
@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxCreate;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Layout\FileLayout;
|
||||
use Joomla\CMS\User\UserFactoryInterface;
|
||||
use Webauthn\PublicKeyCredentialSource;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Ajax handler for akaction=create
|
||||
*
|
||||
* Handles the browser postback for the credentials creation flow
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
trait AjaxHandlerCreate
|
||||
{
|
||||
/**
|
||||
* Handle the callback to add a new WebAuthn authenticator
|
||||
*
|
||||
* @param AjaxCreate $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function onAjaxWebauthnCreate(AjaxCreate $event): void
|
||||
{
|
||||
// Load plugin language files
|
||||
$this->loadLanguage();
|
||||
|
||||
/**
|
||||
* Fundamental sanity check: this callback is only allowed after a Public Key has been created server-side and
|
||||
* the user it was created for matches the current user.
|
||||
*
|
||||
* This is also checked in the validateAuthenticationData() so why check here? In case we have the wrong user
|
||||
* I need to fail early with a Joomla error page instead of falling through the code and possibly displaying
|
||||
* someone else's Webauthn configuration thus mitigating a major privacy and security risk. So, please, DO NOT
|
||||
* remove this sanity check!
|
||||
*/
|
||||
$session = $this->getApplication()->getSession();
|
||||
$storedUserId = $session->get('plg_system_webauthn.registration_user_id', 0);
|
||||
$thatUser = empty($storedUserId) ?
|
||||
Factory::getApplication()->getIdentity() :
|
||||
Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($storedUserId);
|
||||
$myUser = Factory::getApplication()->getIdentity();
|
||||
|
||||
if ($thatUser->guest || ($thatUser->id != $myUser->id)) {
|
||||
// Unset the session variables used for registering authenticators (security precaution).
|
||||
$session->set('plg_system_webauthn.registration_user_id', null);
|
||||
$session->set('plg_system_webauthn.publicKeyCredentialCreationOptions', null);
|
||||
|
||||
// Politely tell the presumed hacker trying to abuse this callback to go away.
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_USER'));
|
||||
}
|
||||
|
||||
// Get the credentials repository object. It's outside the try-catch because I also need it to display the GUI.
|
||||
$credentialRepository = $this->authenticationHelper->getCredentialsRepository();
|
||||
|
||||
// Try to validate the browser data. If there's an error I won't save anything and pass the message to the GUI.
|
||||
try {
|
||||
$input = $this->getApplication()->getInput();
|
||||
|
||||
// Retrieve the data sent by the device
|
||||
$data = $input->get('data', '', 'raw');
|
||||
|
||||
$publicKeyCredentialSource = $this->authenticationHelper->validateAttestationResponse($data);
|
||||
|
||||
if (!\is_object($publicKeyCredentialSource) || !($publicKeyCredentialSource instanceof PublicKeyCredentialSource)) {
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_NO_ATTESTED_DATA'));
|
||||
}
|
||||
|
||||
$credentialRepository->saveCredentialSource($publicKeyCredentialSource);
|
||||
} catch (\Exception $e) {
|
||||
$error = $e->getMessage();
|
||||
$publicKeyCredentialSource = null;
|
||||
}
|
||||
|
||||
// Unset the session variables used for registering authenticators (security precaution).
|
||||
$session->set('plg_system_webauthn.registration_user_id', null);
|
||||
$session->set('plg_system_webauthn.publicKeyCredentialCreationOptions', null);
|
||||
|
||||
// Render the GUI and return it
|
||||
$layoutParameters = [
|
||||
'user' => $thatUser,
|
||||
'allow_add' => $thatUser->id == $myUser->id,
|
||||
'credentials' => $credentialRepository->getAll($thatUser->id),
|
||||
'knownAuthenticators' => $this->authenticationHelper->getKnownAuthenticators(),
|
||||
'attestationSupport' => $this->authenticationHelper->hasAttestationSupport(),
|
||||
];
|
||||
|
||||
if (isset($error) && !empty($error)) {
|
||||
$layoutParameters['error'] = $error;
|
||||
}
|
||||
|
||||
$layout = new FileLayout('plugins.system.webauthn.manage', JPATH_SITE . '/plugins/system/webauthn/layout');
|
||||
|
||||
$event->addResult($layout->render($layoutParameters));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxDelete;
|
||||
use Joomla\CMS\User\User;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Ajax handler for akaction=delete
|
||||
*
|
||||
* Deletes a security key
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
trait AjaxHandlerDelete
|
||||
{
|
||||
/**
|
||||
* Handle the callback to remove an authenticator
|
||||
*
|
||||
* @param AjaxDelete $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function onAjaxWebauthnDelete(AjaxDelete $event): void
|
||||
{
|
||||
// Load plugin language files
|
||||
$this->loadLanguage();
|
||||
|
||||
// Initialize objects
|
||||
$input = $this->getApplication()->getInput();
|
||||
$repository = $this->authenticationHelper->getCredentialsRepository();
|
||||
|
||||
// Retrieve data from the request
|
||||
$credentialId = $input->getBase64('credential_id', '');
|
||||
|
||||
// Is this a valid credential?
|
||||
if (empty($credentialId)) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$credentialId = base64_decode($credentialId);
|
||||
|
||||
if (empty($credentialId) || !$repository->has($credentialId)) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure I am editing my own key
|
||||
try {
|
||||
$user = $this->getApplication()->getIdentity() ?? new User();
|
||||
$credentialHandle = $repository->getUserHandleFor($credentialId);
|
||||
$myHandle = $repository->getHandleFromUserId($user->id);
|
||||
} catch (\Exception $e) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($credentialHandle !== $myHandle) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete the record
|
||||
try {
|
||||
$repository->remove($credentialId);
|
||||
} catch (\Exception $e) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$event->addResult(true);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxInitCreate;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\User\User;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Ajax handler for akaction=initcreate
|
||||
*
|
||||
* Returns the Public Key Creation Options to start the attestation ceremony on the browser.
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
trait AjaxHandlerInitCreate
|
||||
{
|
||||
/**
|
||||
* Returns the Public Key Creation Options to start the attestation ceremony on the browser.
|
||||
*
|
||||
* @param AjaxInitCreate $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
* @since 4.2.0
|
||||
*/
|
||||
public function onAjaxWebauthnInitcreate(AjaxInitCreate $event): void
|
||||
{
|
||||
// Load plugin language files
|
||||
$this->loadLanguage();
|
||||
|
||||
// Make sure I have a valid user
|
||||
$user = Factory::getApplication()->getIdentity();
|
||||
|
||||
if (!($user instanceof User) || $user->guest) {
|
||||
$event->addResult(new \stdClass());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// I need the server to have either GMP or BCComp support to attest new authenticators
|
||||
if (\function_exists('gmp_intval') === false && \function_exists('bccomp') === false) {
|
||||
$event->addResult(new \stdClass());
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$session = $this->getApplication()->getSession();
|
||||
$session->set('plg_system_webauthn.registration_user_id', $user->id);
|
||||
|
||||
$event->addResult($this->authenticationHelper->getPubKeyCreationOptions($user));
|
||||
}
|
||||
}
|
||||
309
plugins/system/webauthn/src/PluginTraits/AjaxHandlerLogin.php
Normal file
309
plugins/system/webauthn/src/PluginTraits/AjaxHandlerLogin.php
Normal file
@ -0,0 +1,309 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Exception;
|
||||
use Joomla\CMS\Authentication\Authentication;
|
||||
use Joomla\CMS\Authentication\AuthenticationResponse;
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxLogin;
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Plugin\PluginHelper;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\CMS\User\User;
|
||||
use Joomla\CMS\User\UserFactoryInterface;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Ajax handler for akaction=login
|
||||
*
|
||||
* Verifies the response received from the browser and logs in the user
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
trait AjaxHandlerLogin
|
||||
{
|
||||
/**
|
||||
* Returns the public key set for the user and a unique challenge in a Public Key Credential Request encoded as
|
||||
* JSON.
|
||||
*
|
||||
* @param AjaxLogin $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function onAjaxWebauthnLogin(AjaxLogin $event): void
|
||||
{
|
||||
// Load plugin language files
|
||||
$this->loadLanguage();
|
||||
|
||||
$session = $this->getApplication()->getSession();
|
||||
$returnUrl = $session->get('plg_system_webauthn.returnUrl', Uri::base());
|
||||
$userId = $session->get('plg_system_webauthn.userId', 0);
|
||||
|
||||
try {
|
||||
$credentialRepository = $this->authenticationHelper->getCredentialsRepository();
|
||||
|
||||
// No user ID: no username was provided and the resident credential refers to an unknown user handle. DIE!
|
||||
if (empty($userId)) {
|
||||
Log::add('Cannot determine the user ID', Log::NOTICE, 'webauthn.system');
|
||||
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
|
||||
}
|
||||
|
||||
// Do I have a valid user?
|
||||
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
|
||||
|
||||
if ($user->id != $userId) {
|
||||
$message = sprintf('User #%d does not exist', $userId);
|
||||
Log::add($message, Log::NOTICE, 'webauthn.system');
|
||||
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
|
||||
}
|
||||
|
||||
// Validate the authenticator response and get the user handle
|
||||
$userHandle = $this->getUserHandleFromResponse($user);
|
||||
|
||||
if (\is_null($userHandle)) {
|
||||
Log::add('Cannot retrieve the user handle from the request; the browser did not assert our request.', Log::NOTICE, 'webauthn.system');
|
||||
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
|
||||
}
|
||||
|
||||
// Does the user handle match the user ID? This should never trigger by definition of the login check.
|
||||
$validUserHandle = $credentialRepository->getHandleFromUserId($userId);
|
||||
|
||||
if ($userHandle != $validUserHandle) {
|
||||
$message = sprintf('Invalid user handle; expected %s, got %s', $validUserHandle, $userHandle);
|
||||
Log::add($message, Log::NOTICE, 'webauthn.system');
|
||||
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
|
||||
}
|
||||
|
||||
// Make sure the user exists
|
||||
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
|
||||
|
||||
if ($user->id != $userId) {
|
||||
$message = sprintf('Invalid user ID; expected %d, got %d', $userId, $user->id);
|
||||
Log::add($message, Log::NOTICE, 'webauthn.system');
|
||||
|
||||
throw new \RuntimeException(Text::_('PLG_SYSTEM_WEBAUTHN_ERR_CREATE_INVALID_LOGIN_REQUEST'));
|
||||
}
|
||||
|
||||
// Login the user
|
||||
Log::add("Logging in the user", Log::INFO, 'webauthn.system');
|
||||
$this->loginUser((int) $userId);
|
||||
} catch (\Throwable $e) {
|
||||
$session->set('plg_system_webauthn.publicKeyCredentialRequestOptions', null);
|
||||
|
||||
$response = $this->getAuthenticationResponseObject();
|
||||
$response->status = Authentication::STATUS_UNKNOWN;
|
||||
$response->error_message = $e->getMessage();
|
||||
|
||||
Log::add(sprintf("Received login failure. Message: %s", $e->getMessage()), Log::ERROR, 'webauthn.system');
|
||||
|
||||
// This also enqueues the login failure message for display after redirection. Look for JLog in that method.
|
||||
$this->processLoginFailure($response);
|
||||
} finally {
|
||||
/**
|
||||
* This code needs to run no matter if the login succeeded or failed. It prevents replay attacks and takes
|
||||
* the user back to the page they started from.
|
||||
*/
|
||||
|
||||
// Remove temporary information for security reasons
|
||||
$session->set('plg_system_webauthn.publicKeyCredentialRequestOptions', null);
|
||||
$session->set('plg_system_webauthn.returnUrl', null);
|
||||
$session->set('plg_system_webauthn.userId', null);
|
||||
|
||||
// Redirect back to the page we were before.
|
||||
$this->getApplication()->redirect($returnUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logs in a user to the site, bypassing the authentication plugins.
|
||||
*
|
||||
* @param int $userId The user ID to log in
|
||||
*
|
||||
* @return void
|
||||
* @throws \Exception
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function loginUser(int $userId): void
|
||||
{
|
||||
// Trick the class auto-loader into loading the necessary classes
|
||||
class_exists('Joomla\\CMS\\Authentication\\Authentication', true);
|
||||
|
||||
// Fake a successful login message
|
||||
$isAdmin = $this->getApplication()->isClient('administrator');
|
||||
$user = Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById($userId);
|
||||
|
||||
// Does the user account have a pending activation?
|
||||
if (!empty($user->activation)) {
|
||||
throw new \RuntimeException(Text::_('JGLOBAL_AUTH_ACCESS_DENIED'));
|
||||
}
|
||||
|
||||
// Is the user account blocked?
|
||||
if ($user->block) {
|
||||
throw new \RuntimeException(Text::_('JGLOBAL_AUTH_ACCESS_DENIED'));
|
||||
}
|
||||
|
||||
$statusSuccess = Authentication::STATUS_SUCCESS;
|
||||
|
||||
$response = $this->getAuthenticationResponseObject();
|
||||
$response->status = $statusSuccess;
|
||||
$response->username = $user->username;
|
||||
$response->fullname = $user->name;
|
||||
$response->error_message = '';
|
||||
$response->language = $user->getParam('language');
|
||||
$response->type = 'Passwordless';
|
||||
|
||||
if ($isAdmin) {
|
||||
$response->language = $user->getParam('admin_language');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up the login options.
|
||||
*
|
||||
* The 'remember' element forces the use of the Remember Me feature when logging in with Webauthn, as the
|
||||
* users would expect.
|
||||
*
|
||||
* The 'action' element is actually required by plg_user_joomla. It is the core ACL action the logged in user
|
||||
* must be allowed for the login to succeed. Please note that front-end and back-end logins use a different
|
||||
* action. This allows us to provide the WebAuthn button on both front- and back-end and be sure that if a
|
||||
* used with no backend access tries to use it to log in Joomla! will just slap him with an error message about
|
||||
* insufficient privileges - the same thing that'd happen if you tried to use your front-end only username and
|
||||
* password in a back-end login form.
|
||||
*/
|
||||
$options = [
|
||||
'remember' => true,
|
||||
'action' => 'core.login.site',
|
||||
];
|
||||
|
||||
if ($isAdmin) {
|
||||
$options['action'] = 'core.login.admin';
|
||||
}
|
||||
|
||||
// Run the user plugins. They CAN block login by returning boolean false and setting $response->error_message.
|
||||
PluginHelper::importPlugin('user');
|
||||
$eventClassName = self::getEventClassByEventName('onUserLogin');
|
||||
$event = new $eventClassName('onUserLogin', [(array) $response, $options]);
|
||||
$result = $this->getApplication()->getDispatcher()->dispatch($event->getName(), $event);
|
||||
$results = !isset($result['result']) || \is_null($result['result']) ? [] : $result['result'];
|
||||
|
||||
// If there is no boolean FALSE result from any plugin the login is successful.
|
||||
if (\in_array(false, $results, true) === false) {
|
||||
// Set the user in the session, letting Joomla! know that we are logged in.
|
||||
$this->getApplication()->getSession()->set('user', $user);
|
||||
|
||||
// Trigger the onUserAfterLogin event
|
||||
$options['user'] = $user;
|
||||
$options['responseType'] = $response->type;
|
||||
|
||||
// The user is successfully logged in. Run the after login events
|
||||
$eventClassName = self::getEventClassByEventName('onUserAfterLogin');
|
||||
$event = new $eventClassName('onUserAfterLogin', [$options]);
|
||||
$this->getApplication()->getDispatcher()->dispatch($event->getName(), $event);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// If we are here the plugins marked a login failure. Trigger the onUserLoginFailure Event.
|
||||
$eventClassName = self::getEventClassByEventName('onUserLoginFailure');
|
||||
$event = new $eventClassName('onUserLoginFailure', [(array) $response]);
|
||||
$this->getApplication()->getDispatcher()->dispatch($event->getName(), $event);
|
||||
|
||||
// Log the failure
|
||||
Log::add($response->error_message, Log::WARNING, 'jerror');
|
||||
|
||||
// Throw an exception to let the caller know that the login failed
|
||||
throw new \RuntimeException($response->error_message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a (blank) Joomla! authentication response
|
||||
*
|
||||
* @return AuthenticationResponse
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function getAuthenticationResponseObject(): AuthenticationResponse
|
||||
{
|
||||
// Force the class auto-loader to load the JAuthentication class
|
||||
class_exists('Joomla\\CMS\\Authentication\\Authentication', true);
|
||||
|
||||
return new AuthenticationResponse();
|
||||
}
|
||||
|
||||
/**
|
||||
* Have Joomla! process a login failure
|
||||
*
|
||||
* @param AuthenticationResponse $response The Joomla! auth response object
|
||||
*
|
||||
* @return boolean
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function processLoginFailure(AuthenticationResponse $response): bool
|
||||
{
|
||||
// Import the user plugin group.
|
||||
PluginHelper::importPlugin('user');
|
||||
|
||||
// Trigger onUserLoginFailure Event.
|
||||
Log::add('Calling onUserLoginFailure plugin event', Log::INFO, 'plg_system_webauthn');
|
||||
|
||||
$eventClassName = self::getEventClassByEventName('onUserLoginFailure');
|
||||
$event = new $eventClassName('onUserLoginFailure', [(array) $response]);
|
||||
$this->getApplication()->getDispatcher()->dispatch($event->getName(), $event);
|
||||
|
||||
// If status is success, any error will have been raised by the user plugin
|
||||
$expectedStatus = Authentication::STATUS_SUCCESS;
|
||||
|
||||
if ($response->status !== $expectedStatus) {
|
||||
Log::add('The login failure has been logged in Joomla\'s error log', Log::INFO, 'webauthn.system');
|
||||
|
||||
// Everything logged in the 'jerror' category ends up being enqueued in the application message queue.
|
||||
Log::add($response->error_message, Log::WARNING, 'jerror');
|
||||
} else {
|
||||
$message = 'A login failure was caused by a third party user plugin but it did not return any' .
|
||||
'further information.';
|
||||
Log::add($message, Log::WARNING, 'webauthn.system');
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate the authenticator response sent to us by the browser.
|
||||
*
|
||||
* @param User $user The user we are trying to log in.
|
||||
*
|
||||
* @return string|null The user handle or null
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function getUserHandleFromResponse(User $user): ?string
|
||||
{
|
||||
// Retrieve data from the request and session
|
||||
$pubKeyCredentialSource = $this->authenticationHelper->validateAssertionResponse(
|
||||
$this->getApplication()->getInput()->getBase64('data', ''),
|
||||
$user
|
||||
);
|
||||
|
||||
return $pubKeyCredentialSource ? $pubKeyCredentialSource->getUserHandle() : null;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Joomla\CMS\Event\Plugin\System\Webauthn\AjaxSaveLabel;
|
||||
use Joomla\CMS\User\User;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Ajax handler for akaction=savelabel
|
||||
*
|
||||
* Stores a new label for a security key
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
trait AjaxHandlerSaveLabel
|
||||
{
|
||||
/**
|
||||
* Handle the callback to rename an authenticator
|
||||
*
|
||||
* @param AjaxSaveLabel $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function onAjaxWebauthnSavelabel(AjaxSaveLabel $event): void
|
||||
{
|
||||
// Load plugin language files
|
||||
$this->loadLanguage();
|
||||
|
||||
// Initialize objects
|
||||
$input = $this->getApplication()->getInput();
|
||||
$repository = $this->authenticationHelper->getCredentialsRepository();
|
||||
|
||||
// Retrieve data from the request
|
||||
$credentialId = $input->getBase64('credential_id', '');
|
||||
$newLabel = $input->getString('new_label', '');
|
||||
|
||||
// Is this a valid credential?
|
||||
if (empty($credentialId)) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$credentialId = base64_decode($credentialId);
|
||||
|
||||
if (empty($credentialId) || !$repository->has($credentialId)) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure I am editing my own key
|
||||
try {
|
||||
$credentialHandle = $repository->getUserHandleFor($credentialId);
|
||||
$user = $this->getApplication()->getIdentity() ?? new User();
|
||||
$myHandle = $repository->getHandleFromUserId($user->id);
|
||||
} catch (\Exception $e) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($credentialHandle !== $myHandle) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure the new label is not empty
|
||||
if (empty($newLabel)) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the new label
|
||||
try {
|
||||
$repository->setLabel($credentialId, $newLabel);
|
||||
} catch (\Exception $e) {
|
||||
$event->addResult(false);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$event->addResult(true);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2022 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Joomla\CMS\Event\Result\ResultAwareInterface;
|
||||
use Joomla\Event\Event;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Utility trait to facilitate returning data from event handlers.
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
trait EventReturnAware
|
||||
{
|
||||
/**
|
||||
* Adds a result value to an event
|
||||
*
|
||||
* @param Event $event The event we were processing
|
||||
* @param mixed $value The value to append to the event's results
|
||||
*
|
||||
* @return void
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function returnFromEvent(Event $event, $value = null): void
|
||||
{
|
||||
if ($event instanceof ResultAwareInterface) {
|
||||
$event->addResult($value);
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $event->getArgument('result') ?: [];
|
||||
|
||||
if (!\is_array($result)) {
|
||||
$result = [$result];
|
||||
}
|
||||
|
||||
$result[] = $value;
|
||||
|
||||
$event->setArgument('result', $result);
|
||||
}
|
||||
}
|
||||
76
plugins/system/webauthn/src/PluginTraits/UserDeletion.php
Normal file
76
plugins/system/webauthn/src/PluginTraits/UserDeletion.php
Normal file
@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\Database\DatabaseInterface;
|
||||
use Joomla\Event\Event;
|
||||
use Joomla\Utilities\ArrayHelper;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Delete all WebAuthn credentials for a particular user
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
trait UserDeletion
|
||||
{
|
||||
/**
|
||||
* Remove all passwordless credential information for the given user ID.
|
||||
*
|
||||
* This method is called after user data is deleted from the database.
|
||||
*
|
||||
* @param Event $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function onUserAfterDelete(Event $event): void
|
||||
{
|
||||
/**
|
||||
* @var array $user Holds the user data
|
||||
* @var bool $success True if user was successfully stored in the database
|
||||
* @var string|null $msg Message
|
||||
*/
|
||||
[$user, $success, $msg] = array_values($event->getArguments());
|
||||
|
||||
if (!$success) {
|
||||
$this->returnFromEvent($event, true);
|
||||
}
|
||||
|
||||
$userId = ArrayHelper::getValue($user, 'id', 0, 'int');
|
||||
|
||||
if ($userId) {
|
||||
Log::add("Removing WebAuthn Passwordless Login information for deleted user #{$userId}", Log::DEBUG, 'webauthn.system');
|
||||
|
||||
/** @var DatabaseInterface $db */
|
||||
$db = Factory::getContainer()->get(DatabaseInterface::class);
|
||||
|
||||
$query = $db->getQuery(true)
|
||||
->delete($db->quoteName('#__webauthn_credentials'))
|
||||
->where($db->quoteName('user_id') . ' = :userId')
|
||||
->bind(':userId', $userId);
|
||||
|
||||
try {
|
||||
$db->setQuery($query)->execute();
|
||||
} catch (\Exception $e) {
|
||||
// Don't worry if this fails
|
||||
}
|
||||
|
||||
$this->returnFromEvent($event, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
240
plugins/system/webauthn/src/PluginTraits/UserProfileFields.php
Normal file
240
plugins/system/webauthn/src/PluginTraits/UserProfileFields.php
Normal file
@ -0,0 +1,240 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* @package Joomla.Plugin
|
||||
* @subpackage System.Webauthn
|
||||
*
|
||||
* @copyright (C) 2020 Open Source Matters, Inc. <https://www.joomla.org>
|
||||
* @license GNU General Public License version 2 or later; see LICENSE.txt
|
||||
*/
|
||||
|
||||
namespace Joomla\Plugin\System\Webauthn\PluginTraits;
|
||||
|
||||
use Joomla\CMS\Factory;
|
||||
use Joomla\CMS\Form\Form;
|
||||
use Joomla\CMS\HTML\HTMLHelper;
|
||||
use Joomla\CMS\Language\Text;
|
||||
use Joomla\CMS\Log\Log;
|
||||
use Joomla\CMS\Uri\Uri;
|
||||
use Joomla\CMS\User\User;
|
||||
use Joomla\CMS\User\UserFactoryInterface;
|
||||
use Joomla\Event\Event;
|
||||
use Joomla\Plugin\System\Webauthn\Extension\Webauthn;
|
||||
use Joomla\Registry\Registry;
|
||||
|
||||
// phpcs:disable PSR1.Files.SideEffects
|
||||
\defined('_JEXEC') or die;
|
||||
// phpcs:enable PSR1.Files.SideEffects
|
||||
|
||||
/**
|
||||
* Add extra fields in the User Profile page.
|
||||
*
|
||||
* This class only injects the custom form fields. The actual interface is rendered through
|
||||
* JFormFieldWebauthn.
|
||||
*
|
||||
* @see JFormFieldWebauthn::getInput()
|
||||
*
|
||||
* @since 4.0.0
|
||||
*/
|
||||
trait UserProfileFields
|
||||
{
|
||||
/**
|
||||
* User object derived from the displayed user profile data.
|
||||
*
|
||||
* This is required to display the number and names of authenticators already registered when
|
||||
* the user displays the profile view page.
|
||||
*
|
||||
* @var User|null
|
||||
* @since 4.0.0
|
||||
*/
|
||||
private static $userFromFormData = null;
|
||||
|
||||
/**
|
||||
* HTMLHelper method to render the WebAuthn user profile field in the profile view page.
|
||||
*
|
||||
* Instead of showing a nonsensical "Website default" label next to the field, this method
|
||||
* displays the number and names of authenticators already registered by the user.
|
||||
*
|
||||
* This static method is set up for use in the onContentPrepareData method of this plugin.
|
||||
*
|
||||
* @param mixed $value Ignored. The WebAuthn profile field is virtual, it doesn't have a
|
||||
* stored value. We only use it as a proxy to render a sub-form.
|
||||
*
|
||||
* @return string
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public static function renderWebauthnProfileField($value): string
|
||||
{
|
||||
if (\is_null(self::$userFromFormData)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
/** @var Webauthn $plugin */
|
||||
$plugin = Factory::getApplication()->bootPlugin('webauthn', 'system');
|
||||
$credentialRepository = $plugin->getAuthenticationHelper()->getCredentialsRepository();
|
||||
$credentials = $credentialRepository->getAll(self::$userFromFormData->id);
|
||||
$authenticators = array_map(
|
||||
function (array $credential) {
|
||||
return $credential['label'];
|
||||
},
|
||||
$credentials
|
||||
);
|
||||
|
||||
return Text::plural('PLG_SYSTEM_WEBAUTHN_FIELD_N_AUTHENTICATORS_REGISTERED', \count($authenticators), implode(', ', $authenticators));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds additional fields to the user editing form
|
||||
*
|
||||
* @param Event $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function onContentPrepareForm(Event $event)
|
||||
{
|
||||
/**
|
||||
* @var Form $form The form to be altered.
|
||||
* @var mixed $data The associated data for the form.
|
||||
*/
|
||||
[$form, $data] = array_values($event->getArguments());
|
||||
|
||||
$name = $form->getName();
|
||||
|
||||
$allowedForms = [
|
||||
'com_admin.profile', 'com_users.user', 'com_users.profile', 'com_users.registration',
|
||||
];
|
||||
|
||||
if (!\in_array($name, $allowedForms)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This feature only applies in the site and administrator applications
|
||||
if (
|
||||
!$this->getApplication()->isClient('site')
|
||||
&& !$this->getApplication()->isClient('administrator')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This feature only applies to HTTPS sites.
|
||||
if (!Uri::getInstance()->isSsl()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load plugin language files
|
||||
$this->loadLanguage();
|
||||
|
||||
// Get the user object
|
||||
$user = $this->getUserFromData($data);
|
||||
|
||||
// Make sure the loaded user is the correct one
|
||||
if (\is_null($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure I am either editing myself OR I am a Super User
|
||||
if (!$this->canEditUser($user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the fields to the form.
|
||||
if ($name !== 'com_users.registration') {
|
||||
Log::add('Injecting WebAuthn Passwordless Login fields in user profile edit page', Log::DEBUG, 'webauthn.system');
|
||||
|
||||
Form::addFormPath(JPATH_PLUGINS . '/' . $this->_type . '/' . $this->_name . '/forms');
|
||||
$form->loadFile('webauthn', false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Event $event The event we are handling
|
||||
*
|
||||
* @return void
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public function onContentPrepareData(Event $event): void
|
||||
{
|
||||
/**
|
||||
* @var string|null $context The context for the data
|
||||
* @var array|object|null $data An object or array containing the data for the form.
|
||||
*/
|
||||
[$context, $data] = array_values($event->getArguments());
|
||||
|
||||
if (!\in_array($context, ['com_users.profile', 'com_users.user'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Load plugin language files
|
||||
$this->loadLanguage();
|
||||
|
||||
self::$userFromFormData = $this->getUserFromData($data);
|
||||
|
||||
if (!HTMLHelper::isRegistered('users.webauthnWebauthn')) {
|
||||
HTMLHelper::register('users.webauthn', [__CLASS__, 'renderWebauthnProfileField']);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user object based on the ID found in the provided user form data
|
||||
*
|
||||
* @param array|object|null $data The user form data
|
||||
*
|
||||
* @return User|null A user object or null if no match is found
|
||||
*
|
||||
* @throws \Exception
|
||||
* @since 4.0.0
|
||||
*/
|
||||
private function getUserFromData($data): ?User
|
||||
{
|
||||
$id = null;
|
||||
|
||||
if (\is_array($data)) {
|
||||
$id = $data['id'] ?? null;
|
||||
} elseif (\is_object($data) && ($data instanceof Registry)) {
|
||||
$id = $data->get('id');
|
||||
} elseif (\is_object($data)) {
|
||||
$id = $data->id ?? null;
|
||||
}
|
||||
|
||||
$user = empty($id) ? Factory::getApplication()->getIdentity() : Factory::getContainer()
|
||||
->get(UserFactoryInterface::class)
|
||||
->loadUserById($id);
|
||||
|
||||
// Make sure the loaded user is the correct one
|
||||
if ($user->id != $id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the current user allowed to edit the WebAuthn configuration of $user?
|
||||
*
|
||||
* To do so I must either be editing my own account OR I have to be a Super User.
|
||||
*
|
||||
* @param ?User $user The user you want to know if we're allowed to edit
|
||||
*
|
||||
* @return boolean
|
||||
*
|
||||
* @since 4.2.0
|
||||
*/
|
||||
private function canEditUser(?User $user = null): bool
|
||||
{
|
||||
// I can edit myself, but Guests can't have passwordless logins associated
|
||||
if (empty($user) || $user->guest) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Get the currently logged in used
|
||||
$myUser = $this->getApplication()->getIdentity() ?? new User();
|
||||
|
||||
// I can edit myself. If I'm a Super user I can edit other users too.
|
||||
return ($myUser->id == $user->id) || $myUser->authorise('core.admin');
|
||||
}
|
||||
}
|
||||
41
plugins/system/webauthn/webauthn.xml
Normal file
41
plugins/system/webauthn/webauthn.xml
Normal file
@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<extension type="plugin" group="system" method="upgrade">
|
||||
<name>plg_system_webauthn</name>
|
||||
<version>4.0.0</version>
|
||||
<creationDate>2019-07-02</creationDate>
|
||||
<author>Joomla! Project</author>
|
||||
<authorEmail>admin@joomla.org</authorEmail>
|
||||
<authorUrl>www.joomla.org</authorUrl>
|
||||
<copyright>(C) 2020 Open Source Matters, Inc.</copyright>
|
||||
<license>GNU General Public License version 2 or later; see LICENSE.txt</license>
|
||||
<description>PLG_SYSTEM_WEBAUTHN_DESCRIPTION</description>
|
||||
<namespace path="src">Joomla\Plugin\System\Webauthn</namespace>
|
||||
<files>
|
||||
<folder>forms</folder>
|
||||
<folder plugin="webauthn">services</folder>
|
||||
<folder>src</folder>
|
||||
</files>
|
||||
<languages>
|
||||
<language tag="en-GB">language/en-GB/plg_system_webauthn.ini</language>
|
||||
<language tag="en-GB">language/en-GB/plg_system_webauthn.sys.ini</language>
|
||||
</languages>
|
||||
<config>
|
||||
<fields name="params">
|
||||
<fieldset name="basic">
|
||||
<field
|
||||
name="attestationSupport"
|
||||
type="radio"
|
||||
label="PLG_SYSTEM_WEBAUTHN_FIELD_ATTESTATION_SUPPORT_LABEL"
|
||||
description="PLG_SYSTEM_WEBAUTHN_FIELD_ATTESTATION_SUPPORT_DESC"
|
||||
layout="joomla.form.field.radio.switcher"
|
||||
default="0"
|
||||
filter="integer"
|
||||
>
|
||||
<option value="0">JDISABLED</option>
|
||||
<option value="1">JENABLED</option>
|
||||
</field>
|
||||
|
||||
</fieldset>
|
||||
</fields>
|
||||
</config>
|
||||
</extension>
|
||||
Reference in New Issue
Block a user