<?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\Factory; 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 RuntimeException; 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; } }