first commit

This commit is contained in:
2025-06-17 11:53:18 +02:00
commit 9f0f7ba12b
8804 changed files with 1369176 additions and 0 deletions

File diff suppressed because one or more lines are too long

View 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>

View 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;
}
};

View 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;
}
}

View 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);
}
}

View 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',
];
}
}

View 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(),
]);
}
}

View 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;
}
}
}

View File

@ -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());
}
}

View 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);
}
}

View File

@ -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));
}
}

View 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));
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View 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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View 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);
}
}
}

View 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');
}
}

View 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>