diff --git a/app/code/Magento/LoginAsCustomerApi/Api/GenerateLoginCustomerTokenInterface.php b/app/code/Magento/LoginAsCustomerApi/Api/GenerateLoginCustomerTokenInterface.php new file mode 100644 index 0000000000000..98e3b3c719b99 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerApi/Api/GenerateLoginCustomerTokenInterface.php @@ -0,0 +1,26 @@ +loginAsCustomerConfig->isEnabled()) { + throw new LocalizedException(__('Login As Customer module is disabled.')); + } + try { + $authenticationData = $this->getAuthenticationDataBySecret->execute($secret); + $customerId = $authenticationData->getCustomerId(); + } catch (\Exception $e) { + throw new AuthenticationException(__('Invalid or expired secret.')); + } + $context = new CustomUserContext( + (int)$customerId, + CustomUserContext::USER_TYPE_CUSTOMER + ); + $params = $this->tokenManager->createUserTokenParameters(); + return $this->tokenManager->create($context, $params); + } +} diff --git a/app/code/Magento/LoginAsCustomerApi/README.md b/app/code/Magento/LoginAsCustomerApi/README.md index fddc5d97d4696..cbf1ca9e39b83 100644 --- a/app/code/Magento/LoginAsCustomerApi/README.md +++ b/app/code/Magento/LoginAsCustomerApi/README.md @@ -48,6 +48,9 @@ This module provides API for ability to login into customer account for an admin - `\Magento\LoginAsCustomerApi\Api\SetLoggedAsCustomerCustomerIdInterface`: - set id of customer admin is logged as +- `\Magento\LoginAsCustomerApi\Api\GenerateLoginCustomerTokenInterface`: + - generate an integration access token from a valid Login As Customer secret + For information about a public API, see [Public interfaces & APIs](https://developer.adobe.com/commerce/php/development/components/api-concepts/). ## Additional information diff --git a/app/code/Magento/LoginAsCustomerApi/Test/Unit/Model/GenerateLoginCustomerTokenTest.php b/app/code/Magento/LoginAsCustomerApi/Test/Unit/Model/GenerateLoginCustomerTokenTest.php new file mode 100644 index 0000000000000..038b1851fc4b8 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerApi/Test/Unit/Model/GenerateLoginCustomerTokenTest.php @@ -0,0 +1,125 @@ +getAuthenticationDataBySecret = $this->createMock(GetAuthenticationDataBySecretInterface::class); + $this->loginAsCustomerConfig = $this->createMock(LoginAsCustomerConfig::class); + $this->tokenManager = $this->createMock(TokenManager::class); + + $this->service = new GenerateLoginCustomerToken( + $this->getAuthenticationDataBySecret, + $this->loginAsCustomerConfig, + $this->tokenManager + ); + } + + public function testModuleDisabledThrowsException(): void + { + $this->loginAsCustomerConfig + ->expects($this->once()) + ->method('isEnabled') + ->willReturn(false); + + $this->expectException(LocalizedException::class); + $this->expectExceptionMessage('Login As Customer module is disabled.'); + + $this->service->createCustomerAccessToken('secret123'); + } + + public function testInvalidSecretThrowsAuthenticationException(): void + { + $this->loginAsCustomerConfig + ->expects($this->once()) + ->method('isEnabled') + ->willReturn(true); + + $this->getAuthenticationDataBySecret + ->expects($this->once()) + ->method('execute') + ->willThrowException(new \Exception('Secret invalid.')); + + $this->expectException(AuthenticationException::class); + $this->expectExceptionMessage('Invalid or expired secret.'); + + $this->service->createCustomerAccessToken('invalid-secret'); + } + + public function testValidSecretReturnsToken(): void + { + $secret = 'valid-secret'; + $customerId = 42; + $expectedToken = 'generated-token-xyz'; + + $this->loginAsCustomerConfig + ->expects($this->once()) + ->method('isEnabled') + ->willReturn(true); + + $authenticationData = $this->createMock(AuthenticationDataInterface::class); + $authenticationData + ->expects($this->once()) + ->method('getCustomerId') + ->willReturn($customerId); + + $this->getAuthenticationDataBySecret + ->expects($this->once()) + ->method('execute') + ->with($secret) + ->willReturn($authenticationData); + + $tokenParams = $this->createMock(UserTokenParameters::class); + $this->tokenManager + ->expects($this->once()) + ->method('createUserTokenParameters') + ->willReturn($tokenParams); + + $this->tokenManager + ->expects($this->once()) + ->method('create') + ->with( + $this->callback(function ($context) use ($customerId) { + return $context instanceof CustomUserContext + && $context->getUserId() === $customerId + && $context->getUserType() === CustomUserContext::USER_TYPE_CUSTOMER; + }), + $tokenParams + ) + ->willReturn($expectedToken); + + $result = $this->service->createCustomerAccessToken($secret); + + $this->assertSame($expectedToken, $result); + } +} diff --git a/app/code/Magento/LoginAsCustomerApi/etc/acl.xml b/app/code/Magento/LoginAsCustomerApi/etc/acl.xml new file mode 100644 index 0000000000000..45312d7476d10 --- /dev/null +++ b/app/code/Magento/LoginAsCustomerApi/etc/acl.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + diff --git a/app/code/Magento/LoginAsCustomerApi/etc/di.xml b/app/code/Magento/LoginAsCustomerApi/etc/di.xml index d92f8e06febc2..59f03c92c6dc5 100644 --- a/app/code/Magento/LoginAsCustomerApi/etc/di.xml +++ b/app/code/Magento/LoginAsCustomerApi/etc/di.xml @@ -9,4 +9,6 @@ xsi:noNamespaceSchemaLocation="urn:magento:framework:ObjectManager/etc/config.xsd"> + diff --git a/app/code/Magento/LoginAsCustomerApi/etc/webapi.xml b/app/code/Magento/LoginAsCustomerApi/etc/webapi.xml new file mode 100644 index 0000000000000..938d295a4275a --- /dev/null +++ b/app/code/Magento/LoginAsCustomerApi/etc/webapi.xml @@ -0,0 +1,17 @@ + + + + + + + + + + diff --git a/dev/tests/api-functional/testsuite/Magento/Cms/Api/PageRepositoryTest.php b/dev/tests/api-functional/testsuite/Magento/Cms/Api/PageRepositoryTest.php index 9fa9d5bb460f7..cdc91d8e8fbce 100644 --- a/dev/tests/api-functional/testsuite/Magento/Cms/Api/PageRepositoryTest.php +++ b/dev/tests/api-functional/testsuite/Magento/Cms/Api/PageRepositoryTest.php @@ -419,7 +419,7 @@ public function testDelete(): void * @dataProvider byStoresProvider * @magentoApiDataFixture Magento/Cms/_files/pages.php * @magentoApiDataFixture Magento/Store/_files/second_website_with_store_group_and_store.php - * @param string $requestStore + * @param string $requestStore$rules * @return void */ public function testDeleteByStores(string $requestStore): void diff --git a/dev/tests/api-functional/testsuite/Magento/LoginAsCustomerApi/Api/GenerateLoginCustomerTokenTest.php b/dev/tests/api-functional/testsuite/Magento/LoginAsCustomerApi/Api/GenerateLoginCustomerTokenTest.php new file mode 100644 index 0000000000000..8f62969336f1f --- /dev/null +++ b/dev/tests/api-functional/testsuite/Magento/LoginAsCustomerApi/Api/GenerateLoginCustomerTokenTest.php @@ -0,0 +1,158 @@ +objectManager = Bootstrap::getObjectManager(); + $this->adminTokens = $this->objectManager->get(AdminTokenServiceInterface::class); + } + + /** + * @magentoApiDataFixture Magento/LoginAsCustomer/_files/customer.php + * @magentoApiDataFixture Magento/LoginAsCustomerApi/_files/admin_login_as_customer.php + * @magentoConfigFixture default/login_as_customer/general/enabled 1 + */ + public function testValidSecretGeneratesToken(): void + { + $user = $this->loadAdminUser(); + $secret = $this->generateSecret((int)$user->getId()); + $requestData = ['secret' => $secret]; + + $token = $this->_webApiCall($this->getServiceInfo(), $requestData); + + $this->assertIsString($token); + $this->assertNotEmpty($token); + } + + /** + * @magentoApiDataFixture Magento/LoginAsCustomerApi/_files/admin_login_as_customer.php + * @magentoConfigFixture default/login_as_customer/general/enabled 1 + */ + public function testInvalidSecretThrowsException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('{"message":"Invalid or expired secret."}'); + + $this->_webApiCall($this->getServiceInfo(), ['secret' => 'invalid-secret']); + } + + /** + * @magentoApiDataFixture Magento/LoginAsCustomerApi/_files/admin_login_as_customer.php + * @magentoConfigFixture default/login_as_customer/general/enabled 0 + */ + public function testModuleDisabledThrowsException(): void + { + $this->expectException(\Exception::class); + $this->expectExceptionMessage('{"message":"Login As Customer module is disabled."}'); + + $this->_webApiCall($this->getServiceInfo(), ['secret' => 'test']); + } + + /** + * @magentoApiDataFixture Magento/LoginAsCustomer/_files/customer.php + * @magentoApiDataFixture Magento/LoginAsCustomerApi/_files/admin_login_as_customer.php + * @magentoConfigFixture default/login_as_customer/general/enabled 1 + */ + public function testAdminDeletedAfterSecretGeneration(): void + { + $user = $this->loadAdminUser(); + $secret = $this->generateSecret((int)$user->getId()); + + // Delete admin user + $userResource = $this->objectManager->get(\Magento\User\Model\ResourceModel\User::class); + $userResource->delete($user); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('The account sign-in was incorrect or your account is disabled temporarily. Please wait and try again later.'); + + $this->_webApiCall($this->getServiceInfo(), ['secret' => $secret]); + } + + /** + * @magentoApiDataFixture Magento/LoginAsCustomer/_files/customer.php + * @magentoApiDataFixture Magento/LoginAsCustomerApi/_files/admin_login_as_customer.php + * @magentoConfigFixture default/login_as_customer/general/enabled 1 + */ + public function testTamperedSecretThrowsException(): void + { + $user = $this->loadAdminUser(); + $secret = $this->generateSecret((int)$user->getId()); + $tampered = $secret . 'AA'; + $this->expectException(\Exception::class); + $this->expectExceptionMessage('{"message":"Invalid or expired secret."}'); + $this->_webApiCall($this->getServiceInfo(), ['secret' => $tampered]); + } + + /** + * Generate REST service info with token. + */ + private function getServiceInfo(): array + { + $token = $this->adminTokens->createAdminAccessToken( + self::ADMIN_USERNAME, + \Magento\TestFramework\Bootstrap::ADMIN_PASSWORD + ); + + return [ + 'rest' => [ + 'resourcePath' => self::RESOURCE_PATH, + 'httpMethod' => Request::HTTP_METHOD_POST, + 'token' => $token, + ], + ]; + } + + /** + * Loads the admin user created by fixture. + */ + private function loadAdminUser(): User + { + $user = $this->objectManager->create(User::class); + $user->load(self::ADMIN_USERNAME, 'username'); + + if (!$user->getId()) { + $this->fail('Admin user not found. Fixture admin_login_as_customer.php failed.'); + } + + return $user; + } + + /** + * Generates secret for given admin. + */ + private function generateSecret(int $adminId): string + { + $authData = $this->objectManager + ->get(AuthenticationDataInterfaceFactory::class) + ->create([ + 'customerId' => 1, + 'adminId' => $adminId, + ]); + + return $this->objectManager->get(GenerateAuthenticationSecret::class) + ->execute($authData); + } +} diff --git a/dev/tests/integration/testsuite/Magento/LoginAsCustomerApi/_files/admin_login_as_customer.php b/dev/tests/integration/testsuite/Magento/LoginAsCustomerApi/_files/admin_login_as_customer.php new file mode 100644 index 0000000000000..0b28ca0ecc2db --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/LoginAsCustomerApi/_files/admin_login_as_customer.php @@ -0,0 +1,56 @@ +get(RoleFactory::class)->create(); +$role->setName('login_as_customer_api_role'); +$role->setData('role_name', $role->getName()); +$role->setRoleType(Group::ROLE_TYPE); +$role->setUserType((string)UserContextInterface::USER_TYPE_ADMIN); + +/** @var RoleResource $roleResource */ +$roleResource = Bootstrap::getObjectManager()->get(RoleResource::class); +$roleResource->save($role); + +/** + * ACL rule: allow access to API token generation + */ +$rules = Bootstrap::getObjectManager()->get(RulesFactory::class)->create(); +$rules->setRoleId($role->getId()); +$rules->setResources(['Magento_LoginAsCustomerApi::token']); + +/** @var RulesResource $rulesResource */ +$rulesResource = Bootstrap::getObjectManager()->get(RulesResource::class); +$rulesResource->saveRel($rules); + +/** + * Create admin user + */ +$user = Bootstrap::getObjectManager()->create(User::class); +$user->setUsername('TestAdminLoginAsCustomer') + ->setFirstname('John') + ->setLastname('Doe') + ->setEmail('testAdminLoginAsCustomer@example.com') + ->setPassword(\Magento\TestFramework\Bootstrap::ADMIN_PASSWORD) + ->setIsActive(1) + ->setRoleId($role->getId()); + +/** @var UserResource $userResource */ +$userResource = Bootstrap::getObjectManager()->get(UserResource::class); +$userResource->save($user); diff --git a/dev/tests/integration/testsuite/Magento/LoginAsCustomerApi/_files/admin_login_as_customer_rollback.php b/dev/tests/integration/testsuite/Magento/LoginAsCustomerApi/_files/admin_login_as_customer_rollback.php new file mode 100644 index 0000000000000..2bda6a7fb9239 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/LoginAsCustomerApi/_files/admin_login_as_customer_rollback.php @@ -0,0 +1,42 @@ +create(User::class); +$user->load('TestAdminLoginAsCustomer', 'username'); + +/** @var UserResource $userResource */ +$userResource = Bootstrap::getObjectManager()->get(UserResource::class); +$userResource->delete($user); + +/** @var Role $role */ +$role = Bootstrap::getObjectManager()->get(RoleFactory::class)->create(); +$role->load('login_as_customer_api_role', 'role_name'); + + +/** @var Rules $rules */ +$rules = Bootstrap::getObjectManager()->get(RulesFactory::class)->create(); +$rules->load($role->getId(), 'role_id'); + +/** @var RulesResource $rulesResource */ +$rulesResource = Bootstrap::getObjectManager()->get(RulesResource::class); +$rulesResource->delete($rules); + +/** @var RoleResource $roleResource */ +$roleResource = Bootstrap::getObjectManager()->get(RoleResource::class); +$roleResource->delete($role);