Current File : /home/pacjaorg/www/kmm/plugins/system/webauthn/src/Authentication.php |
<?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\Plugin\System\Webauthn\Hotfix\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(): \Webauthn\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;
}
}