257 lines
9.3 KiB
PHP
257 lines
9.3 KiB
PHP
<?php
|
|
|
|
/**
|
|
* @package Joomla.Plugin
|
|
* @subpackage Multifactorauth.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\Multifactorauth\Webauthn;
|
|
|
|
use Joomla\CMS\Factory;
|
|
use Joomla\CMS\MVC\Factory\MVCFactoryInterface;
|
|
use Joomla\CMS\User\UserFactoryInterface;
|
|
use Joomla\Component\Users\Administrator\Helper\Mfa as MfaHelper;
|
|
use Joomla\Component\Users\Administrator\Table\MfaTable;
|
|
use Webauthn\AttestationStatement\AttestationStatement;
|
|
use Webauthn\AttestedCredentialData;
|
|
use Webauthn\PublicKeyCredentialDescriptor;
|
|
use Webauthn\PublicKeyCredentialSource;
|
|
use Webauthn\PublicKeyCredentialSourceRepository;
|
|
use Webauthn\PublicKeyCredentialUserEntity;
|
|
use Webauthn\TrustPath\EmptyTrustPath;
|
|
|
|
// phpcs:disable PSR1.Files.SideEffects
|
|
\defined('_JEXEC') or die;
|
|
// phpcs:enable PSR1.Files.SideEffects
|
|
|
|
/**
|
|
* Implementation of the credentials repository for the WebAuthn library.
|
|
*
|
|
* Important assumption: interaction with Webauthn through the library is only performed for the currently logged in
|
|
* user. Therefore all Methods which take a credential ID work by checking the Joomla MFA records of the current
|
|
* user only. This is a necessity. The records are stored encrypted, therefore we cannot do a partial search in the
|
|
* table. We have to load the records, decrypt them and inspect them. We cannot do that for thousands of records but
|
|
* we CAN do that for the few records each user has under their account.
|
|
*
|
|
* This behavior can be changed by passing a user ID in the constructor of the class.
|
|
*
|
|
* @since 4.2.0
|
|
*/
|
|
class CredentialRepository implements PublicKeyCredentialSourceRepository
|
|
{
|
|
/**
|
|
* The user ID we will operate with
|
|
*
|
|
* @var integer
|
|
* @since 4.2.0
|
|
*/
|
|
private $userId = 0;
|
|
|
|
/**
|
|
* CredentialRepository constructor.
|
|
*
|
|
* @param int $userId The user ID this repository will be working with.
|
|
*
|
|
* @throws \Exception
|
|
* @since 4.2.0
|
|
*/
|
|
public function __construct(int $userId = 0)
|
|
{
|
|
if (empty($userId)) {
|
|
$user = Factory::getApplication()->getIdentity()
|
|
?: Factory::getContainer()->get(UserFactoryInterface::class)->loadUserById(0);
|
|
|
|
$userId = $user->id;
|
|
}
|
|
|
|
$this->userId = $userId;
|
|
}
|
|
|
|
/**
|
|
* Finds a WebAuthn record given a credential ID
|
|
*
|
|
* @param string $publicKeyCredentialId The public credential ID to look for
|
|
*
|
|
* @return PublicKeyCredentialSource|null
|
|
* @since 4.2.0
|
|
*/
|
|
public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource
|
|
{
|
|
$publicKeyCredentialUserEntity = new PublicKeyCredentialUserEntity('', $this->userId, '', '');
|
|
$credentials = $this->findAllForUserEntity($publicKeyCredentialUserEntity);
|
|
|
|
foreach ($credentials as $record) {
|
|
if ($record->getAttestedCredentialData()->getCredentialId() != $publicKeyCredentialId) {
|
|
continue;
|
|
}
|
|
|
|
return $record;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Find all WebAuthn entries given a user entity
|
|
*
|
|
* @param PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity The user entity to search by
|
|
*
|
|
* @return array|PublicKeyCredentialSource[]
|
|
* @throws \Exception
|
|
* @since 4.2.0
|
|
*/
|
|
public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array
|
|
{
|
|
if (empty($publicKeyCredentialUserEntity)) {
|
|
$userId = $this->userId;
|
|
} else {
|
|
$userId = $publicKeyCredentialUserEntity->getId();
|
|
}
|
|
|
|
$return = [];
|
|
|
|
$results = MfaHelper::getUserMfaRecords($userId);
|
|
|
|
if (\count($results) < 1) {
|
|
return $return;
|
|
}
|
|
|
|
/** @var MfaTable $result */
|
|
foreach ($results as $result) {
|
|
$options = $result->options;
|
|
|
|
if (!\is_array($options) || empty($options)) {
|
|
continue;
|
|
}
|
|
|
|
if (!isset($options['attested']) && !isset($options['pubkeysource'])) {
|
|
continue;
|
|
}
|
|
|
|
if (isset($options['attested']) && \is_string($options['attested'])) {
|
|
$options['attested'] = json_decode($options['attested'], true);
|
|
|
|
$return[$result->id] = $this->attestedCredentialToPublicKeyCredentialSource(
|
|
AttestedCredentialData::createFromArray($options['attested']),
|
|
$userId
|
|
);
|
|
} elseif (isset($options['pubkeysource']) && \is_string($options['pubkeysource'])) {
|
|
$options['pubkeysource'] = json_decode($options['pubkeysource'], true);
|
|
$return[$result->id] = PublicKeyCredentialSource::createFromArray($options['pubkeysource']);
|
|
} elseif (isset($options['pubkeysource']) && \is_array($options['pubkeysource'])) {
|
|
$return[$result->id] = PublicKeyCredentialSource::createFromArray($options['pubkeysource']);
|
|
}
|
|
}
|
|
|
|
return $return;
|
|
}
|
|
|
|
/**
|
|
* Converts a legacy AttestedCredentialData object stored in the database into a PublicKeyCredentialSource object.
|
|
*
|
|
* This makes several assumptions which can be problematic and the reason why the WebAuthn library version 2 moved
|
|
* away from attested credentials to public key credential sources:
|
|
*
|
|
* - The credential is always of the public key type (that's safe as the only option supported)
|
|
* - You can access it with any kind of authenticator transport: USB, NFC, Internal or Bluetooth LE (possibly
|
|
* dangerous)
|
|
* - There is no attestations (generally safe since browsers don't seem to support attestation yet)
|
|
* - There is no trust path (generally safe since browsers don't seem to provide one)
|
|
* - No counter was stored (dangerous since it can lead to replay attacks).
|
|
*
|
|
* @param AttestedCredentialData $record Legacy attested credential data object
|
|
* @param int $userId User ID we are getting the credential source for
|
|
*
|
|
* @return PublicKeyCredentialSource
|
|
* @since 4.2.0
|
|
*/
|
|
private function attestedCredentialToPublicKeyCredentialSource(AttestedCredentialData $record, int $userId): PublicKeyCredentialSource
|
|
{
|
|
return new PublicKeyCredentialSource(
|
|
$record->getCredentialId(),
|
|
PublicKeyCredentialDescriptor::CREDENTIAL_TYPE_PUBLIC_KEY,
|
|
[
|
|
PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_USB,
|
|
PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_NFC,
|
|
PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_INTERNAL,
|
|
PublicKeyCredentialDescriptor::AUTHENTICATOR_TRANSPORT_BLE,
|
|
],
|
|
AttestationStatement::TYPE_NONE,
|
|
new EmptyTrustPath(),
|
|
$record->getAaguid(),
|
|
$record->getCredentialPublicKey(),
|
|
$userId,
|
|
0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Save a WebAuthn record
|
|
*
|
|
* @param PublicKeyCredentialSource $publicKeyCredentialSource The record to save
|
|
*
|
|
* @return void
|
|
* @throws \Exception
|
|
* @since 4.2.0
|
|
*/
|
|
public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void
|
|
{
|
|
// I can only create or update credentials for the user this class was created for
|
|
if ($publicKeyCredentialSource->getUserHandle() != $this->userId) {
|
|
throw new \RuntimeException('Cannot create or update WebAuthn credentials for a different user.', 403);
|
|
}
|
|
|
|
// Do I have an existing record for this credential?
|
|
$recordId = null;
|
|
$publicKeyCredentialUserEntity = new PublicKeyCredentialUserEntity('', $this->userId, '', '');
|
|
$credentials = $this->findAllForUserEntity($publicKeyCredentialUserEntity);
|
|
|
|
foreach ($credentials as $id => $record) {
|
|
if ($record->getAttestedCredentialData()->getCredentialId() != $publicKeyCredentialSource->getAttestedCredentialData()->getCredentialId()) {
|
|
continue;
|
|
}
|
|
|
|
$recordId = $id;
|
|
|
|
break;
|
|
}
|
|
|
|
// Create or update a record
|
|
/** @var MVCFactoryInterface $factory */
|
|
$factory = Factory::getApplication()->bootComponent('com_users')->getMVCFactory();
|
|
/** @var MfaTable $mfaTable */
|
|
$mfaTable = $factory->createTable('Mfa', 'Administrator');
|
|
|
|
if ($recordId) {
|
|
$mfaTable->load($recordId);
|
|
|
|
$options = $mfaTable->options;
|
|
|
|
if (isset($options['attested'])) {
|
|
unset($options['attested']);
|
|
}
|
|
|
|
$options['pubkeysource'] = $publicKeyCredentialSource;
|
|
$mfaTable->save(
|
|
[
|
|
'options' => $options,
|
|
]
|
|
);
|
|
} else {
|
|
$mfaTable->reset();
|
|
$mfaTable->save(
|
|
[
|
|
'user_id' => $this->userId,
|
|
'title' => 'WebAuthn auto-save',
|
|
'method' => 'webauthn',
|
|
'default' => 0,
|
|
'options' => ['pubkeysource' => $publicKeyCredentialSource],
|
|
]
|
|
);
|
|
}
|
|
}
|
|
}
|