diff --git a/Classes/Provider/WebAuthnProvider.php b/Classes/Provider/WebAuthnProvider.php index 7096db4..4dea399 100644 --- a/Classes/Provider/WebAuthnProvider.php +++ b/Classes/Provider/WebAuthnProvider.php @@ -17,7 +17,7 @@ namespace Bnf\MfaWebauthn\Provider; -use Bnf\MfaWebauthn\Repository\PublicKeyCredentialSourceRepository; +use Bnf\MfaWebauthn\Repository\CredentialRecordRepository; use Bnf\MfaWebauthn\Server; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; @@ -29,18 +29,16 @@ use TYPO3\CMS\Core\Authentication\Mfa\MfaViewType; use TYPO3\CMS\Core\Context\Context; use TYPO3\CMS\Core\Core\Environment; -use TYPO3\CMS\Core\Information\Typo3Version; use TYPO3\CMS\Core\Http\NormalizedParams; use TYPO3\CMS\Core\Page\PageRenderer; use TYPO3\CMS\Core\Utility\GeneralUtility; use TYPO3\CMS\Extbase\Utility\LocalizationUtility; use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AuthenticatorSelectionCriteria; -use Webauthn\Denormalizer\WebauthnSerializerFactory; +use Webauthn\CredentialRecord; use Webauthn\PublicKeyCredentialCreationOptions; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialRpEntity; -use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialUserEntity; class WebAuthnProvider implements MfaProviderInterface, LoggerAwareInterface @@ -89,8 +87,12 @@ public function canProcess(ServerRequestInterface $request): bool public function isActive(MfaProviderPropertyManager $propertyManager): bool { - return (bool)$propertyManager->getProperty('active') && - count($propertyManager->getProperty(PublicKeyCredentialSourceRepository::PROPERTY) ?? []) > 0; + if (!(bool)$propertyManager->getProperty('active')) { + return false; + } + /** @var array */ + $credentialRecords = $propertyManager->getProperty(CredentialRecordRepository::PROPERTY) ?? []; + return count($credentialRecords) > 0; } public function isLocked(MfaProviderPropertyManager $propertyManager): bool @@ -180,26 +182,26 @@ public function update(ServerRequestInterface $request, MfaProviderPropertyManag private function addCredentials(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool { $data = $this->getPublicKey($request); - $publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository($propertyManager); + $webauthn = $this->createWebauthnServer($request, $propertyManager); + $serializer = $webauthn->getSerializer(); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager, $serializer); $keyDescription = $this->getDescription($request); $keyIcon = $this->getIcon($request); - $serializer = $this->createSerializer(); $creationOptions = $serializer->denormalize( $propertyManager->getProperty('creationOptions'), PublicKeyCredentialCreationOptions::class ); $hostname = $this->getNormalizedParams($request)->getRequestHostOnly(); - $webauthn = $this->createWebauthnServer($request, $propertyManager); try { - $publicKeyCredentialSource = $webauthn->loadAndCheckAttestationResponse( + $credentialRecord = $webauthn->loadAndCheckAttestationResponse( $data, $creationOptions, // This one contains the challenge we stored during the previous step $hostname ); - $publicKeyCredentialSourceRepository->addCredentialSource($publicKeyCredentialSource, $keyDescription, $keyIcon); + $credentialRecordRepository->addCredentialRecord($credentialRecord, $keyDescription, $keyIcon); } catch (\Throwable $exception) { return false; @@ -220,11 +222,13 @@ private function addCredentials(ServerRequestInterface $request, MfaProviderProp private function removeCredentials(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): bool { $data = $this->getPublicKey($request); - $publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository($propertyManager); + $webauthn = $this->createWebauthnServer($request, $propertyManager); + $serializer = $webauthn->getSerializer(); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager, $serializer); try { $sourceData = json_decode($data, true); - $credentialSource = $this->createSerializer()->denormalize($sourceData, PublicKeyCredentialSource::class); - $publicKeyCredentialSourceRepository->removeCredentialSource($credentialSource); + $credentialSource = $serializer->denormalize($sourceData, CredentialRecord::class); + $credentialRecordRepository->removeCredentialRecord($credentialSource); } catch (\Throwable $e) { return false; } @@ -235,7 +239,9 @@ public function verify(ServerRequestInterface $request, MfaProviderPropertyManag { $publicKey = $this->getPublicKey($request); - $serializer = $this->createSerializer(); + $webauthn = $this->createWebauthnServer($request, $propertyManager); + $serializer = $webauthn->getSerializer(); + $userEntity = $serializer->denormalize( $propertyManager->getProperty('userEntity'), PublicKeyCredentialUserEntity::class @@ -246,11 +252,10 @@ public function verify(ServerRequestInterface $request, MfaProviderPropertyManag PublicKeyCredentialRequestOptions::class ); - $webauthn = $this->createWebauthnServer($request, $propertyManager); $hostname = $this->getNormalizedParams($request)->getRequestHostOnly(); try { - $publicKeyCredentialSource = $webauthn->loadAndCheckAssertionResponse( + $credentialRecord = $webauthn->loadAndCheckAssertionResponse( $publicKey, $publicKeyCredentialRequestOptions, // The options stored during the previous (prepareAuth) step $userEntity, @@ -278,6 +283,7 @@ protected function prepareSetup( MfaViewType|string $type ): string { $webauthn = $this->createWebauthnServer($request, $propertyManager); + $serializer = $webauthn->getSerializer(); $authenticatorSelectionCriteria = new AuthenticatorSelectionCriteria( $this->authenticatorAttachment, @@ -286,12 +292,13 @@ protected function prepareSetup( $userEntity = $this->createUserEntity($propertyManager); - $publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository($propertyManager); - $credentialSources = $publicKeyCredentialSourceRepository->findAllForUserEntity($userEntity); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager, $serializer); + $credentialSources = $credentialRecordRepository->findAllForUserEntity($userEntity); // Convert the Credential Sources into Public Key Credential Descriptors - $excludeCredentials = array_map(function (PublicKeyCredentialSource $credential) { - return $credential->getPublicKeyCredentialDescriptor(); - }, $credentialSources); + $excludeCredentials = array_map( + static fn (CredentialRecord $credential) => $credential->getPublicKeyCredentialDescriptor(), + $credentialSources + ); $creationOptions = $webauthn->generatePublicKeyCredentialCreationOptions( $userEntity, @@ -300,7 +307,6 @@ protected function prepareSetup( $authenticatorSelectionCriteria ); - $serializer = $this->createSerializer(); $properties = [ 'creationOptions' => $serializer->normalize($creationOptions), 'userEntity' => $serializer->normalize($userEntity), @@ -309,14 +315,11 @@ protected function prepareSetup( ? $propertyManager->updateProperties($properties) : $propertyManager->createProviderEntry($properties); - $keys = $propertyManager->getProperty(PublicKeyCredentialSourceRepository::PROPERTY) ?? []; + /** @var array $keys */ + $keys = $propertyManager->getProperty(CredentialRecordRepository::PROPERTY) ?? []; $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); - if ((new Typo3Version())->getMajorVersion() >= 12) { - $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); - } else { - $pageRenderer->loadRequireJsModule('TYPO3/CMS/MfaWebauthn/MfaWebAuthn'); - } + $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); $labels = [ 'singular' => 'security key', @@ -332,35 +335,38 @@ protected function prepareSetup( ]; } + /** @var array $credentialCreationOptions */ + $credentialCreationOptions = $serializer->normalize($creationOptions); return $this->renderHtmlTag( 'mfa-webauthn-setup', [ - 'credential-creation-options' => $serializer->normalize($creationOptions), + 'credential-creation-options' => $credentialCreationOptions, 'credentials' => $keys, 'mode' => $type, 'labels' => $labels, - 'locked' => $this->isLocked($propertyManager), ] ); } private function prepareAuth(ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager): string { - $userEntity = $this->createSerializer()->denormalize( + $webauthn = $this->createWebauthnServer($request, $propertyManager); + $serializer = $webauthn->getSerializer(); + + $userEntity = $serializer->denormalize( $propertyManager->getProperty('userEntity'), PublicKeyCredentialUserEntity::class ); - $keys = $propertyManager->getProperty(PublicKeyCredentialSourceRepository::PROPERTY); + $keys = $propertyManager->getProperty(CredentialRecordRepository::PROPERTY); - $publicKeyCredentialSourceRepository = new PublicKeyCredentialSourceRepository($propertyManager); - $credentialSources = $publicKeyCredentialSourceRepository->findAllForUserEntity($userEntity); + $credentialRecordRepository = new CredentialRecordRepository($propertyManager, $serializer); + $credentialSources = $credentialRecordRepository->findAllForUserEntity($userEntity); // Convert the Credential Sources into Public Key Credential Descriptors - $allowedCredentials = array_map(function (PublicKeyCredentialSource $credential) { - return $credential->getPublicKeyCredentialDescriptor(); - }, $credentialSources); - - $webauthn = $this->createWebauthnServer($request, $propertyManager); + $allowedCredentials = array_map( + static fn(CredentialRecord $credential) => $credential->getPublicKeyCredentialDescriptor(), + $credentialSources + ); // We generate the set of options. $publicKeyCredentialRequestOptions = $webauthn->generatePublicKeyCredentialRequestOptions( @@ -369,41 +375,51 @@ private function prepareAuth(ServerRequestInterface $request, MfaProviderPropert ); $propertyManager->updateProperties([ - 'lastRequest' => $this->createSerializer()->normalize($publicKeyCredentialRequestOptions), + 'lastRequest' => $serializer->normalize($publicKeyCredentialRequestOptions), ]); // @todo: Detect FE $pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); - if ((new Typo3Version())->getMajorVersion() >= 12) { - $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); - } else { - $pageRenderer->loadRequireJsModule('TYPO3/CMS/MfaWebauthn/MfaWebAuthn'); - } + $pageRenderer->loadJavaScriptModule('@bnf/mfa-webauthn/mfa-web-authn.js'); + /** @var array $credentialRequestOptions */ + $credentialRequestOptions = $serializer->normalize($publicKeyCredentialRequestOptions); return $this->renderHtmlTag('mfa-webauthn-authenticator', [ - 'credential-request-options' => $this->createSerializer()->normalize($publicKeyCredentialRequestOptions), - 'locked' => $this->isLocked($propertyManager), + 'credential-request-options' => $credentialRequestOptions, ]); } private function getAction(ServerRequestInterface $request): string { - return trim((string)($request->getQueryParams()['webauthn_action'] ?? $request->getParsedBody()['webauthn_action'] ?? '')); + return $this->getRequestData($request, 'action'); } private function getPublicKey(ServerRequestInterface $request): string { - return trim((string)($request->getQueryParams()['webauthn_publicKeyCredential'] ?? $request->getParsedBody()['webauthn_publicKeyCredential'] ?? '')); + return $this->getRequestData($request, 'publicKeyCredential'); } private function getDescription(ServerRequestInterface $request): string { - return trim((string)($request->getQueryParams()['webauthn_publicKeyDescription'] ?? $request->getParsedBody()['webauthn_publicKeyDescription'] ?? '')); + return $this->getRequestData($request, 'publicKeyDescription'); } private function getIcon(ServerRequestInterface $request): string { - return trim((string)($request->getQueryParams()['webauthn_publicKeyIcon'] ?? $request->getParsedBody()['webauthn_publicKeyIcon'] ?? '')); + return $this->getRequestData($request, 'publicKeyIcon'); + } + + private function getRequestData(ServerRequestInterface $request, string $identifier): string + { + $index = 'webauthn_' . $identifier; + if (isset($request->getQueryParams()[$index])) { + return trim((string)$request->getQueryParams()[$index]); + } + $body = $request->getParsedBody(); + if (!is_array($body) || !isset($body[$index])) { + return ''; + } + return trim((string)$body[$index]); } private function createUserEntity(MfaProviderPropertyManager $propertyManager): PublicKeyCredentialUserEntity @@ -419,11 +435,6 @@ private function createUserEntity(MfaProviderPropertyManager $propertyManager): return new PublicKeyCredentialUserEntity($userName, $uniqueid, $displayName); } - private function createSerializer(): \Symfony\Component\Serializer\SerializerInterface - { - return (new WebauthnSerializerFactory(new AttestationStatementSupportManager()))->create(); - } - private function createWebauthnServer( ServerRequestInterface $request, MfaProviderPropertyManager $propertyManager @@ -431,29 +442,35 @@ private function createWebauthnServer( $name = 'TYPO3 Backend'; $id = $this->getNormalizedParams($request)->getRequestHostOnly(); - $server = new Server( - new PublicKeyCredentialRpEntity($name, $id), - new PublicKeyCredentialSourceRepository($propertyManager) - ); - if ($this->logger !== null) { - $server->setLogger($this->logger); - } - + $allowedOrigins = []; if (preg_match('/^(.+\.)?localhost$/', $id)) { // Marks 'localhost' and *.localhost as secure - // relying party ID (helps for local testing - $server->setSecuredRelyingPartyId([$id]); + $allowedOrigins = ['localhost']; } + $server = new Server( + new PublicKeyCredentialRpEntity($name, $id), + $allowedOrigins, + $this->logger, + ); + $serializer = $server->getSerializer(); + $repository = new CredentialRecordRepository($propertyManager, $serializer); + $server->setCredentialRecordRepository($repository); + return $server; } + /** + * @param array|ArrayObject|object> $attributes + */ private function renderHtmlTag(string $tagName, array $attributes = [], string $content = ''): string { $unescaped = []; foreach ($attributes as $name => $value) { if (is_object($value) || is_array($value)) { $value = GeneralUtility::jsonEncodeForHtmlAttribute($value, false); + } else { + $value = (string)$value; } $unescaped[$name] = $value; } diff --git a/Classes/Repository/CredentialRecordRepository.php b/Classes/Repository/CredentialRecordRepository.php new file mode 100644 index 0000000..fe26cb0 --- /dev/null +++ b/Classes/Repository/CredentialRecordRepository.php @@ -0,0 +1,137 @@ + $source + */ + private function createCredentialRecord(array $source): CredentialRecord + { + return $this->normalizer->denormalize($source, CredentialRecord::class); + } + + public function findOneByCredentialId(string $publicKeyCredentialId): ?CredentialRecord + { + $data = $this->load(); + $identifier = base64_encode($publicKeyCredentialId); + $source = $data[$identifier]['publickey'] ?? null; + if ($source === null) { + return null; + } + + return $this->createCredentialRecord($source); + } + + /** + * @return CredentialRecord[] + */ + public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array + { + $sources = []; + foreach ($this->load() as $data) { + $source = $this->createCredentialRecord($data['publickey']); + if ($source->userHandle === $publicKeyCredentialUserEntity->id) { + $sources[] = $source; + } + } + return $sources; + } + + public function saveCredentialRecord(CredentialRecord $credentialRecord): void + { + $identifier = base64_encode($credentialRecord->publicKeyCredentialId); + /** @var array $source */ + $source = $this->normalizer->normalize($credentialRecord); + + $data = $this->load(); + $data[$identifier]['publickey'] = $source; + /** @var int $timestamp */ + $timestamp = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + $data[$identifier]['updated'] = $timestamp; + $this->save($data); + } + + public function addCredentialRecord(CredentialRecord $credentialRecord, string $description, string $icon): void + { + $identifier = base64_encode($credentialRecord->publicKeyCredentialId); + + $source = []; + /** @var array $publickey */ + $publickey = $this->normalizer->normalize($credentialRecord); + $source['publickey'] = $publickey; + $source['description'] = $description; + $source['icon'] = $icon; + /** @var int $timestamp */ + $timestamp = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); + $source['created'] = $timestamp; + + $data = $this->load(); + $data[$identifier] = $source; + $this->save($data); + } + + public function removeCredentialRecord(CredentialRecord $credentialRecord): void + { + $identifier = base64_encode($credentialRecord->publicKeyCredentialId); + + $data = $this->load(); + if (!isset($data[$identifier])) { + throw new \Exception('Credential source does not exist', 1613413321); + } + unset($data[$identifier]); + $this->save($data); + } + + /** + * @return array, description?: string, icon?: string, created?: int, updated?: int}> + */ + private function load(): array + { + /** @var array, description?: string, icon?: string, created?: int, updated?: int}> $data */ + $data = $this->propertyManager->getProperty(self::PROPERTY) ?? []; + return $data; + } + + /** + * @param array, description?: string, icon?: string, created?: int, updated?: int}> $data + */ + private function save(array $data): void + { + $properties = [self::PROPERTY => $data]; + $this->propertyManager->hasProviderEntry() + ? $this->propertyManager->updateProperties($properties) + : $this->propertyManager->createProviderEntry($properties); + } +} diff --git a/Classes/Repository/PublicKeyCredentialSourceRepository.php b/Classes/Repository/PublicKeyCredentialSourceRepository.php deleted file mode 100644 index 20cd7f2..0000000 --- a/Classes/Repository/PublicKeyCredentialSourceRepository.php +++ /dev/null @@ -1,134 +0,0 @@ -propertyManager = $mfaProviderPropertyManager; - } - - private static function createSerializer(): Serializer - { - return new Serializer([ - new PublicKeyCredentialSourceDenormalizer(), - new TrustPathDenormalizer(), - new UidNormalizer(), - new ArrayDenormalizer(), - ]); - } - - private function createPublicKeyCredentialSource(array $source): PublicKeyCredentialSource - { - return self::createSerializer()->denormalize($source, PublicKeyCredentialSource::class); - } - - public function findOneByCredentialId(string $publicKeyCredentialId): ?PublicKeyCredentialSource - { - $data = $this->load(); - $identifier = base64_encode($publicKeyCredentialId); - $source = $data[$identifier]['publickey'] ?? null; - if ($source === null) { - return null; - } - - return $this->createPublicKeyCredentialSource($source); - } - - /** - * @return PublicKeyCredentialSource[] - */ - public function findAllForUserEntity(PublicKeyCredentialUserEntity $publicKeyCredentialUserEntity): array - { - $sources = []; - foreach ($this->load() as $data) { - $source = $this->createPublicKeyCredentialSource($data['publickey']); - if ($source->userHandle === $publicKeyCredentialUserEntity->id) { - $sources[] = $source; - } - } - return $sources; - } - - public function saveCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void - { - $identifier = base64_encode($publicKeyCredentialSource->publicKeyCredentialId); - $source = self::createSerializer()->normalize($publicKeyCredentialSource); - - $data = $this->load(); - $data[$identifier]['publickey'] = $source; - $data[$identifier]['updated'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); - $this->save($data); - } - - public function addCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource, string $description, string $icon): void - { - $identifier = base64_encode($publicKeyCredentialSource->publicKeyCredentialId); - - $source = []; - $source['publickey'] = self::createSerializer()->normalize($publicKeyCredentialSource); - $source['description'] = $description; - $source['icon'] = $icon; - $source['created'] = GeneralUtility::makeInstance(Context::class)->getPropertyFromAspect('date', 'timestamp'); - - $data = $this->load(); - $data[$identifier] = $source; - $this->save($data); - } - - public function removeCredentialSource(PublicKeyCredentialSource $publicKeyCredentialSource): void - { - $identifier = base64_encode($publicKeyCredentialSource->publicKeyCredentialId); - - $data = $this->load(); - if (!isset($data[$identifier])) { - throw new \Exception('Credential source does not exist', 1613413321); - } - unset($data[$identifier]); - $this->save($data); - } - - private function load(): array - { - return $this->propertyManager->getProperty(self::PROPERTY) ?? []; - } - - private function save(array $data): void - { - $properties = [self::PROPERTY => $data]; - $this->propertyManager->hasProviderEntry() - ? $this->propertyManager->updateProperties($properties) - : $this->propertyManager->createProviderEntry($properties); - } -} diff --git a/Classes/Server.php b/Classes/Server.php index 7ad3f80..ec817aa 100644 --- a/Classes/Server.php +++ b/Classes/Server.php @@ -13,7 +13,7 @@ namespace Bnf\MfaWebauthn; -use Bnf\MfaWebauthn\Repository\PublicKeyCredentialSourceRepository; +use Bnf\MfaWebauthn\Repository\CredentialRecordRepository; use Cose\Algorithm\Algorithm; use Cose\Algorithm\ManagerFactory; use Cose\Algorithm\Signature\ECDSA; @@ -21,6 +21,9 @@ use Cose\Algorithm\Signature\RSA; use InvalidArgumentException; use Psr\Log\LoggerInterface; +use Symfony\Component\Serializer\SerializerInterface; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Webauthn\AttestationStatement\AndroidKeyAttestationStatementSupport; use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\AttestationStatement\FidoU2FAttestationStatementSupport; @@ -34,6 +37,7 @@ use Webauthn\AuthenticatorAttestationResponseValidator; use Webauthn\AuthenticatorSelectionCriteria; use Webauthn\CeremonyStep\CeremonyStepManagerFactory; +use Webauthn\CredentialRecord; use Webauthn\Denormalizer\WebauthnSerializerFactory; use Webauthn\PublicKeyCredential; use Webauthn\PublicKeyCredentialCreationOptions; @@ -41,26 +45,20 @@ use Webauthn\PublicKeyCredentialParameters; use Webauthn\PublicKeyCredentialRequestOptions; use Webauthn\PublicKeyCredentialRpEntity; -use Webauthn\PublicKeyCredentialSource; use Webauthn\PublicKeyCredentialUserEntity; class Server { /** - * @var int + * @var positive-int */ - public $timeout = 60000; + public int $timeout = 60000; /** - * @var int<1, max> + * @var positive-int */ - public $challengeSize = 32; - - /** - * @var PublicKeyCredentialRpEntity - */ - private $rpEntity; + public int $challengeSize = 32; /** * @var ManagerFactory @@ -68,9 +66,9 @@ class Server private $coseAlgorithmManagerFactory; /** - * @var PublicKeyCredentialSourceRepository + * @var CredentialRecordRepository */ - private $publicKeyCredentialSourceRepository; + private $credentialRecordRepository; /** * @var ExtensionOutputCheckerHandler @@ -82,20 +80,12 @@ class Server */ private $selectedAlgorithms; - /** - * @var LoggerInterface|null - */ - private $logger; - - /** - * @var string[] - */ - private $securedRelyingPartyId = []; - - public function __construct(PublicKeyCredentialRpEntity $relyingParty, PublicKeyCredentialSourceRepository $publicKeyCredentialSourceRepository) - { - $this->rpEntity = $relyingParty; - + public function __construct( + private readonly PublicKeyCredentialRpEntity $rpEntity, + /** @var list */ + private readonly array $allowedOrigins, + private ?LoggerInterface $logger, + ) { $this->coseAlgorithmManagerFactory = new ManagerFactory(); $this->coseAlgorithmManagerFactory->add('RS1', new RSA\RS1()); $this->coseAlgorithmManagerFactory->add('RS256', new RSA\RS256()); @@ -111,10 +101,19 @@ public function __construct(PublicKeyCredentialRpEntity $relyingParty, PublicKey $this->coseAlgorithmManagerFactory->add('Ed25519', new EdDSA\Ed25519()); $this->selectedAlgorithms = ['RS256', 'RS512', 'PS256', 'PS512', 'ES256', 'ES512', 'Ed25519']; - $this->publicKeyCredentialSourceRepository = $publicKeyCredentialSourceRepository; $this->extensionOutputCheckerHandler = new ExtensionOutputCheckerHandler(); } + public function getCredentialRecordRepository(): CredentialRecordRepository + { + return $this->credentialRecordRepository; + } + + public function setCredentialRecordRepository(CredentialRecordRepository $credentialRecordRepository): void + { + $this->credentialRecordRepository = $credentialRecordRepository; + } + /** * @param string[] $selectedAlgorithms */ @@ -141,20 +140,6 @@ public function setExtensionOutputCheckerHandler(ExtensionOutputCheckerHandler $ return $this; } - /** - * @param string[] $securedRelyingPartyId - */ - public function setSecuredRelyingPartyId(array $securedRelyingPartyId): void - { - $count = count($securedRelyingPartyId); - if ($count === 0 || count($securedRelyingPartyId) !== count(array_filter($securedRelyingPartyId, fn ($value): bool => is_string($value)))) { - throw new InvalidArgumentException( - 'Invalid list. Shall be a list of strings' - ); - } - $this->securedRelyingPartyId = $securedRelyingPartyId; - } - /** * @param PublicKeyCredentialDescriptor[] $excludedPublicKeyDescriptors */ @@ -197,11 +182,11 @@ public function generatePublicKeyCredentialRequestOptions(?string $userVerificat ); } - public function loadAndCheckAttestationResponse(string $data, PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, string $hostname): PublicKeyCredentialSource + public function loadAndCheckAttestationResponse(string $data, PublicKeyCredentialCreationOptions $publicKeyCredentialCreationOptions, string $hostname): CredentialRecord { $attestationStatementSupportManager = $this->getAttestationStatementSupportManager(); - $serializer = (new WebauthnSerializerFactory($attestationStatementSupportManager))->create(); + $serializer = $this->getSerializer($attestationStatementSupportManager); $publicKeyCredential = $serializer->deserialize($data, PublicKeyCredential::class, 'json'); $authenticatorResponse = $publicKeyCredential->response; $authenticatorResponse instanceof AuthenticatorAttestationResponse || throw new \InvalidArgumentException('Not an authenticator attestation response'); @@ -217,16 +202,16 @@ public function loadAndCheckAttestationResponse(string $data, PublicKeyCredentia return $authenticatorAttestationResponseValidator->check($authenticatorResponse, $publicKeyCredentialCreationOptions, $hostname); } - public function loadAndCheckAssertionResponse(string $data, PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, ?PublicKeyCredentialUserEntity $userEntity, string $hostname): PublicKeyCredentialSource + public function loadAndCheckAssertionResponse(string $data, PublicKeyCredentialRequestOptions $publicKeyCredentialRequestOptions, ?PublicKeyCredentialUserEntity $userEntity, string $hostname): CredentialRecord { $attestationStatementSupportManager = $this->getAttestationStatementSupportManager(); - $serializer = (new WebauthnSerializerFactory($attestationStatementSupportManager))->create(); + $serializer = $this->getSerializer($attestationStatementSupportManager); $publicKeyCredential = $serializer->deserialize($data, PublicKeyCredential::class, 'json'); $authenticatorResponse = $publicKeyCredential->response; $authenticatorResponse instanceof AuthenticatorAssertionResponse || throw new InvalidArgumentException('Not an authenticator assertion response'); - $credentialSource = $this->publicKeyCredentialSourceRepository->findOneByCredentialId($publicKeyCredential->rawId); + $credentialSource = $this->credentialRecordRepository->findOneByCredentialId($publicKeyCredential->rawId); $credentialSource !== null || throw new InvalidArgumentException('Credential source not found'); $ceremonyStepManagerFactory = $this->createCeremonyStepManagerFactory($attestationStatementSupportManager); @@ -237,21 +222,29 @@ public function loadAndCheckAssertionResponse(string $data, PublicKeyCredentialR $authenticatorAssertionResponseValidator->setLogger($this->logger); } - $updatedCredentialSource = $authenticatorAssertionResponseValidator->check( + $updatedCredentialRecord = $authenticatorAssertionResponseValidator->check( $credentialSource, $authenticatorResponse, $publicKeyCredentialRequestOptions, $hostname, $userEntity?->id, ); - $this->publicKeyCredentialSourceRepository->saveCredentialSource($updatedCredentialSource); + $this->credentialRecordRepository->saveCredentialRecord($updatedCredentialRecord); - return $updatedCredentialSource; + return $updatedCredentialRecord; } - public function setLogger(LoggerInterface $logger): void + public function getSerializer(?AttestationStatementSupportManager $attestationStatementSupportManager = null): SerializerInterface&NormalizerInterface&DenormalizerInterface { - $this->logger = $logger; + $attestationStatementSupportManager ??= $this->getAttestationStatementSupportManager(); + $serializer = (new WebauthnSerializerFactory($attestationStatementSupportManager))->create(); + if (!$serializer instanceof NormalizerInterface || + !$serializer instanceof DenormalizerInterface + ) { + throw new \RuntimeException('Expected WebauthnSerializerFactory to create a (de)normalizing serializer', 1777882044); + } + + return $serializer; } private function createCeremonyStepManagerFactory(AttestationStatementSupportManager $attestationStatementSupportManager): CeremonyStepManagerFactory @@ -260,8 +253,8 @@ private function createCeremonyStepManagerFactory(AttestationStatementSupportMan $factory->setAlgorithmManager($this->coseAlgorithmManagerFactory->generate(...$this->selectedAlgorithms)); $factory->setAttestationStatementSupportManager($attestationStatementSupportManager); $factory->setExtensionOutputCheckerHandler($this->extensionOutputCheckerHandler); - if ($this->securedRelyingPartyId !== []) { - $factory->setSecuredRelyingPartyId($this->securedRelyingPartyId); + if ($this->allowedOrigins !== []) { + $factory->setAllowedOrigins($this->allowedOrigins, true); } return $factory; } diff --git a/Resources/Private/Libraries/composer.json b/Resources/Private/Libraries/composer.json index 175bfa4..fa5fefb 100644 --- a/Resources/Private/Libraries/composer.json +++ b/Resources/Private/Libraries/composer.json @@ -1,7 +1,7 @@ { "name": "bnf/mfa-webauthn-libraries-for-classic-mode", "require": { - "web-auth/webauthn-lib": "^5.2" + "web-auth/webauthn-lib": "^5.3" }, "replace": { "psr/http-client": "*", diff --git a/Resources/Private/Libraries/composer.lock b/Resources/Private/Libraries/composer.lock index 8c63320..e1e6116 100644 --- a/Resources/Private/Libraries/composer.lock +++ b/Resources/Private/Libraries/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "ec80cd0982e961daa0e183a087576310", + "content-hash": "0bc8d1743bff1b79a315258b5ef56912", "packages": [ { "name": "brick/math", @@ -888,16 +888,16 @@ }, { "name": "web-auth/webauthn-lib", - "version": "5.2.5", + "version": "5.3.1", "source": { "type": "git", "url": "https://github.com/web-auth/webauthn-lib.git", - "reference": "c28f27cb8f968d2b84db48587563f03bb451b60a" + "reference": "a272f254c056fb3d6c80a4801d3c7c5fedc6a08d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/c28f27cb8f968d2b84db48587563f03bb451b60a", - "reference": "c28f27cb8f968d2b84db48587563f03bb451b60a", + "url": "https://api.github.com/repos/web-auth/webauthn-lib/zipball/a272f254c056fb3d6c80a4801d3c7c5fedc6a08d", + "reference": "a272f254c056fb3d6c80a4801d3c7c5fedc6a08d", "shasum": "" }, "require": { @@ -905,18 +905,18 @@ "ext-openssl": "*", "paragonie/constant_time_encoding": "^2.6|^3.0", "php": ">=8.2", - "phpdocumentor/reflection-docblock": "^5.3", + "phpdocumentor/reflection-docblock": "^5.3|^6.0", "psr/clock": "^1.0", "psr/event-dispatcher": "^1.0", "psr/log": "^1.0|^2.0|^3.0", "spomky-labs/cbor-php": "^3.0", "spomky-labs/pki-framework": "^1.0", - "symfony/clock": "^6.4|^7.0", + "symfony/clock": "^6.4|^7.0|^8.0", "symfony/deprecation-contracts": "^3.2", - "symfony/property-access": "^6.4|^7.0", - "symfony/property-info": "^6.4|^7.0", - "symfony/serializer": "^6.4|^7.0", - "symfony/uid": "^6.4|^7.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4|^7.0|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", "web-auth/cose-lib": "^4.2.3" }, "suggest": { @@ -958,7 +958,7 @@ "webauthn" ], "support": { - "source": "https://github.com/web-auth/webauthn-lib/tree/5.2.5" + "source": "https://github.com/web-auth/webauthn-lib/tree/5.3.1" }, "funding": [ { @@ -970,7 +970,7 @@ "type": "patreon" } ], - "time": "2026-03-23T21:43:02+00:00" + "time": "2026-05-01T12:14:37+00:00" }, { "name": "webmozart/assert", diff --git a/Resources/Public/JavaScript/MfaWebAuthn.js b/Resources/Public/JavaScript/MfaWebAuthn.js deleted file mode 100644 index 8a10684..0000000 --- a/Resources/Public/JavaScript/MfaWebAuthn.js +++ /dev/null @@ -1 +0,0 @@ -define(["lit"],(function(e){"use strict";function t(e){const t=new Uint8Array(e);let n="";for(const e of t)n+=String.fromCharCode(e);return btoa(n).replace(/\+/g,"-").replace(/\//g,"_").replace(/=/g,"")}function n(e){const t=e.replace(/-/g,"+").replace(/_/g,"/"),n=(4-t.length%4)%4,i=t.padEnd(t.length+n,"="),r=atob(i),a=new ArrayBuffer(r.length),o=new Uint8Array(a);for(let e=0;e"public-key"===e.type)).length?new o({message:'No entry in pubKeyCredParams was of type "public-key"',code:"ERROR_MALFORMED_PUBKEYCREDPARAMS",cause:e}):new o({message:"No available authenticator supported any of the specified pubKeyCredParams algorithms",code:"ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG",cause:e});if("SecurityError"===e.name){const t=window.location.hostname;if(!a(t))return new o({message:`${window.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(n.rp.id!==t)return new o({message:`The RP ID "${n.rp.id}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("TypeError"===e.name){if(n.user.id.byteLength<1||n.user.id.byteLength>64)return new o({message:"User ID was not between 1 and 64 characters",code:"ERROR_INVALID_USER_ID_LENGTH",cause:e})}else if("UnknownError"===e.name)return new o({message:"The authenticator was unable to process the specified options, or could not create a new credential",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:u})}if(!d)throw new Error("Registration was not completed");const{id:h,rawId:p,response:m,type:b}=d;let w;return"function"==typeof m.getTransports&&(w=m.getTransports()),{id:h,rawId:t(p),response:{attestationObject:t(m.attestationObject),clientDataJSON:t(m.clientDataJSON),transports:w},type:b,clientExtensionResults:d.getClientExtensionResults(),authenticatorAttachment:c(d.authenticatorAttachment)}}async function d(e,l=!1){if(!i())throw new Error("WebAuthn is not supported in this browser");let u;0!==e.allowCredentials?.length&&(u=e.allowCredentials?.map(r));const d={...e,challenge:n(e.challenge),allowCredentials:u},h={};if(l){if(!await async function(){const e=window.PublicKeyCredential;return void 0!==e.isConditionalMediationAvailable&&e.isConditionalMediationAvailable()}())throw Error("Browser does not support WebAuthn autofill");if(document.querySelectorAll("input[autocomplete*='webauthn']").length<1)throw Error('No with `"webauthn"` in its `autocomplete` attribute was detected');h.mediation="conditional",d.allowCredentials=[]}let p;h.publicKey=d,h.signal=s.createNewAbortSignal();try{p=await navigator.credentials.get(h)}catch(e){throw function({error:e,options:t}){const{publicKey:n}=t;if(!n)throw Error("options was missing required publicKey property");if("AbortError"===e.name){if(t.signal instanceof AbortSignal)return new o({message:"Authentication ceremony was sent an abort signal",code:"ERROR_CEREMONY_ABORTED",cause:e})}else{if("NotAllowedError"===e.name)return new o({message:e.message,code:"ERROR_PASSTHROUGH_SEE_CAUSE_PROPERTY",cause:e});if("SecurityError"===e.name){const t=window.location.hostname;if(!a(t))return new o({message:`${window.location.hostname} is an invalid domain`,code:"ERROR_INVALID_DOMAIN",cause:e});if(n.rpId!==t)return new o({message:`The RP ID "${n.rpId}" is invalid for this domain`,code:"ERROR_INVALID_RP_ID",cause:e})}else if("UnknownError"===e.name)return new o({message:"The authenticator was unable to process the specified options, or could not create a new assertion signature",code:"ERROR_AUTHENTICATOR_GENERAL_ERROR",cause:e})}return e}({error:e,options:h})}if(!p)throw new Error("Authentication was not completed");const{id:m,rawId:b,response:w,type:f}=p;let g;var R;return w.userHandle&&(R=w.userHandle,g=new TextDecoder("utf-8").decode(R)),{id:m,rawId:t(b),response:{authenticatorData:t(w.authenticatorData),clientDataJSON:t(w.clientDataJSON),signature:t(w.signature),userHandle:g},type:f,clientExtensionResults:p.getClientExtensionResults(),authenticatorAttachment:c(p.authenticatorAttachment)}}class h extends HTMLElement{connectedCallback(){const e=document.createElement("input");e.setAttribute("type","hidden"),e.setAttribute("name","webauthn_publicKeyCredential"),this.appendChild(e);let t=this.parentElement;for(;"form"!==t.tagName.toLowerCase();)t=t.parentElement;t.addEventListener("submit",(n=>{if(e.value)return;n.preventDefault();d(JSON.parse(this.getAttribute("credential-request-options"))).then((n=>{e.value=JSON.stringify(n),t.requestSubmit()}),(e=>{console.log(e)}))})),t.requestSubmit()}}window.customElements.define("mfa-webauthn-authenticator",h);class p extends e.LitElement{static get properties(){return{mode:{type:String},credentials:{type:Object},credentialCreationOptions:{type:Object,attribute:"credential-creation-options"},labels:{type:Object},publicKeyCredential:{type:String,attribute:!1},publicKeyDescription:{type:String,attribute:!1},publicKeyIcon:{type:String,attribute:!1},action:{type:String,attribute:!1},loading:{type:Boolean,attribute:!1}}}render(){const t="add"===this.action?"fa-check":this.loading?"fa-circle-o-notch fa-spin":"fa-plus";return e.html` ${0===Object.keys(this.credentials).length?e.html`

No ${this.labels.plural} added

Configure ${this.labels.plural} below
`:e.html`${Object.keys(this.credentials).map((t=>e.html``))}
Registered ${this.labels.plural}
${this._getIcon(this.credentials[t].icon||"key")}${this.credentials[t].description||"(unnamed)"}
Last used: ${this._formatDate(this.credentials[t].updated)}
`} `}createRenderRoot(){return this}connectedCallback(){for(this.publicKeyCrendetial="",this.publicKeyDescription="",this.action="",this.form=this.parentElement;"form"!==this.form.tagName.toLowerCase();)this.form=this.form.parentElement;super.connectedCallback(),"setup"===this.mode&&this._createCredentials()}_createCredentials(e){e&&e.preventDefault();const t=JSON.parse(JSON.stringify(this.credentialCreationOptions));this.loading=!0,u(t).then((e=>{this.action="add",this.publicKeyCredential=JSON.stringify(e),this.publicKeyIcon="key";let t=this.labels.defaultName;if("platform"===this.credentialCreationOptions.authenticatorSelection.authenticatorAttachment){const e=/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);t="My "+(e?"Phone":"Computer"),this.publicKeyIcon=e?"mobile":"computer"}const n=window.prompt("Please provide a name for this "+this.labels.signular+".",t);this.publicKeyDescription=n,this.loading=!1,this.updateComplete.then((()=>this.form.requestSubmit()))}),(e=>{this.loading=!1,console.log(e)}))}_removeCredentials(e,t){e.preventDefault(),this.action="remove";const n=this.credentials[t].description||"(unnamed)";window.confirm("Do you really want to delete the "+this.labels.singular+' "'+n+'"?')&&(this.publicKeyCredential=JSON.stringify(this.credentials[t].publickey),this.updateComplete.then((()=>this.form.requestSubmit())))}_formatDate(e){if(!e)return"never";const t=new Date(1e3*e);return t.toLocaleDateString("en-US",{weekday:"long",year:"numeric",month:"long",day:"numeric"})+" "+t.toLocaleTimeString("en-US")}_getIcon(t){return"mobile"===t?e.html``:"computer"===t?e.html``:"trash"===t?e.html``:e.html``}}window.customElements.define("mfa-webauthn-setup",p)})); diff --git a/build/rollup.config.js b/build/rollup.config.js index 5f99915..7f9fa2b 100644 --- a/build/rollup.config.js +++ b/build/rollup.config.js @@ -12,12 +12,6 @@ export default { name: 'webauthn', plugins: [terser()] }, - { - file: '../Resources/Public/JavaScript/MfaWebAuthn.js', - format: 'amd', - name: 'webauthn', - plugins: [terser()] - }, ], plugins: [ resolve({ diff --git a/composer.json b/composer.json index 42bb466..6ff1b45 100644 --- a/composer.json +++ b/composer.json @@ -12,7 +12,7 @@ "require": { "php": "^8.2", "typo3/cms-core": "^12.0 || ^13.0 || ^14.0", - "web-auth/webauthn-lib": "^5.2.4" + "web-auth/webauthn-lib": "^5.3" }, "suggest": { "ext-bcmath": "bcmath or gmp are needed for webauthn", @@ -32,7 +32,8 @@ } }, "require-dev": { - "phpstan/phpstan": "^1.8" + "phpstan/phpstan": "^1.8", + "phpstan/phpstan-deprecation-rules": "*" }, "config": { "allow-plugins": { diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 36c8060..7366218 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,3 +1,6 @@ +includes: + - vendor/phpstan/phpstan-deprecation-rules/rules.neon + parameters: level: max