diff --git a/Api/AdminTfa/AuthenticateInterface.php b/Api/AdminTfa/AuthenticateInterface.php new file mode 100644 index 0000000..79e9d97 --- /dev/null +++ b/Api/AdminTfa/AuthenticateInterface.php @@ -0,0 +1,34 @@ +setName('security:tfa:passkey:reset-all'); + $this->setDescription('Reset passkey 2FA configuration for all admin users'); + $this->addOption('force', 'f', InputOption::VALUE_NONE, 'Skip confirmation prompt'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $users = $this->userCollectionFactory->create(); + $resetCount = 0; + $affectedUsers = []; + + foreach ($users as $user) { + $userId = (int) $user->getId(); + foreach (self::PROVIDER_CODES as $providerCode) { + $config = $this->userConfigManager->getProviderConfig($userId, $providerCode); + if (!empty($config) && isset($config['registration'])) { + $affectedUsers[$userId] = $user->getUserName(); + } + } + } + + if (empty($affectedUsers)) { + $output->writeln('No admin users have passkey 2FA configured.'); + return Command::SUCCESS; + } + + $output->writeln(sprintf( + 'Found %d admin user(s) with passkey 2FA configured:', + count($affectedUsers) + )); + foreach ($affectedUsers as $username) { + $output->writeln(' - ' . $username); + } + + if (!$input->getOption('force')) { + $helper = $this->getHelper('question'); + $question = new ConfirmationQuestion( + 'Reset passkey 2FA for all listed users? [y/N] ', + false + ); + if (!$helper->ask($input, $output, $question)) { + $output->writeln('Aborted.'); + return Command::SUCCESS; + } + } + + foreach (array_keys($affectedUsers) as $userId) { + foreach (self::PROVIDER_CODES as $providerCode) { + $config = $this->userConfigManager->getProviderConfig($userId, $providerCode); + if (!empty($config) && isset($config['registration'])) { + $this->userConfigManager->resetProviderConfig($userId, $providerCode); + $resetCount++; + } + } + } + + $output->writeln(sprintf( + 'Reset %d passkey configuration(s) for %d admin user(s).', + $resetCount, + count($affectedUsers) + )); + + return Command::SUCCESS; + } +} diff --git a/Controller/Account/Rename.php b/Controller/Account/Rename.php index d183011..0a6f71d 100644 --- a/Controller/Account/Rename.php +++ b/Controller/Account/Rename.php @@ -57,6 +57,9 @@ public function execute(): Json try { $body = $this->json->unserialize($this->request->getContent()); + if (!is_array($body)) { + $body = []; + } $entityId = (int) ($body['entity_id'] ?? 0); $friendlyName = (string) ($body['friendly_name'] ?? ''); $customerId = (int) $this->customerSession->getCustomerId(); diff --git a/Controller/Adminhtml/Passkey/Auth.php b/Controller/Adminhtml/Passkey/Auth.php new file mode 100644 index 0000000..5e9e96d --- /dev/null +++ b/Controller/Adminhtml/Passkey/Auth.php @@ -0,0 +1,60 @@ +getRequest()->getParam('provider', 'passkey'); + $user = $this->session->getUser(); + if ($user) { + $this->userConfigManager->setDefaultProvider( + (int) $user->getId(), + $providerCode + ); + } + + /** @var Page $page */ + $page = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $page->getConfig()->getTitle()->set(__('Passkey Authentication')); + return $page; + } + + protected function _isAllowed(): bool + { + $user = $this->session->getUser(); + if (!$user) { + return false; + } + $userId = (int) $user->getId(); + $providerCode = $this->getRequest()->getParam('provider', 'passkey'); + + try { + $provider = $this->tfa->getProvider($providerCode); + return $provider !== null && $provider->isEnabled() && $provider->isActive($userId); + } catch (\Exception $e) { + return false; + } + } +} diff --git a/Controller/Adminhtml/Passkey/AuthPost.php b/Controller/Adminhtml/Passkey/AuthPost.php new file mode 100644 index 0000000..4041d3e --- /dev/null +++ b/Controller/Adminhtml/Passkey/AuthPost.php @@ -0,0 +1,88 @@ +jsonFactory->create(); + $user = $this->session->getUser(); + if ($user === null) { + return $result->setData([ + 'success' => false, + 'message' => __('Session expired. Please sign in again.'), + ]); + } + + try { + $providerCode = $this->getRequest()->getParam('provider', Engine::PROVIDER_CODE_ALL); + $credentialJson = $this->getRequest()->getParam('credential'); + + if ($credentialJson) { + // Phase 2: Verify assertion + $request = $this->dataObjectFactory->create(['data' => [ + 'challenge_token' => $this->getRequest()->getParam('challenge_token'), + 'credential' => $credentialJson, + ]]); + + $this->authenticate->verifyAssertion($user, $request); + $this->tfaSession->grantAccess(); + + return $result->setData([ + 'success' => true, + 'redirect_url' => $this->getUrl('adminhtml/dashboard'), + ]); + } + + // Phase 1: Get authentication options + $authData = $this->authenticate->getAuthenticationData($user, $providerCode); + + return $result->setData($authData); + } catch (\Exception $e) { + $this->alert->event( + 'MageOS_PasskeyAuth', + 'Passkey authentication failed for admin user ' . $user->getUserName() + . ': ' . $e->getMessage(), + AlertInterface::LEVEL_WARNING + ); + + return $result->setData([ + 'success' => false, + 'message' => $e->getMessage(), + ]); + } + } + + protected function _isAllowed(): bool + { + $user = $this->session->getUser(); + return $user !== null; + } +} diff --git a/Controller/Adminhtml/Passkey/Configure.php b/Controller/Adminhtml/Passkey/Configure.php new file mode 100644 index 0000000..338b0fd --- /dev/null +++ b/Controller/Adminhtml/Passkey/Configure.php @@ -0,0 +1,69 @@ +session = $session; + $this->tfa = $tfa; + } + + public function execute(): Page + { + /** @var Page $page */ + $page = $this->resultFactory->create(ResultFactory::TYPE_PAGE); + $page->getConfig()->getTitle()->set(__('Passkey Configuration')); + return $page; + } + + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + $user = $this->session->getUser(); + if (!$user) { + return false; + } + $userId = (int) $user->getId(); + $providerCode = $this->getProviderCode(); + + try { + $provider = $this->tfa->getProvider($providerCode); + return $provider !== null && $provider->isEnabled() && !$provider->isActive($userId); + } catch (\Exception $e) { + return false; + } + } + + private function getProviderCode(): string + { + $code = $this->getRequest()->getParam('provider'); + if (in_array($code, ['passkey', 'passkey_hardware'], true)) { + return $code; + } + return 'passkey'; + } +} diff --git a/Controller/Adminhtml/Passkey/ConfigurePost.php b/Controller/Adminhtml/Passkey/ConfigurePost.php new file mode 100644 index 0000000..e23345b --- /dev/null +++ b/Controller/Adminhtml/Passkey/ConfigurePost.php @@ -0,0 +1,104 @@ +session = $session; + } + + public function execute(): ResultInterface + { + $result = $this->jsonFactory->create(); + $user = $this->session->getUser(); + if ($user === null) { + return $result->setData([ + 'success' => false, + 'message' => __('Session expired. Please sign in again.'), + ]); + } + + try { + $providerCode = $this->getRequest()->getParam('provider', Engine::PROVIDER_CODE_ALL); + $authenticatorPolicy = $providerCode === Engine::PROVIDER_CODE_HARDWARE ? 'hardware' : 'all'; + + $credentialJson = $this->getRequest()->getParam('credential'); + + if ($credentialJson) { + // Phase 2: Process attestation response + $challengeToken = $this->getRequest()->getParam('challenge_token'); + $friendlyName = $this->getRequest()->getParam('friendly_name'); + + $this->configure->activate( + $user, + $challengeToken, + $credentialJson, + $providerCode, + $friendlyName + ); + + $this->tfaSession->grantAccess(); + $this->alert->event( + 'MageOS_PasskeyAuth', + 'Passkey registered for admin user ' . $user->getUserName(), + AlertInterface::LEVEL_INFO + ); + + return $result->setData(['success' => true]); + } + + // Phase 1: Generate registration options + $registrationData = $this->configure->getRegistrationData($user, $authenticatorPolicy); + + return $result->setData($registrationData); + } catch (\Exception $e) { + $this->alert->event( + 'MageOS_PasskeyAuth', + 'Passkey registration failed for admin user ' . $user->getUserName() + . ': ' . $e->getMessage(), + AlertInterface::LEVEL_WARNING + ); + + return $result->setData([ + 'success' => false, + 'message' => $e->getMessage(), + ]); + } + } + + protected function _isAllowed() + { + if (!parent::_isAllowed()) { + return false; + } + + $user = $this->session->getUser(); + return $user !== null; + } +} diff --git a/Controller/Authentication/Options.php b/Controller/Authentication/Options.php index 4c10074..65a9d8b 100644 --- a/Controller/Authentication/Options.php +++ b/Controller/Authentication/Options.php @@ -30,11 +30,18 @@ public function execute(): Json try { $body = $this->json->unserialize($this->request->getContent()); - $email = $body['email'] ?? null; + if (!is_array($body)) { + $body = []; + } + $email = isset($body['email']) && is_string($body['email']) ? $body['email'] : null; $optionsJson = $this->authenticationOptions->generate($email); + $optionsData = json_decode($optionsJson, true); + if (!is_array($optionsData)) { + throw new LocalizedException(__('Unable to generate authentication options.')); + } - return $resultJson->setData(json_decode($optionsJson, true)); + return $resultJson->setData($optionsData); } catch (LocalizedException $e) { return $resultJson->setHttpResponseCode(400)->setData([ 'errors' => true, diff --git a/Controller/Authentication/Verify.php b/Controller/Authentication/Verify.php index fcdb303..60ad7c8 100644 --- a/Controller/Authentication/Verify.php +++ b/Controller/Authentication/Verify.php @@ -40,13 +40,21 @@ public function execute(): Json try { $body = $this->json->unserialize($this->request->getContent()); + if (!is_array($body)) { + $body = []; + } $ip = $this->request->getClientIp() ?? 'unknown'; $this->rateLimiter->checkVerifyFailRate($ip); + $challengeToken = isset($body['challengeToken']) && is_string($body['challengeToken']) + ? $body['challengeToken'] + : ''; + $credential = $body['credential'] ?? []; + $result = $this->authenticationVerifier->verify( - $body['challengeToken'] ?? '', - $this->json->serialize($body['credential'] ?? []) + $challengeToken, + $this->json->serialize($credential) ); $customer = $this->customerRepository->getById($result->getCustomerId()); diff --git a/Controller/Registration/Options.php b/Controller/Registration/Options.php index 20ca27e..dc5bfa8 100644 --- a/Controller/Registration/Options.php +++ b/Controller/Registration/Options.php @@ -56,8 +56,12 @@ public function execute(): Json try { $customerId = (int) $this->customerSession->getCustomerId(); $optionsJson = $this->registrationOptions->generate($customerId); + $optionsData = json_decode($optionsJson, true); + if (!is_array($optionsData)) { + throw new LocalizedException(__('Unable to generate registration options.')); + } - return $resultJson->setData(json_decode($optionsJson, true)); + return $resultJson->setData($optionsData); } catch (LocalizedException $e) { return $resultJson->setHttpResponseCode(400)->setData([ 'errors' => true, diff --git a/Controller/Registration/Verify.php b/Controller/Registration/Verify.php index 754def6..8ce3653 100644 --- a/Controller/Registration/Verify.php +++ b/Controller/Registration/Verify.php @@ -57,13 +57,24 @@ public function execute(): Json try { $body = $this->json->unserialize($this->request->getContent()); + if (!is_array($body)) { + $body = []; + } $customerId = (int) $this->customerSession->getCustomerId(); + $challengeToken = isset($body['challengeToken']) && is_string($body['challengeToken']) + ? $body['challengeToken'] + : ''; + $credentialData = $body['credential'] ?? []; + $friendlyName = isset($body['friendlyName']) && is_string($body['friendlyName']) + ? $body['friendlyName'] + : null; + $credential = $this->registrationVerifier->verify( $customerId, - $body['challengeToken'] ?? '', - $this->json->serialize($body['credential'] ?? []), - $body['friendlyName'] ?? null + $challengeToken, + $this->json->serialize($credentialData), + $friendlyName ); return $resultJson->setData([ diff --git a/Model/AdminTfa/AdminTfaConfig.php b/Model/AdminTfa/AdminTfaConfig.php new file mode 100644 index 0000000..cd03947 --- /dev/null +++ b/Model/AdminTfa/AdminTfaConfig.php @@ -0,0 +1,62 @@ +getAdminBaseUrl()); + if (!isset($parsed['host'])) { + throw new LocalizedException(__('Could not determine admin domain from base URL.')); + } + return $parsed['host']; + } + + public function getRpName(): string + { + return $this->storeManager->getStore(Store::ADMIN_CODE)->getName(); + } + + public function getAllowedOrigins(): array + { + $parsed = parse_url($this->getAdminBaseUrl()); + $origin = ($parsed['scheme'] ?? 'https') . '://' . ($parsed['host'] ?? ''); + if (isset($parsed['port'])) { + $origin .= ':' . $parsed['port']; + } + return [$origin]; + } + + public function getUserVerification(): string + { + return 'required'; + } + + public function getAuthenticatorAttachment(string $policy): ?string + { + return $policy === 'hardware' ? 'cross-platform' : null; + } + + public function getAttestation(string $policy): string + { + return $policy === 'hardware' ? 'direct' : 'none'; + } + + private function getAdminBaseUrl(): string + { + return $this->storeManager->getStore(Store::ADMIN_CODE)->getBaseUrl(); + } +} diff --git a/Model/AdminTfa/Authenticate.php b/Model/AdminTfa/Authenticate.php new file mode 100644 index 0000000..276e153 --- /dev/null +++ b/Model/AdminTfa/Authenticate.php @@ -0,0 +1,166 @@ +getId(); + $config = $this->userConfigManager->getProviderConfig($userId, $providerCode); + + if (empty($config) || !isset($config['registration']['credential_id'])) { + throw new LocalizedException(__('Passkey is not configured for this user.')); + } + + $this->originValidator->validate($config['registration']); + + $credentialId = base64_decode($config['registration']['credential_id']); + + $allowCredentials = [ + PublicKeyCredentialDescriptor::create('public-key', $credentialId), + ]; + + $options = PublicKeyCredentialRequestOptions::create( + challenge: random_bytes(32), + rpId: $this->adminTfaConfig->getRpId(), + allowCredentials: $allowCredentials, + userVerification: $this->adminTfaConfig->getUserVerification(), + timeout: 60000, + ); + + $serializer = $this->serializerFactory->get(); + $optionsJson = $serializer->serialize($options, 'json'); + + $challengeToken = $this->challengeManager->create(self::CHALLENGE_TYPE, $optionsJson); + + $optionsArray = json_decode($optionsJson, true); + if (!is_array($optionsArray)) { + throw new LocalizedException(__('Failed to decode authentication options.')); + } + $optionsArray['challengeToken'] = $challengeToken; + + return $optionsArray; + } + + public function verifyAssertion(UserInterface $user, DataObject $request): bool + { + $challengeToken = $request->getData('challenge_token'); + $credentialJson = $request->getData('credential'); + + if (!$challengeToken || !$credentialJson) { + throw new LocalizedException(__('Missing challenge token or credential data.')); + } + + $userId = (int) $user->getId(); + $providerCode = $this->resolveProviderCode($userId); + + $config = $this->userConfigManager->getProviderConfig($userId, $providerCode); + if (empty($config) || !isset($config['registration'])) { + throw new LocalizedException(__('Passkey is not configured for this user.')); + } + + $this->originValidator->validate($config['registration']); + + $optionsJson = $this->challengeManager->consume($challengeToken, self::CHALLENGE_TYPE); + + $serializer = $this->serializerFactory->get(); + + $requestOptions = $serializer->deserialize( + $optionsJson, + PublicKeyCredentialRequestOptions::class, + 'json' + ); + + $credential = $serializer->deserialize( + $credentialJson, + PublicKeyCredential::class, + 'json' + ); + + $response = $credential->response; + if (!$response instanceof \Webauthn\AuthenticatorAssertionResponse) { + throw new LocalizedException(__('Invalid assertion response.')); + } + + $storedSource = $serializer->deserialize( + $config['registration']['credential_source'], + PublicKeyCredentialSource::class, + 'json' + ); + + $ceremonyStepManager = $this->ceremonyStepManagerProvider->getRequestCeremony(); + $validator = AuthenticatorAssertionResponseValidator::create($ceremonyStepManager); + + $updatedSource = $validator->check($storedSource, $response, $requestOptions); + + // Clone detection: warn on counter regression + $storedCount = (int) ($config['registration']['sign_count'] ?? 0); + $newCount = $updatedSource->counter; + if ($newCount > 0 && $newCount <= $storedCount) { + $this->logger->warning('Passkey sign counter regression detected (possible clone)', [ + 'admin_user_id' => $userId, + 'stored_count' => $storedCount, + 'new_count' => $newCount, + ]); + } + + // Update stored credential + $config['registration']['credential_source'] = $serializer->serialize($updatedSource, 'json'); + $config['registration']['sign_count'] = $updatedSource->counter; + $config['registration']['last_used_at'] = date('c'); + + $this->userConfigManager->setProviderConfig($userId, $providerCode, $config); + + $this->logger->info('Admin passkey authentication successful', [ + 'admin_user_id' => $userId, + 'provider' => $providerCode, + ]); + + return true; + } + + /** + * Determine which passkey provider code is active for this user. + */ + private function resolveProviderCode(int $userId): string + { + foreach ([Engine::PROVIDER_CODE_ALL, Engine::PROVIDER_CODE_HARDWARE] as $code) { + $config = $this->userConfigManager->getProviderConfig($userId, $code); + if (!empty($config) && isset($config['registration'])) { + return $code; + } + } + throw new LocalizedException(__('No passkey provider configured for this user.')); + } +} diff --git a/Model/AdminTfa/Configure.php b/Model/AdminTfa/Configure.php new file mode 100644 index 0000000..4976842 --- /dev/null +++ b/Model/AdminTfa/Configure.php @@ -0,0 +1,164 @@ +adminTfaConfig->getRpName(), + $this->adminTfaConfig->getRpId() + ); + + $userEntity = PublicKeyCredentialUserEntity::create( + $user->getUserName(), + hash('sha256', (string) $user->getId()), + $user->getFirstName() . ' ' . $user->getLastName() + ); + + $challenge = random_bytes(32); + + $credentialParameters = [ + PublicKeyCredentialParameters::create('public-key', -7), // ES256 + PublicKeyCredentialParameters::create('public-key', -257), // RS256 + ]; + + $authenticatorSelection = AuthenticatorSelectionCriteria::create( + authenticatorAttachment: $this->adminTfaConfig->getAuthenticatorAttachment($authenticatorPolicy), + userVerification: $this->adminTfaConfig->getUserVerification(), + residentKey: AuthenticatorSelectionCriteria::RESIDENT_KEY_REQUIREMENT_DISCOURAGED, + ); + + $excludeCredentials = $this->getExcludeCredentials($user); + + $options = PublicKeyCredentialCreationOptions::create( + rp: $rpEntity, + user: $userEntity, + challenge: $challenge, + pubKeyCredParams: $credentialParameters, + authenticatorSelection: $authenticatorSelection, + attestation: $this->adminTfaConfig->getAttestation($authenticatorPolicy), + excludeCredentials: $excludeCredentials, + timeout: 60000, + ); + + $serializer = $this->serializerFactory->get(); + $optionsJson = $serializer->serialize($options, 'json'); + + $challengeToken = $this->challengeManager->create(self::CHALLENGE_TYPE, $optionsJson); + + $optionsArray = json_decode($optionsJson, true); + if (!is_array($optionsArray)) { + throw new LocalizedException(__('Failed to decode registration options.')); + } + $optionsArray['challengeToken'] = $challengeToken; + + return $optionsArray; + } + + public function activate( + UserInterface $user, + string $challengeToken, + string $attestationResponseJson, + string $providerCode, + ?string $friendlyName = null + ): void { + $userId = (int) $user->getId(); + + $optionsJson = $this->challengeManager->consume($challengeToken, self::CHALLENGE_TYPE); + + $serializer = $this->serializerFactory->get(); + + $creationOptions = $serializer->deserialize( + $optionsJson, + PublicKeyCredentialCreationOptions::class, + 'json' + ); + + $credential = $serializer->deserialize( + $attestationResponseJson, + PublicKeyCredential::class, + 'json' + ); + + $response = $credential->response; + if (!$response instanceof AuthenticatorAttestationResponse) { + throw new LocalizedException(__('Invalid attestation response.')); + } + + $ceremonyStepManager = $this->ceremonyStepManagerProvider->getCreationCeremony(); + $validator = AuthenticatorAttestationResponseValidator::create($ceremonyStepManager); + + $credentialSource = $validator->check($response, $creationOptions); + + $credentialSourceJson = $serializer->serialize($credentialSource, 'json'); + + $this->userConfigManager->setProviderConfig($userId, $providerCode, [ + 'registration' => [ + 'credential_source' => $credentialSourceJson, + 'credential_id' => base64_encode($credentialSource->publicKeyCredentialId), + 'rp_id' => $this->adminTfaConfig->getRpId(), + 'friendly_name' => $friendlyName ? mb_substr(trim($friendlyName), 0, 255) : null, + 'aaguid' => $credentialSource->aaguid->toString(), + 'registered_at' => date('c'), + 'last_used_at' => null, + 'sign_count' => $credentialSource->counter, + ], + ]); + $this->userConfigManager->activateProviderConfiguration($userId, $providerCode); + + $this->logger->info('Admin passkey registered', [ + 'admin_user_id' => $userId, + 'provider' => $providerCode, + 'aaguid' => $credentialSource->aaguid->toString(), + ]); + } + + private function getExcludeCredentials(UserInterface $user): array + { + $excludeCredentials = []; + foreach ([Engine::PROVIDER_CODE_ALL, Engine::PROVIDER_CODE_HARDWARE] as $code) { + $config = $this->userConfigManager->getProviderConfig((int) $user->getId(), $code); + if (isset($config['registration']['credential_id'])) { + $excludeCredentials[] = PublicKeyCredentialDescriptor::create( + 'public-key', + base64_decode($config['registration']['credential_id']) + ); + } + } + return $excludeCredentials; + } +} diff --git a/Model/AdminTfa/Engine.php b/Model/AdminTfa/Engine.php new file mode 100644 index 0000000..16efb2d --- /dev/null +++ b/Model/AdminTfa/Engine.php @@ -0,0 +1,63 @@ +getId(); + $providerCode = $this->getProviderCode(); + + $config = $this->userConfigManager->getProviderConfig($userId, $providerCode); + if (empty($config) || !isset($config['registration'])) { + throw new LocalizedException(__( + 'Passkey is not configured for this user.' + )); + } + + $this->originValidator->validate($config['registration']); + + return $this->authenticate->verifyAssertion($user, $request); + } + + public function getAuthenticatorPolicy(): string + { + return $this->authenticatorPolicy; + } + + public function getProviderCode(): string + { + return $this->authenticatorPolicy === 'hardware' + ? self::PROVIDER_CODE_HARDWARE + : self::PROVIDER_CODE_ALL; + } +} diff --git a/Model/AdminTfa/OriginValidator.php b/Model/AdminTfa/OriginValidator.php new file mode 100644 index 0000000..4695f79 --- /dev/null +++ b/Model/AdminTfa/OriginValidator.php @@ -0,0 +1,34 @@ +adminTfaConfig->getRpId(); + if ($storedRpId !== $currentRpId) { + throw new LocalizedException(__( + 'The admin domain has changed since your passkey was registered ' + . '(was "%1", now "%2"). Please ask an administrator to reset your ' + . 'passkey configuration.', + $storedRpId, + $currentRpId + )); + } + } +} diff --git a/Model/Authentication/OptionsGenerator.php b/Model/Authentication/OptionsGenerator.php index a691a3e..b95b4d2 100644 --- a/Model/Authentication/OptionsGenerator.php +++ b/Model/Authentication/OptionsGenerator.php @@ -86,6 +86,9 @@ public function generate(?string $email = null): string ); $optionsArray = $this->json->unserialize($serializedOptions); + if (!is_array($optionsArray)) { + throw new LocalizedException(__('Failed to decode authentication options.')); + } $optionsArray['challengeToken'] = $challengeToken; return $this->json->serialize($optionsArray); diff --git a/Model/Config.php b/Model/Config.php index 4a80e18..5900d10 100644 --- a/Model/Config.php +++ b/Model/Config.php @@ -4,10 +4,11 @@ namespace MageOS\PasskeyAuth\Model; +use MageOS\PasskeyAuth\Api\WebAuthnConfigInterface; use Magento\Framework\App\Config\ScopeConfigInterface; use Magento\Store\Model\StoreManagerInterface; -class Config +class Config implements WebAuthnConfigInterface { public const XML_PATH_ENABLED = 'customer/passkey/enabled'; public const XML_PATH_PROMPT_AFTER_LOGIN = 'customer/passkey/prompt_after_login'; diff --git a/Model/Registration/OptionsGenerator.php b/Model/Registration/OptionsGenerator.php index ef064c8..36d071a 100644 --- a/Model/Registration/OptionsGenerator.php +++ b/Model/Registration/OptionsGenerator.php @@ -105,6 +105,9 @@ public function generate(int $customerId): string ); $optionsArray = $this->json->unserialize($serializedOptions); + if (!is_array($optionsArray)) { + throw new LocalizedException(__('Failed to decode registration options.')); + } $optionsArray['challengeToken'] = $challengeToken; return $this->json->serialize($optionsArray); diff --git a/Model/WebAuthn/CeremonyStepManagerProvider.php b/Model/WebAuthn/CeremonyStepManagerProvider.php index 0890b0b..aadbb0c 100644 --- a/Model/WebAuthn/CeremonyStepManagerProvider.php +++ b/Model/WebAuthn/CeremonyStepManagerProvider.php @@ -4,7 +4,7 @@ namespace MageOS\PasskeyAuth\Model\WebAuthn; -use MageOS\PasskeyAuth\Model\Config; +use MageOS\PasskeyAuth\Api\WebAuthnConfigInterface; use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\CeremonyStep\CeremonyStepManager; use Webauthn\CeremonyStep\CeremonyStepManagerFactory; @@ -12,7 +12,7 @@ class CeremonyStepManagerProvider { public function __construct( - private readonly Config $config, + private readonly WebAuthnConfigInterface $config, private readonly AttestationStatementSupportManager $attestationStatementSupportManager ) { } diff --git a/Test/Unit/Console/Command/ResetPasskeyTfaCommandTest.php b/Test/Unit/Console/Command/ResetPasskeyTfaCommandTest.php new file mode 100644 index 0000000..e282328 --- /dev/null +++ b/Test/Unit/Console/Command/ResetPasskeyTfaCommandTest.php @@ -0,0 +1,106 @@ +userConfigManager = $this->createMock(UserConfigManagerInterface::class); + $this->userCollectionFactory = $this->createMock(UserCollectionFactory::class); + + $this->command = new ResetPasskeyTfaCommand( + $this->userConfigManager, + $this->userCollectionFactory + ); + } + + public function testCommandHasCorrectName(): void + { + $this->assertSame('security:tfa:passkey:reset-all', $this->command->getName()); + } + + public function testForceOptionSkipsConfirmation(): void + { + $definition = $this->command->getDefinition(); + $this->assertTrue($definition->hasOption('force')); + } + + public function testExecuteResetsMatchingUsers(): void + { + $user1 = $this->createMock(User::class); + $user1->method('getId')->willReturn(1); + $user1->method('getUserName')->willReturn('admin1'); + + $user2 = $this->createMock(User::class); + $user2->method('getId')->willReturn(2); + $user2->method('getUserName')->willReturn('admin2'); + + $collection = $this->createMock(UserCollection::class); + $collection->method('getIterator')->willReturn(new \ArrayIterator([$user1, $user2])); + $this->userCollectionFactory->method('create')->willReturn($collection); + + $this->userConfigManager->method('getProviderConfig') + ->willReturnCallback(function (int $userId, string $code) { + if ($userId === 1 && $code === Engine::PROVIDER_CODE_ALL) { + return ['registration' => ['credential_id' => 'abc']]; + } + if ($userId === 2 && $code === Engine::PROVIDER_CODE_HARDWARE) { + return ['registration' => ['credential_id' => 'def']]; + } + return null; + }); + + $this->userConfigManager->expects($this->exactly(2)) + ->method('resetProviderConfig'); + + $input = $this->createMock(InputInterface::class); + $input->method('getOption')->with('force')->willReturn(true); + + $output = $this->createMock(OutputInterface::class); + + $ref = new \ReflectionMethod($this->command, 'execute'); + $ref->setAccessible(true); + $result = $ref->invoke($this->command, $input, $output); + + $this->assertSame(0, $result); + } + + public function testExecuteReturnsSuccessWhenNoUsersConfigured(): void + { + $collection = $this->createMock(UserCollection::class); + $collection->method('getIterator')->willReturn(new \ArrayIterator([])); + $this->userCollectionFactory->method('create')->willReturn($collection); + + $this->userConfigManager->expects($this->never())->method('resetProviderConfig'); + + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(OutputInterface::class); + $output->expects($this->once()) + ->method('writeln') + ->with('No admin users have passkey 2FA configured.'); + + $ref = new \ReflectionMethod($this->command, 'execute'); + $ref->setAccessible(true); + $result = $ref->invoke($this->command, $input, $output); + + $this->assertSame(0, $result); + } +} diff --git a/Test/Unit/Model/AdminTfa/AdminTfaConfigTest.php b/Test/Unit/Model/AdminTfa/AdminTfaConfigTest.php new file mode 100644 index 0000000..f19e7b6 --- /dev/null +++ b/Test/Unit/Model/AdminTfa/AdminTfaConfigTest.php @@ -0,0 +1,103 @@ +storeManager = $this->createMock(StoreManagerInterface::class); + $this->config = new AdminTfaConfig($this->storeManager); + } + + public function testGetRpIdExtractsDomainFromAdminBaseUrl(): void + { + $store = $this->createMock(Store::class); + $store->method('getBaseUrl')->willReturn('https://admin.example.com/'); + $this->storeManager->method('getStore') + ->with(Store::ADMIN_CODE) + ->willReturn($store); + + $this->assertSame('admin.example.com', $this->config->getRpId()); + } + + public function testGetRpIdHandlesPortInUrl(): void + { + $store = $this->createMock(Store::class); + $store->method('getBaseUrl')->willReturn('https://admin.example.com:8443/backend/'); + $this->storeManager->method('getStore') + ->with(Store::ADMIN_CODE) + ->willReturn($store); + + $this->assertSame('admin.example.com', $this->config->getRpId()); + } + + public function testGetAllowedOriginsReturnsSchemeAndHost(): void + { + $store = $this->createMock(Store::class); + $store->method('getBaseUrl')->willReturn('https://admin.example.com/'); + $this->storeManager->method('getStore') + ->with(Store::ADMIN_CODE) + ->willReturn($store); + + $this->assertSame(['https://admin.example.com'], $this->config->getAllowedOrigins()); + } + + public function testGetAllowedOriginsIncludesNonStandardPort(): void + { + $store = $this->createMock(Store::class); + $store->method('getBaseUrl')->willReturn('https://admin.example.com:8443/backend/'); + $this->storeManager->method('getStore') + ->with(Store::ADMIN_CODE) + ->willReturn($store); + + $this->assertSame(['https://admin.example.com:8443'], $this->config->getAllowedOrigins()); + } + + public function testGetRpNameReturnsStoreName(): void + { + $store = $this->createMock(Store::class); + $store->method('getName')->willReturn('My Store Admin'); + $this->storeManager->method('getStore') + ->with(Store::ADMIN_CODE) + ->willReturn($store); + + $this->assertSame('My Store Admin', $this->config->getRpName()); + } + + public function testGetUserVerificationReturnsRequired(): void + { + $this->assertSame('required', $this->config->getUserVerification()); + } + + public function testGetAuthenticatorAttachmentReturnsNullForAllPolicy(): void + { + $this->assertNull($this->config->getAuthenticatorAttachment('all')); + } + + public function testGetAuthenticatorAttachmentReturnsCrossPlatformForHardwarePolicy(): void + { + $this->assertSame('cross-platform', $this->config->getAuthenticatorAttachment('hardware')); + } + + public function testGetAttestationReturnsNoneForAllPolicy(): void + { + $this->assertSame('none', $this->config->getAttestation('all')); + } + + public function testGetAttestationReturnsDirectForHardwarePolicy(): void + { + $this->assertSame('direct', $this->config->getAttestation('hardware')); + } +} diff --git a/Test/Unit/Model/AdminTfa/EngineTest.php b/Test/Unit/Model/AdminTfa/EngineTest.php new file mode 100644 index 0000000..176ab35 --- /dev/null +++ b/Test/Unit/Model/AdminTfa/EngineTest.php @@ -0,0 +1,99 @@ +userConfigManager = $this->createMock(UserConfigManagerInterface::class); + $this->originValidator = $this->createMock(OriginValidator::class); + $this->authenticate = $this->createMock(AuthenticateInterface::class); + + $this->engine = new Engine( + $this->userConfigManager, + $this->originValidator, + $this->authenticate, + 'all' + ); + } + + public function testIsEnabledReturnsTrue(): void + { + $this->assertTrue($this->engine->isEnabled()); + } + + public function testVerifyDelegatesToAuthenticate(): void + { + $user = $this->createMock(UserInterface::class); + $user->method('getId')->willReturn('42'); + $request = new DataObject(['credential' => '{"id":"abc"}', 'challenge_token' => 'tok123']); + + $this->userConfigManager->method('getProviderConfig') + ->with(42, 'passkey') + ->willReturn(['registration' => ['rp_id' => 'admin.example.com']]); + + $this->originValidator->expects($this->once()) + ->method('validate') + ->with(['rp_id' => 'admin.example.com']); + + $this->authenticate->expects($this->once()) + ->method('verifyAssertion') + ->with($user, $request) + ->willReturn(true); + + $this->assertTrue($this->engine->verify($user, $request)); + } + + public function testVerifyThrowsOnDomainMismatch(): void + { + $user = $this->createMock(UserInterface::class); + $user->method('getId')->willReturn('42'); + $request = new DataObject(); + + $this->userConfigManager->method('getProviderConfig') + ->willReturn(['registration' => ['rp_id' => 'old.example.com']]); + + $this->originValidator->method('validate') + ->willThrowException(new LocalizedException(__('domain has changed'))); + + $this->expectException(LocalizedException::class); + $this->engine->verify($user, $request); + } + + public function testVerifyThrowsWhenNotConfigured(): void + { + $user = $this->createMock(UserInterface::class); + $user->method('getId')->willReturn('42'); + $request = new DataObject(); + + $this->userConfigManager->method('getProviderConfig') + ->willReturn(null); + + $this->expectException(LocalizedException::class); + $this->expectExceptionMessage('not configured'); + $this->engine->verify($user, $request); + } + + public function testGetAuthenticatorPolicyReturnsConstructorValue(): void + { + $this->assertSame('all', $this->engine->getAuthenticatorPolicy()); + } +} diff --git a/Test/Unit/Model/AdminTfa/OriginValidatorTest.php b/Test/Unit/Model/AdminTfa/OriginValidatorTest.php new file mode 100644 index 0000000..d8612e5 --- /dev/null +++ b/Test/Unit/Model/AdminTfa/OriginValidatorTest.php @@ -0,0 +1,48 @@ +adminTfaConfig = $this->createMock(AdminTfaConfig::class); + $this->validator = new OriginValidator($this->adminTfaConfig); + } + + public function testValidatePassesWhenRpIdMatches(): void + { + $this->adminTfaConfig->method('getRpId')->willReturn('admin.example.com'); + $config = ['rp_id' => 'admin.example.com']; + $this->validator->validate($config); + $this->addToAssertionCount(1); + } + + public function testValidateThrowsWhenRpIdMismatches(): void + { + $this->adminTfaConfig->method('getRpId')->willReturn('new-admin.example.com'); + $config = ['rp_id' => 'old-admin.example.com']; + $this->expectException(LocalizedException::class); + $this->expectExceptionMessage('admin domain has changed'); + $this->validator->validate($config); + } + + public function testValidatePassesWhenNoRpIdStored(): void + { + $this->adminTfaConfig->method('getRpId')->willReturn('admin.example.com'); + $config = []; + $this->validator->validate($config); + $this->addToAssertionCount(1); + } +} diff --git a/Test/Unit/Model/WebAuthn/CeremonyStepManagerProviderTest.php b/Test/Unit/Model/WebAuthn/CeremonyStepManagerProviderTest.php index 74a4211..b6dedb3 100644 --- a/Test/Unit/Model/WebAuthn/CeremonyStepManagerProviderTest.php +++ b/Test/Unit/Model/WebAuthn/CeremonyStepManagerProviderTest.php @@ -4,21 +4,19 @@ namespace MageOS\PasskeyAuth\Test\Unit\Model\WebAuthn; +use MageOS\PasskeyAuth\Api\WebAuthnConfigInterface; use MageOS\PasskeyAuth\Model\WebAuthn\CeremonyStepManagerProvider; -use MageOS\PasskeyAuth\Test\Unit\Traits\MocksConfigTrait; use PHPUnit\Framework\TestCase; use Webauthn\AttestationStatement\AttestationStatementSupportManager; use Webauthn\CeremonyStep\CeremonyStepManager; class CeremonyStepManagerProviderTest extends TestCase { - use MocksConfigTrait; - private CeremonyStepManagerProvider $provider; protected function setUp(): void { - $configMock = $this->createConfigMock(); + $configMock = $this->createMock(WebAuthnConfigInterface::class); $configMock->method('getAllowedOrigins')->willReturn(['https://example.com']); $this->provider = new CeremonyStepManagerProvider( diff --git a/composer.json b/composer.json index e9eeb1d..7811cd2 100644 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "magento/module-customer": "*", "magento/module-integration": "*", "magento/module-store": "*", + "magento/module-two-factor-auth": "*", "web-auth/webauthn-lib": "^5.0" }, "require-dev": { diff --git a/etc/adminhtml/routes.xml b/etc/adminhtml/routes.xml new file mode 100644 index 0000000..fe81f9c --- /dev/null +++ b/etc/adminhtml/routes.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/etc/di.xml b/etc/di.xml index 037d251..34c36e8 100644 --- a/etc/di.xml +++ b/etc/di.xml @@ -2,6 +2,10 @@ + + + @@ -27,4 +31,93 @@ + + + + + + + + + MageOS\PasskeyAuth\Model\AdminTfa\AdminTfaConfig + + + + + + + MageOS\PasskeyAuth\Model\WebAuthn\AdminCeremonyStepManagerProvider + + + + + + + MageOS\PasskeyAuth\Model\WebAuthn\AdminCeremonyStepManagerProvider + + + + + + + all + + + + + + hardware + + + + + + + MageOS\PasskeyAuth\Model\AdminTfa\Engine\AllPasskeys + passkey + Passkey + MageOS_PasskeyAuth::images/providers/passkey.png + tfa/passkey/configure + tfa/passkey/auth + true + + + + + + MageOS\PasskeyAuth\Model\AdminTfa\Engine\HardwareOnly + passkey_hardware + Passkey (Hardware Key Only) + MageOS_PasskeyAuth::images/providers/passkey-hardware.png + tfa/passkey/configure + tfa/passkey/auth + true + + + + + + + + MageOS\PasskeyAuth\Model\Provider\Passkey + MageOS\PasskeyAuth\Model\Provider\PasskeyHardware + + + + + + + + + MageOS\PasskeyAuth\Console\Command\ResetPasskeyTfaCommand + + + diff --git a/etc/module.xml b/etc/module.xml index 62e9bd9..7f370c4 100644 --- a/etc/module.xml +++ b/etc/module.xml @@ -6,6 +6,7 @@ + diff --git a/view/adminhtml/layout/tfa_passkey_auth.xml b/view/adminhtml/layout/tfa_passkey_auth.xml new file mode 100644 index 0000000..47dc2f5 --- /dev/null +++ b/view/adminhtml/layout/tfa_passkey_auth.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + MageOS_PasskeyAuth/js/passkey-tfa-auth + + + + + + + + + diff --git a/view/adminhtml/layout/tfa_passkey_configure.xml b/view/adminhtml/layout/tfa_passkey_configure.xml new file mode 100644 index 0000000..755b876 --- /dev/null +++ b/view/adminhtml/layout/tfa_passkey_configure.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + MageOS_PasskeyAuth/js/passkey-tfa-configure + + + + + + + + diff --git a/view/adminhtml/templates/tfa/passkey/auth.phtml b/view/adminhtml/templates/tfa/passkey/auth.phtml new file mode 100644 index 0000000..c0773d3 --- /dev/null +++ b/view/adminhtml/templates/tfa/passkey/auth.phtml @@ -0,0 +1,27 @@ +getRequest()->getParam('provider', 'passkey'); +?> +
+ +
+getChildHtml('tfa-change-provider') ?> + diff --git a/view/adminhtml/templates/tfa/passkey/configure.phtml b/view/adminhtml/templates/tfa/passkey/configure.phtml new file mode 100644 index 0000000..d39c2ee --- /dev/null +++ b/view/adminhtml/templates/tfa/passkey/configure.phtml @@ -0,0 +1,26 @@ +getRequest()->getParam('provider', 'passkey'); +?> +
+ +
+ diff --git a/view/adminhtml/web/images/providers/passkey-hardware.png b/view/adminhtml/web/images/providers/passkey-hardware.png new file mode 100644 index 0000000..d984977 Binary files /dev/null and b/view/adminhtml/web/images/providers/passkey-hardware.png differ diff --git a/view/adminhtml/web/images/providers/passkey.png b/view/adminhtml/web/images/providers/passkey.png new file mode 100644 index 0000000..d984977 Binary files /dev/null and b/view/adminhtml/web/images/providers/passkey.png differ diff --git a/view/adminhtml/web/js/passkey-tfa-auth.js b/view/adminhtml/web/js/passkey-tfa-auth.js new file mode 100644 index 0000000..c8e837d --- /dev/null +++ b/view/adminhtml/web/js/passkey-tfa-auth.js @@ -0,0 +1,102 @@ +define([ + 'uiComponent', + 'jquery', + 'MageOS_PasskeyAuth/js/passkey-core', + 'mage/translate' +], function (Component, $, passkeyCore, $t) { + return Component.extend({ + defaults: { + template: 'MageOS_PasskeyAuth/tfa/passkey/auth', + postUrl: '', + successUrl: '', + provider: 'passkey', + currentStep: 'idle', + errorMessage: '' + }, + + initObservable: function () { + this._super().observe(['currentStep', 'errorMessage']); + return this; + }, + + initialize: function () { + this._super(); + + if (!passkeyCore.isAvailable()) { + this.currentStep('no-webauthn'); + return this; + } + + // Auto-trigger authentication on load + this.authenticate(); + + return this; + }, + + authenticate: function () { + var self = this; + this.currentStep('authenticating'); + this.errorMessage(''); + + $.ajax({ + url: this.postUrl, + type: 'POST', + dataType: 'json', + data: { + form_key: window.FORM_KEY, + provider: this.provider + } + }).done(function (options) { + if (options.message) { + self.errorMessage(options.message); + self.currentStep('error'); + return; + } + + var challengeToken = options.challengeToken; + var requestOptions = passkeyCore.prepareRequestOptions(options); + + navigator.credentials.get({ publicKey: requestOptions }) + .then(function (credential) { + var serialized = passkeyCore.serializeAssertionResponse(credential); + + return $.ajax({ + url: self.postUrl, + type: 'POST', + dataType: 'json', + data: { + form_key: window.FORM_KEY, + challenge_token: challengeToken, + credential: JSON.stringify(serialized), + provider: self.provider + } + }); + }) + .then(function (response) { + if (response.success) { + self.currentStep('success'); + window.location.href = response.redirect_url || self.successUrl; + } else { + self.errorMessage(response.message || $t('Authentication failed.')); + self.currentStep('error'); + } + }) + .catch(function (err) { + if (err.name !== 'AbortError') { + self.errorMessage(err.message || $t('Authentication failed.')); + self.currentStep('error'); + } else { + self.currentStep('idle'); + } + }); + }).fail(function () { + self.errorMessage($t('Server error. Please try again.')); + self.currentStep('error'); + }); + }, + + retry: function () { + this.authenticate(); + } + }); +}); diff --git a/view/adminhtml/web/js/passkey-tfa-configure.js b/view/adminhtml/web/js/passkey-tfa-configure.js new file mode 100644 index 0000000..988a7b9 --- /dev/null +++ b/view/adminhtml/web/js/passkey-tfa-configure.js @@ -0,0 +1,102 @@ +define([ + 'uiComponent', + 'jquery', + 'MageOS_PasskeyAuth/js/passkey-core', + 'mage/translate' +], function (Component, $, passkeyCore, $t) { + return Component.extend({ + defaults: { + template: 'MageOS_PasskeyAuth/tfa/passkey/configure', + postUrl: '', + successUrl: '', + provider: 'passkey', + currentStep: 'idle', + errorMessage: '', + friendlyName: '' + }, + + initObservable: function () { + this._super().observe(['currentStep', 'errorMessage', 'friendlyName']); + return this; + }, + + initialize: function () { + this._super(); + + if (!passkeyCore.isAvailable()) { + this.currentStep('no-webauthn'); + } + + return this; + }, + + register: function () { + var self = this; + this.currentStep('registering'); + this.errorMessage(''); + + $.ajax({ + url: this.postUrl, + type: 'POST', + dataType: 'json', + data: { + form_key: window.FORM_KEY + } + }).done(function (options) { + if (options.message) { + self.errorMessage(options.message); + self.currentStep('error'); + return; + } + + var challengeToken = options.challengeToken; + var createOptions = passkeyCore.prepareCreationOptions(options); + + navigator.credentials.create({ publicKey: createOptions }) + .then(function (credential) { + var serialized = passkeyCore.serializeAttestationResponse(credential); + + return $.ajax({ + url: self.postUrl, + type: 'POST', + dataType: 'json', + data: { + form_key: window.FORM_KEY, + challenge_token: challengeToken, + credential: JSON.stringify(serialized), + friendly_name: self.friendlyName(), + provider: self.provider + } + }); + }) + .then(function (response) { + if (response.success) { + self.currentStep('registered'); + setTimeout(function () { + window.location.href = self.successUrl; + }, 1500); + } else { + self.errorMessage(response.message || $t('Registration failed.')); + self.currentStep('error'); + } + }) + .catch(function (err) { + if (err.name !== 'AbortError') { + self.errorMessage(err.message || $t('Registration failed.')); + self.currentStep('error'); + } else { + self.currentStep('idle'); + } + }); + }).fail(function () { + self.errorMessage($t('Server error. Please try again.')); + self.currentStep('error'); + }); + }, + + retry: function () { + this.currentStep('idle'); + this.errorMessage(''); + } + }); +}); diff --git a/view/adminhtml/web/template/tfa/passkey/auth.html b/view/adminhtml/web/template/tfa/passkey/auth.html new file mode 100644 index 0000000..2802070 --- /dev/null +++ b/view/adminhtml/web/template/tfa/passkey/auth.html @@ -0,0 +1,41 @@ +
+ +
+ + +
+ + + +
+
+

+
+ + + +
+
+

+
+ + + +
+ +
+ + + + +
+ +
+ +
diff --git a/view/adminhtml/web/template/tfa/passkey/configure.html b/view/adminhtml/web/template/tfa/passkey/configure.html new file mode 100644 index 0000000..60e3961 --- /dev/null +++ b/view/adminhtml/web/template/tfa/passkey/configure.html @@ -0,0 +1,58 @@ +
+ +
+ +

+
+ +
+ +
+
+
+
+ +
+
+
+ + + +
+
+

+
+ + + +
+ +
+ + + +
+ +
+ + + + +
+ +
+ +
diff --git a/view/frontend/web/js/passkey-core.js b/view/base/web/js/passkey-core.js similarity index 100% rename from view/frontend/web/js/passkey-core.js rename to view/base/web/js/passkey-core.js