Skip to content

Commit

Permalink
Merge branch 'b-7.2.x-password_change_token_invalidation-OXDEV-8407' …
Browse files Browse the repository at this point in the history
…into b-7.2.x
  • Loading branch information
TitaKoleva committed Oct 17, 2024
2 parents 2cb6258 + d7ff9b3 commit 6a4b8f0
Show file tree
Hide file tree
Showing 19 changed files with 539 additions and 46 deletions.
9 changes: 9 additions & 0 deletions CHANGELOG-v10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 7 additions & 0 deletions services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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' ]

Expand Down
13 changes: 3 additions & 10 deletions src/Controller/Login.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -20,7 +19,6 @@ class Login
public function __construct(
protected Token $tokenService,
protected LoginServiceInterface $loginService,
protected RefreshTokenServiceInterface $refreshTokenService,
) {
}

Expand All @@ -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);
}
}
6 changes: 6 additions & 0 deletions src/DataType/LoginInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
103 changes: 103 additions & 0 deletions src/Event/Subscriber/PasswordChangeSubscriber.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

/**
* Copyright © OXID eSales AG. All rights reserved.
* See LICENSE file for license details.
*/

declare(strict_types=1);

namespace OxidEsales\GraphQL\Base\Event\Subscriber;

use OxidEsales\Eshop\Application\Model\User;
use OxidEsales\EshopCommunity\Internal\Transition\ShopEvents\AfterModelUpdateEvent;
use OxidEsales\EshopCommunity\Internal\Transition\ShopEvents\BeforeModelUpdateEvent;
use OxidEsales\GraphQL\Base\Infrastructure\RefreshTokenRepositoryInterface;
use OxidEsales\GraphQL\Base\Infrastructure\Token;
use OxidEsales\GraphQL\Base\Service\UserModelService;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Contracts\EventDispatcher\Event;

class PasswordChangeSubscriber implements EventSubscriberInterface
{
/**
* Whether the password had been changed.
*
* @var array
*/
protected array $userIdWithChangedPwd = [];

public function __construct(
private readonly UserModelService $userModelService,
private readonly RefreshTokenRepositoryInterface $refreshTokenRepository,
private readonly Token $tokenInfrastructure
) {
}

/**
* Handle ApplicationModelChangedEvent.
*
* @param Event $event Event to be handled
*/
public function handleBeforeUpdate(Event $event): void
{
/** @phpstan-ignore-next-line method.notFound */
$model = $event->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<class-string,string>
*/
public static function getSubscribedEvents(): array
{
return [
BeforeModelUpdateEvent::class => 'handleBeforeUpdate',
AfterModelUpdateEvent::class => 'handleAfterUpdate'
];
}
}
2 changes: 1 addition & 1 deletion src/Event/Subscriber/UserDeleteSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ public function handle(Event $event): Event
return $event;
}

$this->tokenInfrastructure->cleanUpTokens();
$this->tokenInfrastructure->deleteOrphanedTokens();

return $event;
}
Expand Down
13 changes: 13 additions & 0 deletions src/Infrastructure/RefreshTokenRepository.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
2 changes: 2 additions & 0 deletions src/Infrastructure/RefreshTokenRepositoryInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,4 +24,6 @@ public function removeExpiredTokens(): void;
* @throws InvalidRefreshToken
*/
public function getTokenUser(string $refreshToken): UserInterface;

public function invalidateUserTokens(string $user): void;
}
15 changes: 14 additions & 1 deletion src/Infrastructure/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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();
}
}
14 changes: 11 additions & 3 deletions src/Service/LoginService.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand All @@ -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),
);
}
}
4 changes: 2 additions & 2 deletions src/Service/LoginServiceInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
1 change: 0 additions & 1 deletion src/Service/Token.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 34 additions & 0 deletions src/Service/UserModelService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/**
* Copyright © OXID eSales AG. All rights reserved.
* See LICENSE file for license details.
*/

declare(strict_types=1);

namespace OxidEsales\GraphQL\Base\Service;

use OxidEsales\GraphQL\Base\Infrastructure\Legacy;

/**
* User model service
*/
class UserModelService
{
public function __construct(
private readonly Legacy $legacyInfrastructure,
) {
}

public function isPasswordChanged(string $userId, ?string $passwordNew): bool
{
$userModel = $this->legacyInfrastructure->getUserModel($userId);
$currentPassword = $userModel->getFieldData('oxpassword');
if (!$passwordNew || !$currentPassword) {
return false;
}

return $currentPassword !== $passwordNew;
}
}
36 changes: 35 additions & 1 deletion tests/Integration/Infrastructure/RefreshTokenRepositoryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
Loading

0 comments on commit 6a4b8f0

Please sign in to comment.