diff --git a/CHANGELOG-v10.md b/CHANGELOG-v10.md index 9cd5dbe5..76bca0ff 100644 --- a/CHANGELOG-v10.md +++ b/CHANGELOG-v10.md @@ -26,5 +26,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New configuration options: - `sRefreshTokenLifetime` - options for refresh token lifetime, from 24 hours to 90 days - `sFingerprintCookieMode` - option for the authentication fingerprint cookie mode, same or cross origin +- Access and refresh tokens are now invalidated when the user's password is changed + - New methods: + - `OxidEsales\GraphQL\Base\Infrastructure\RefreshTokenRepositoryInterface::invalidateUserTokens` + - `OxidEsales\GraphQL\Base\Infrastructure\Token::invalidateUserTokens` + - New event subscriber: + - `OxidEsales\GraphQL\Base\Event\Subscriber\PasswordChangeSubscriber` + +## Changed +- Renamed OxidEsales\GraphQL\Base\Infrastructure\Token::cleanUpTokens() to deleteOrphanedTokens() [10.0.0]: https://github.com/OXID-eSales/graphql-base-module/compare/v9.0.0...b-7.2.x diff --git a/services.yaml b/services.yaml index 2c453716..1944a3ea 100644 --- a/services.yaml +++ b/services.yaml @@ -80,6 +80,9 @@ services: OxidEsales\GraphQL\Base\Service\FingerprintServiceInterface: class: OxidEsales\GraphQL\Base\Service\FingerprintService + OxidEsales\GraphQL\Base\Service\UserModelService: + class: OxidEsales\GraphQL\Base\Service\UserModelService + OxidEsales\GraphQL\Base\Controller\: resource: 'src/Controller/' public: true @@ -107,6 +110,10 @@ services: class: OxidEsales\GraphQL\Base\Event\Subscriber\UserDeleteSubscriber tags: [ 'kernel.event_subscriber' ] + OxidEsales\GraphQL\Base\Event\Subscriber\PasswordChangeSubscriber: + class: OxidEsales\GraphQL\Base\Event\Subscriber\PasswordChangeSubscriber + tags: [ 'kernel.event_subscriber' ] + OxidEsales\GraphQL\Base\Event\Subscriber\BeforeTokenCreationSubscriber: tags: [ 'kernel.event_subscriber' ] diff --git a/src/Controller/Login.php b/src/Controller/Login.php index 9c32a296..f2fb3d3c 100644 --- a/src/Controller/Login.php +++ b/src/Controller/Login.php @@ -9,9 +9,8 @@ namespace OxidEsales\GraphQL\Base\Controller; -use OxidEsales\GraphQL\Base\DataType\Login as LoginDatatype; +use OxidEsales\GraphQL\Base\DataType\LoginInterface; use OxidEsales\GraphQL\Base\Service\LoginServiceInterface; -use OxidEsales\GraphQL\Base\Service\RefreshTokenServiceInterface; use OxidEsales\GraphQL\Base\Service\Token; use TheCodingMachine\GraphQLite\Annotations\Query; @@ -20,7 +19,6 @@ class Login public function __construct( protected Token $tokenService, protected LoginServiceInterface $loginService, - protected RefreshTokenServiceInterface $refreshTokenService, ) { } @@ -44,13 +42,8 @@ public function token(?string $username = null, ?string $password = null): strin * * @Query */ - public function login(?string $username = null, ?string $password = null): LoginDatatype + public function login(?string $username = null, ?string $password = null): LoginInterface { - $user = $this->loginService->login($username, $password); - - return new LoginDatatype( - refreshToken: $this->refreshTokenService->createRefreshTokenForUser($user), - accessToken: $this->tokenService->createTokenForUser($user), - ); + return $this->loginService->login($username, $password); } } diff --git a/src/DataType/LoginInterface.php b/src/DataType/LoginInterface.php index 9f7d6149..67b8470e 100644 --- a/src/DataType/LoginInterface.php +++ b/src/DataType/LoginInterface.php @@ -9,9 +9,15 @@ namespace OxidEsales\GraphQL\Base\DataType; +use TheCodingMachine\GraphQLite\Annotations\Field; +use TheCodingMachine\GraphQLite\Annotations\Type; + +#[Type] interface LoginInterface { + #[Field] public function refreshToken(): string; + #[Field] public function accessToken(): string; } diff --git a/src/Event/Subscriber/PasswordChangeSubscriber.php b/src/Event/Subscriber/PasswordChangeSubscriber.php new file mode 100644 index 00000000..1f8d69f0 --- /dev/null +++ b/src/Event/Subscriber/PasswordChangeSubscriber.php @@ -0,0 +1,103 @@ +getModel(); + + if (!$model instanceof User) { + return; + } + + $newPassword = $model->getFieldData('oxpassword'); + if (!$this->userModelService->isPasswordChanged($model->getId(), $newPassword)) { + return; + } + + $this->userIdWithChangedPwd[$model->getId()] = true; + } + + /** + * Handle ApplicationModelChangedEvent. + * + * @param Event $event Event to be handled + */ + public function handleAfterUpdate(Event $event): void + { + /** @phpstan-ignore-next-line method.notFound */ + $model = $event->getModel(); + + if (!$model instanceof User || !isset($this->userIdWithChangedPwd[$model->getId()])) { + return; + } + + $this->refreshTokenRepository->invalidateUserTokens($model->getId()); + $this->tokenInfrastructure->invalidateUserTokens($model->getId()); + unset($this->userIdWithChangedPwd[$model->getId()]); + } + + /** + * Returns an array of event names this subscriber wants to listen to. + * + * The array keys are event names and the value can be: + * + * * The method name to call (priority defaults to 0) + * * An array composed of the method name to call and the priority + * * An array of arrays composed of the method names to call and respective + * priorities, or 0 if unset + * + * For instance: + * + * * array('eventName' => 'methodName') + * * array('eventName' => array('methodName', $priority)) + * * array('eventName' => array(array('methodName1', $priority), array('methodName2'))) + * + * @return array + */ + public static function getSubscribedEvents(): array + { + return [ + BeforeModelUpdateEvent::class => 'handleBeforeUpdate', + AfterModelUpdateEvent::class => 'handleAfterUpdate' + ]; + } +} diff --git a/src/Event/Subscriber/UserDeleteSubscriber.php b/src/Event/Subscriber/UserDeleteSubscriber.php index bd35f916..5ce49507 100644 --- a/src/Event/Subscriber/UserDeleteSubscriber.php +++ b/src/Event/Subscriber/UserDeleteSubscriber.php @@ -35,7 +35,7 @@ public function handle(Event $event): Event return $event; } - $this->tokenInfrastructure->cleanUpTokens(); + $this->tokenInfrastructure->deleteOrphanedTokens(); return $event; } diff --git a/src/Infrastructure/RefreshTokenRepository.php b/src/Infrastructure/RefreshTokenRepository.php index e7051a39..be5bf61e 100644 --- a/src/Infrastructure/RefreshTokenRepository.php +++ b/src/Infrastructure/RefreshTokenRepository.php @@ -87,4 +87,17 @@ public function getTokenUser(string $refreshToken): UserInterface return new UserDataType($userModel, $isAnonymous); } + + public function invalidateUserTokens(string $userId): void + { + $queryBuilder = $this->queryBuilderFactory->create() + ->update('oegraphqlrefreshtoken') + ->where('OXUSERID = :userId') + ->set('EXPIRES_AT', 'NOW()') + ->setParameters([ + 'userId' => $userId, + ]); + + $queryBuilder->execute(); + } } diff --git a/src/Infrastructure/RefreshTokenRepositoryInterface.php b/src/Infrastructure/RefreshTokenRepositoryInterface.php index d9aa83fb..e74a7aff 100644 --- a/src/Infrastructure/RefreshTokenRepositoryInterface.php +++ b/src/Infrastructure/RefreshTokenRepositoryInterface.php @@ -24,4 +24,6 @@ public function removeExpiredTokens(): void; * @throws InvalidRefreshToken */ public function getTokenUser(string $refreshToken): UserInterface; + + public function invalidateUserTokens(string $user): void; } diff --git a/src/Infrastructure/Token.php b/src/Infrastructure/Token.php index 1a317cce..16065e91 100644 --- a/src/Infrastructure/Token.php +++ b/src/Infrastructure/Token.php @@ -62,7 +62,7 @@ public function removeExpiredTokens(UserInterface $user): void $queryBuilder->execute(); } - public function cleanUpTokens(): void + public function deleteOrphanedTokens(): void { /** @var \Doctrine\DBAL\Driver\Statement $execute */ $execute = $this->queryBuilderFactory->create() @@ -155,4 +155,17 @@ public function userHasToken(UserInterface $user, string $tokenId): bool return false; } + + public function invalidateUserTokens(string $userId): void + { + $queryBuilder = $this->queryBuilderFactory->create() + ->update('oegraphqltoken') + ->where('OXUSERID = :userId') + ->set('EXPIRES_AT', 'NOW()') + ->setParameters([ + 'userId' => $userId, + ]); + + $queryBuilder->execute(); + } } diff --git a/src/Service/LoginService.php b/src/Service/LoginService.php index 27790479..87f6eab4 100644 --- a/src/Service/LoginService.php +++ b/src/Service/LoginService.php @@ -9,7 +9,8 @@ namespace OxidEsales\GraphQL\Base\Service; -use OxidEsales\GraphQL\Base\DataType\UserInterface; +use OxidEsales\GraphQL\Base\DataType\Login as LoginDatatype; +use OxidEsales\GraphQL\Base\DataType\LoginInterface; use OxidEsales\GraphQL\Base\Infrastructure\Legacy; /** @@ -19,11 +20,18 @@ class LoginService implements LoginServiceInterface { public function __construct( private readonly Legacy $legacyInfrastructure, + protected Token $tokenService, + protected RefreshTokenServiceInterface $refreshTokenService, ) { } - public function login(?string $userName, ?string $password): UserInterface + public function login(?string $userName, ?string $password): LoginInterface { - return $this->legacyInfrastructure->login($userName, $password); + $user = $this->legacyInfrastructure->login($userName, $password); + + return new LoginDatatype( + refreshToken: $this->refreshTokenService->createRefreshTokenForUser($user), + accessToken: $this->tokenService->createTokenForUser($user), + ); } } diff --git a/src/Service/LoginServiceInterface.php b/src/Service/LoginServiceInterface.php index eeba3672..39df62cc 100644 --- a/src/Service/LoginServiceInterface.php +++ b/src/Service/LoginServiceInterface.php @@ -9,12 +9,12 @@ namespace OxidEsales\GraphQL\Base\Service; -use OxidEsales\GraphQL\Base\DataType\UserInterface; +use OxidEsales\GraphQL\Base\DataType\LoginInterface; /** * User login service */ interface LoginServiceInterface { - public function login(?string $userName, ?string $password): UserInterface; + public function login(?string $userName, ?string $password): LoginInterface; } diff --git a/src/Service/Token.php b/src/Service/Token.php index 82f5ff76..87781e81 100644 --- a/src/Service/Token.php +++ b/src/Service/Token.php @@ -11,7 +11,6 @@ use DateTimeImmutable; use Lcobucci\JWT\UnencryptedToken; -use OxidEsales\GraphQL\Base\DataType\User as UserDataType; use OxidEsales\GraphQL\Base\DataType\UserInterface; use OxidEsales\GraphQL\Base\Event\BeforeTokenCreation; use OxidEsales\GraphQL\Base\Exception\InvalidLogin; diff --git a/src/Service/UserModelService.php b/src/Service/UserModelService.php new file mode 100644 index 00000000..bc92faad --- /dev/null +++ b/src/Service/UserModelService.php @@ -0,0 +1,34 @@ +legacyInfrastructure->getUserModel($userId); + $currentPassword = $userModel->getFieldData('oxpassword'); + if (!$passwordNew || !$currentPassword) { + return false; + } + + return $currentPassword !== $passwordNew; + } +} diff --git a/tests/Integration/Infrastructure/RefreshTokenRepositoryTest.php b/tests/Integration/Infrastructure/RefreshTokenRepositoryTest.php index c0792990..7f205890 100644 --- a/tests/Integration/Infrastructure/RefreshTokenRepositoryTest.php +++ b/tests/Integration/Infrastructure/RefreshTokenRepositoryTest.php @@ -10,10 +10,11 @@ namespace OxidEsales\GraphQL\Base\Tests\Integration\Infrastructure; use DateTime; +use DateTimeImmutable; use Doctrine\DBAL\Connection; use OxidEsales\EshopCommunity\Internal\Framework\Database\ConnectionProviderInterface; +use OxidEsales\GraphQL\Base\DataType\UserInterface; use OxidEsales\GraphQL\Base\Exception\InvalidRefreshToken; -use OxidEsales\GraphQL\Base\Exception\InvalidToken; use OxidEsales\GraphQL\Base\Infrastructure\RefreshTokenRepository; use OxidEsales\GraphQL\Base\Infrastructure\RefreshTokenRepositoryInterface; use OxidEsales\GraphQL\Base\Tests\Integration\TestCase; @@ -138,6 +139,39 @@ public function testGetTokenUserExplodesOnWrongToken(): void $sut->getTokenUser(uniqid()); } + public function testInvalidateRefreshTokens(): void + { + $expires = new DateTimeImmutable('+8 hours'); + $this->addToken( + oxid: 'pwd_change_token', + expires: $expires->format('Y-m-d H:i:s'), + userId: $userId = '_testUser', + token: $token = uniqid(), + ); + + $sut = $this->getSut(); + $sut->invalidateUserTokens($userId); + + $this->expectException(InvalidRefreshToken::class); + $sut->getTokenUser($token); + } + + public function testInvalidateRefreshTokensWrongUserId(): void + { + $expires = new DateTimeImmutable('+8 hours'); + $this->addToken( + oxid: 'pwd_change_token', + expires: $expires->format('Y-m-d H:i:s'), + userId: '_testUser', + token: $token = uniqid(), + ); + + $sut = $this->getSut(); + $sut->invalidateUserTokens('some_user_id'); + + $this->assertTrue($sut->getTokenUser($token) instanceof UserInterface); + } + private function getDbConnection(): Connection { return $this->get(ConnectionProviderInterface::class)->get(); diff --git a/tests/Integration/Infrastructure/TokenTest.php b/tests/Integration/Infrastructure/TokenTest.php index 23f4b2ba..6d89c7dd 100644 --- a/tests/Integration/Infrastructure/TokenTest.php +++ b/tests/Integration/Infrastructure/TokenTest.php @@ -13,6 +13,7 @@ use Lcobucci\JWT\Token\DataSet; use Lcobucci\JWT\UnencryptedToken; use OxidEsales\Eshop\Application\Model\User; +use OxidEsales\EshopCommunity\Internal\Framework\Database\ConnectionProviderInterface; use OxidEsales\EshopCommunity\Tests\Integration\IntegrationTestCase; use OxidEsales\EshopCommunity\Tests\TestContainerFactory; use OxidEsales\GraphQL\Base\DataType\Token as TokenDataType; @@ -30,6 +31,9 @@ class TokenTest extends IntegrationTestCase /** @var TokenInfrastructure */ private $tokenInfrastructure; + /** @var ConnectionProviderInterface */ + private $connection; + public function setUp(): void { parent::setUp(); @@ -37,6 +41,7 @@ public function setUp(): void $container = $containerFactory->create(); $container->compile(); $this->tokenInfrastructure = $container->get(TokenInfrastructure::class); + $this->connection = $container->get(ConnectionProviderInterface::class)->get(); } public function testRegisterToken(): void @@ -306,6 +311,48 @@ public function testInvalidateTokenAfterDeleteUser(): void $this->assertFalse($this->tokenInfrastructure->isTokenRegistered('_deletedUser')); } + public function testInvalidateAccessTokens(): void + { + $this->tokenInfrastructure->registerToken( + $this->getTokenMock( + $token = 'pwd_change_token', + $userId = '_testUser' + ), + new DateTimeImmutable('now'), + new DateTimeImmutable('+8 hours') + ); + + $this->tokenInfrastructure->invalidateUserTokens($userId); + $result = $this->connection->executeQuery( + "select expires_at from `oegraphqltoken` where oxid=:tokenId", + ['tokenId' => $token] + ); + $expiresAtAfterChange = $result->fetchOne(); + + $this->assertTrue(new DateTimeImmutable($expiresAtAfterChange) <= new DateTimeImmutable('now')); + } + + public function testInvalidateAccessTokensWrongUserId(): void + { + $this->tokenInfrastructure->registerToken( + $this->getTokenMock( + tokenId: $token = 'pwd_change_token', + userId: '_testUser' + ), + new DateTimeImmutable('now'), + new DateTimeImmutable('+8 hours') + ); + + $this->tokenInfrastructure->invalidateUserTokens('wrong_user_id'); + $result = $this->connection->executeQuery( + "select expires_at from `oegraphqltoken` where oxid=:tokenId", + ['tokenId' => $token] + ); + $expiresAtAfterChange = $result->fetchOne(); + + $this->assertFalse(new DateTimeImmutable($expiresAtAfterChange) <= new DateTimeImmutable('now')); + } + private function getTokenMock( string $tokenId = self::TEST_TOKEN_ID, string $userId = self::TEST_USER_ID diff --git a/tests/Unit/Controller/LoginTest.php b/tests/Unit/Controller/LoginTest.php index a6aceb82..69e01750 100644 --- a/tests/Unit/Controller/LoginTest.php +++ b/tests/Unit/Controller/LoginTest.php @@ -9,16 +9,14 @@ namespace OxidEsales\GraphQL\Base\Tests\Unit\Controller; -use Lcobucci\JWT\UnencryptedToken; use OxidEsales\Eshop\Application\Model\User as UserModel; use OxidEsales\GraphQL\Base\Controller\Login; +use OxidEsales\GraphQL\Base\DataType\LoginInterface; use OxidEsales\GraphQL\Base\DataType\User; -use OxidEsales\GraphQL\Base\DataType\UserInterface; use OxidEsales\GraphQL\Base\Infrastructure\Legacy; use OxidEsales\GraphQL\Base\Infrastructure\Token as TokenInfrastructure; use OxidEsales\GraphQL\Base\Service\JwtConfigurationBuilder; use OxidEsales\GraphQL\Base\Service\LoginServiceInterface; -use OxidEsales\GraphQL\Base\Service\RefreshTokenServiceInterface; use OxidEsales\GraphQL\Base\Service\Token as TokenService; use OxidEsales\GraphQL\Base\Tests\Unit\BaseTestCase; use PHPUnit\Framework\MockObject\MockObject; @@ -60,7 +58,6 @@ public function setUp(): void new EventDispatcher(), $this->getModuleConfigurationMock(), $this->tokenInfrastructure, - $this->getRefreshRepositoryMock() ); } @@ -79,7 +76,6 @@ public function testCreateTokenWithValidCredentials(): void $loginController = new Login( $this->tokenService, $this->getLoginService($this->legacy), - $this->createStub(RefreshTokenServiceInterface::class), ); $jwt = $loginController->token($username, $password); @@ -103,7 +99,6 @@ public function testCreateTokenWithMissingPassword(): void $loginController = new Login( $this->tokenService, $this->getLoginService($this->legacy), - $this->createStub(RefreshTokenServiceInterface::class), ); $jwt = $loginController->token('none'); @@ -132,7 +127,6 @@ public function testCreateTokenWithMissingUsername(): void $loginController = new Login( $this->tokenService, $this->getLoginService($this->legacy), - $this->createStub(RefreshTokenServiceInterface::class), ); $jwt = $loginController->token(null, 'none'); @@ -161,7 +155,6 @@ public function testCreateAnonymousToken(): void $loginController = new Login( $this->tokenService, $this->getLoginService($this->legacy), - $this->createStub(RefreshTokenServiceInterface::class), ); $jwt = $loginController->token(); @@ -174,31 +167,19 @@ public function testCreateAnonymousToken(): void $this->assertNotEmpty($token->claims()->get(TokenService::CLAIM_USERID)); } - public function testLoginCreatesLoginInputTypeResult(): void + public function testLoginReturnsLoginServiceResult(): void { $loginController = new Login( - tokenService: $tokenServiceMock = $this->createMock(TokenService::class), + tokenService: $this->createStub(TokenService::class), loginService: $loginServiceMock = $this->createMock(LoginServiceInterface::class), - refreshTokenService: $refreshTokenMock = $this->createMock(RefreshTokenServiceInterface::class), ); $userName = uniqid(); $password = uniqid(); - $userStub = $this->createStub(UserInterface::class); - $loginServiceMock->method('login')->with($userName, $password)->willReturn($userStub); + $loginDataTypeStub = $this->createStub(LoginInterface::class); + $loginServiceMock->method('login')->with($userName, $password)->willReturn($loginDataTypeStub); - $refreshToken = uniqid(); - $refreshTokenMock->method('createRefreshTokenForUser')->with($userStub)->willReturn($refreshToken); - - $accessTokenStub = $this->createConfiguredStub(UnencryptedToken::class, [ - 'toString' => $accessToken = uniqid() - ]); - $tokenServiceMock->method('createTokenForUser')->with($userStub)->willReturn($accessTokenStub); - - $result = $loginController->login($userName, $password); - - $this->assertSame($refreshToken, $result->refreshToken()); - $this->assertSame($accessToken, $result->accessToken()); + $this->assertSame($loginDataTypeStub, $loginController->login($userName, $password)); } } diff --git a/tests/Unit/Event/Subscriber/PasswordChangeSubscriberTest.php b/tests/Unit/Event/Subscriber/PasswordChangeSubscriberTest.php new file mode 100644 index 00000000..d9d7c1bb --- /dev/null +++ b/tests/Unit/Event/Subscriber/PasswordChangeSubscriberTest.php @@ -0,0 +1,164 @@ +getSut(); + $configuration = $sut->getSubscribedEvents(); + + $this->assertTrue(array_key_exists(BeforeModelUpdateEvent::class, $configuration)); + $this->assertTrue(array_key_exists(AfterModelUpdateEvent::class, $configuration)); + $this->assertTrue($configuration[BeforeModelUpdateEvent::class] === 'handleBeforeUpdate'); + $this->assertTrue($configuration[AfterModelUpdateEvent::class] === 'handleAfterUpdate'); + } + + public function testSubscriberWithUserModelPwdChange(): void + { + $userModelService = $this->createPartialMock(UserModelService::class, ['isPasswordChanged']); + $userModelService->method('isPasswordChanged')->with($userId = uniqid())->willReturn(true); + + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository->expects($this->once()) + ->method('invalidateUserTokens'); + + $tokenInfrastructure = $this->createMock(Token::class); + $tokenInfrastructure->expects($this->once()) + ->method('invalidateUserTokens') + ->with($this->equalTo($userId)); + + $userModelStub = $this->createConfiguredStub(User::class, ['getId' => $userId]); + $beforeUpdateStub = $this->createConfiguredStub(BeforeModelUpdateEvent::class, ['getModel' => $userModelStub]); + $afterUpdateStub = $this->createConfiguredStub(AfterModelUpdateEvent::class, ['getModel' => $userModelStub]); + + $sut = $this->getSut($userModelService, $refreshTokenRepository, $tokenInfrastructure); + $sut->handleBeforeUpdate($beforeUpdateStub); + $sut->handleAfterUpdate($afterUpdateStub); + } + + public function testSubscriberWithMultipleUserModelsPwdChange(): void + { + $userModelService = $this->createPartialMock(UserModelService::class, ['isPasswordChanged']); + $userModelService->method('isPasswordChanged')->willReturn(true); + + $userModelStub1 = $this->createConfiguredStub(User::class, ['getId' => $userId1 = uniqid()]); + $userModelStub2 = $this->createConfiguredStub(User::class, ['getId' => $userId2 = uniqid()]); + + $methodArgs = []; + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository->expects($this->exactly(2)) + ->method('invalidateUserTokens') + ->willReturnCallback(function ($userId) use (&$methodArgs) { + $methodArgs[] = $userId; + }); + + $tokenInfrastructure = $this->createMock(Token::class); + $tokenInfrastructure->expects($this->exactly(2)) + ->method('invalidateUserTokens'); + + $beforeUpdateStub1 = $this->createConfiguredStub( + BeforeModelUpdateEvent::class, + ['getModel' => $userModelStub1] + ); + $beforeUpdateStub2 = $this->createConfiguredStub( + BeforeModelUpdateEvent::class, + ['getModel' => $userModelStub2] + ); + $afterUpdateStub1 = $this->createConfiguredStub( + AfterModelUpdateEvent::class, + ['getModel' => $userModelStub1] + ); + $afterUpdateStub2 = $this->createConfiguredStub( + AfterModelUpdateEvent::class, + ['getModel' => $userModelStub2] + ); + + $sut = $this->getSut($userModelService, $refreshTokenRepository, $tokenInfrastructure); + $sut->handleBeforeUpdate($beforeUpdateStub1); + $sut->handleBeforeUpdate($beforeUpdateStub2); + $sut->handleAfterUpdate($afterUpdateStub1); + $sut->handleAfterUpdate($afterUpdateStub2); + + // Ensure that invalidateUserTokens was called with the correct parameters + $this->assertCount(2, $methodArgs); + $this->assertSame($userId1, $methodArgs[0]); + $this->assertSame($userId2, $methodArgs[1]); + } + + public function testSubscriberWithNoUserModel(): void + { + $userModelService = $this->createPartialMock(UserModelService::class, ['isPasswordChanged']); + $userModelService->expects($this->never()) + ->method('isPasswordChanged'); + + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository->expects($this->never()) + ->method('invalidateUserTokens'); + + $tokenInfrastructure = $this->createMock(Token::class); + $tokenInfrastructure->expects($this->never()) + ->method('invalidateUserTokens'); + + $beforeUpdateStub = $this->createConfiguredStub(BeforeModelUpdateEvent::class, ['getModel' => new Article()]); + $afterUpdateStub = $this->createConfiguredStub(AfterModelUpdateEvent::class, ['getModel' => new Article()]); + + $sut = $this->getSut($userModelService, $refreshTokenRepository, $tokenInfrastructure); + $sut->handleBeforeUpdate($beforeUpdateStub); + $sut->handleAfterUpdate($afterUpdateStub); + } + + public function testSubscriberWithUserModelNoPwdChanged(): void + { + $userModelService = $this->createPartialMock(UserModelService::class, ['isPasswordChanged']); + $userModelService->method('isPasswordChanged')->with($userId = uniqid())->willReturn(false); + + $refreshTokenRepository = $this->createMock(RefreshTokenRepositoryInterface::class); + $refreshTokenRepository->expects($this->never()) + ->method('invalidateUserTokens'); + + $tokenInfrastructure = $this->createMock(Token::class); + $tokenInfrastructure->expects($this->never()) + ->method('invalidateUserTokens'); + + $userModelStub = $this->createConfiguredStub(User::class, ['getId' => $userId]); + $beforeUpdateStub = $this->createConfiguredStub(BeforeModelUpdateEvent::class, ['getModel' => $userModelStub]); + $afterUpdateStub = $this->createConfiguredStub(AfterModelUpdateEvent::class, ['getModel' => $userModelStub]); + + $sut = $this->getSut($userModelService, $refreshTokenRepository, $tokenInfrastructure); + $sut->handleBeforeUpdate($beforeUpdateStub); + $sut->handleAfterUpdate($afterUpdateStub); + } + + protected function getSut( + UserModelService $userModelService = null, + RefreshTokenRepositoryInterface $refreshTokenRepository = null, + Token $tokenInfrastructure = null, + ): PasswordChangeSubscriber { + return new PasswordChangeSubscriber( + userModelService: $userModelService ?? $this->createStub(UserModelService::class), + refreshTokenRepository: + $refreshTokenRepository ?? $this->createStub(RefreshTokenRepositoryInterface::class), + tokenInfrastructure: $tokenInfrastructure ?? $this->createStub(Token::class) + ); + } +} diff --git a/tests/Unit/Service/LoginServiceTest.php b/tests/Unit/Service/LoginServiceTest.php index 9cc34e68..e015419e 100644 --- a/tests/Unit/Service/LoginServiceTest.php +++ b/tests/Unit/Service/LoginServiceTest.php @@ -9,19 +9,24 @@ namespace OxidEsales\GraphQL\Base\Tests\Unit\Service; +use Lcobucci\JWT\UnencryptedToken; use OxidEsales\GraphQL\Base\DataType\UserInterface; use OxidEsales\GraphQL\Base\Infrastructure\Legacy; use OxidEsales\GraphQL\Base\Service\LoginService; +use OxidEsales\GraphQL\Base\Service\RefreshTokenServiceInterface; +use OxidEsales\GraphQL\Base\Service\Token as TokenService; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(LoginService::class)] class LoginServiceTest extends TestCase { - public function testLoginReturnsUserDataType(): void + public function testLoginCreatesLoginInputTypeResult(): void { $sut = new LoginService( legacyInfrastructure: $legacyInfrastructureMock = $this->createMock(Legacy::class), + tokenService: $tokenServiceMock = $this->createMock(TokenService::class), + refreshTokenService: $refreshTokenMock = $this->createMock(RefreshTokenServiceInterface::class), ); $userName = uniqid(); @@ -31,6 +36,17 @@ public function testLoginReturnsUserDataType(): void ->with($userName, $password) ->willReturn($userType = $this->createStub(UserInterface::class)); - $this->assertSame($userType, $sut->login($userName, $password)); + $refreshToken = uniqid(); + $refreshTokenMock->method('createRefreshTokenForUser')->with($userType)->willReturn($refreshToken); + + $accessToken = uniqid(); + $tokenServiceMock->method('createTokenForUser')->with($userType)->willReturn( + $this->createConfiguredStub(UnencryptedToken::class, ['toString' => $accessToken]) + ); + + $result = $sut->login($userName, $password); + + $this->assertSame($refreshToken, $result->refreshToken()); + $this->assertSame($accessToken, $result->accessToken()); } } diff --git a/tests/Unit/Service/UserModelServiceTest.php b/tests/Unit/Service/UserModelServiceTest.php new file mode 100644 index 00000000..c84a3d6d --- /dev/null +++ b/tests/Unit/Service/UserModelServiceTest.php @@ -0,0 +1,64 @@ +createMock(Legacy::class), + ); + + $legacyInfrastructureMock->method('getUserModel')->with($userId)->willReturn( + $this->getUserModelMock($password) + ); + + $this->assertTrue($sut->isPasswordChanged($userId, $newPassword)); + } + + public function testIsPasswordChangedOnSamePassword(): void + { + $userId = uniqid(); + $password = uniqid(); + $newPassword = $password; + + $sut = new UserModelService( + legacyInfrastructure: $legacyInfrastructureMock = $this->createMock(Legacy::class), + ); + + $legacyInfrastructureMock->method('getUserModel')->with($userId)->willReturn( + $this->getUserModelMock($password) + ); + + $this->assertFalse($sut->isPasswordChanged($userId, $newPassword)); + } + + protected function getUserModelMock(string $password): UserModel + { + $userModelMock = $this->createMock(UserModel::class); + $userModelMock->method('getFieldData')->willReturnMap([ + ['oxpassword', $password], + ]); + + return $userModelMock; + } +}