diff --git a/app/config/security.yml b/app/config/security.yml index 191a9444288bd..aff234ca6ee68 100644 --- a/app/config/security.yml +++ b/app/config/security.yml @@ -1,6 +1,7 @@ # To get started with security, check out the documentation: # https://symfony.com/doc/current/security.html security: + enable_authenticator_manager: true password_hashers: # auto hasher with default options for the User class (and children) PrestaShopBundle\Entity\ApiAccess: 'auto' @@ -37,10 +38,9 @@ security: pattern: '^(%api_base_path%)(?!/docs)' stateless: true provider: 'oauth2' - guard: - authenticator: - - PrestaShop\PrestaShop\Core\Security\OAuth2\TokenAuthenticator + custom_authenticators: + - PrestaShop\PrestaShop\Core\Security\OAuth2\TokenAuthenticator request_matcher: PrestaShop\PrestaShop\Core\Security\OAuth2\ApiRequestMatcher - main: - anonymous: ~ + access_control: + - { path: ^/, roles: PUBLIC_ACCESS } diff --git a/src/Core/Security/OAuth2/TokenAuthenticator.php b/src/Core/Security/OAuth2/TokenAuthenticator.php index d6b1ce42229f0..ce318951a3787 100644 --- a/src/Core/Security/OAuth2/TokenAuthenticator.php +++ b/src/Core/Security/OAuth2/TokenAuthenticator.php @@ -28,40 +28,27 @@ namespace PrestaShop\PrestaShop\Core\Security\OAuth2; -use Lcobucci\JWT\Encoding\JoseEncoder; -use Lcobucci\JWT\Token\InvalidTokenStructure; -use Lcobucci\JWT\Token\Parser; -use Psr\Http\Message\ServerRequestInterface; use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Authentication\Token\TokenInterface; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Component\Security\Core\User\UserProviderInterface; -use Symfony\Component\Security\Guard\AbstractGuardAuthenticator; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; +use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator; +use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge; +use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport; /** * This class is responsible for authenticating api calls using the Authorization header * * @experimental */ -class TokenAuthenticator extends AbstractGuardAuthenticator +class TokenAuthenticator extends AbstractAuthenticator { - /** - * @var AuthorisationServerInterface - */ - private $authorizationServer; - - /** - * @var HttpMessageFactoryInterface - */ - private $httpMessageFactory; - - public function __construct(AuthorisationServerInterface $authorizationServer, HttpMessageFactoryInterface $httpMessageFactory) - { - $this->authorizationServer = $authorizationServer; - $this->httpMessageFactory = $httpMessageFactory; + public function __construct( + private readonly AuthorisationServerInterface $authorizationServer, + private readonly HttpMessageFactoryInterface $httpMessageFactory, + ) { } public function start(Request $request, AuthenticationException $authException = null): Response @@ -71,17 +58,11 @@ public function start(Request $request, AuthenticationException $authException = public function supports(Request $request): bool { - try { - $authorization = $request->headers->get('Authorization') ?? null; - if (null === $authorization) { - return false; - } - $explode = explode(' ', $authorization); - if (count($explode) >= 2) { - $token = $explode[1]; - (new Parser(new JoseEncoder()))->parse($token); - } - } catch (InvalidTokenStructure $e) { + $authorization = $request->headers->get('Authorization') ?? null; + if (null === $authorization) { + return false; + } + if (!str_starts_with(strtolower($authorization), 'bearer ')) { return false; } @@ -89,40 +70,36 @@ public function supports(Request $request): bool return true; } - public function getCredentials(Request $request): ServerRequestInterface - { - return $this->httpMessageFactory->createRequest($request); - } - - public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface - { - return $this->authorizationServer->getUser($credentials); - } - - public function checkCredentials($credentials, UserInterface $user): bool - { - return $this->authorizationServer->isTokenValid($credentials); - } - public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response { return $this->returnWWWAuthenticateResponse(); } - public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): ?Response + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response { // No response returned here, the request should keep running return null; } - public function supportsRememberMe(): bool + private function returnWWWAuthenticateResponse(): Response { - // Stateless API, remember me feature doesn't apply here - return false; + return new Response(null, Response::HTTP_UNAUTHORIZED, ['WWW-Authenticate' => 'Bearer']); } - private function returnWWWAuthenticateResponse(): Response + public function authenticate(Request $request) { - return new Response(null, Response::HTTP_UNAUTHORIZED, ['WWW-Authenticate' => 'Bearer']); + $authorization = $request->headers->get('Authorization'); + if (null === $authorization) { + throw new CustomUserMessageAuthenticationException('No API token provided'); + } + + $credentials = $this->httpMessageFactory->createRequest($request); + $userIdentifier = $this->authorizationServer->getUser($credentials); + + if (null === $userIdentifier) { + throw new CustomUserMessageAuthenticationException('Invalid credentials'); + } + + return new SelfValidatingPassport(new UserBadge($userIdentifier->getUserIdentifier())); } } diff --git a/tests/Integration/PrestaShopBundle/Controller/Admin/FrameworkBundleAdminControllerTest.php b/tests/Integration/PrestaShopBundle/Controller/Admin/FrameworkBundleAdminControllerTest.php index 85e2a41b80840..4cc794c304872 100644 --- a/tests/Integration/PrestaShopBundle/Controller/Admin/FrameworkBundleAdminControllerTest.php +++ b/tests/Integration/PrestaShopBundle/Controller/Admin/FrameworkBundleAdminControllerTest.php @@ -27,7 +27,6 @@ namespace Tests\Integration\PrestaShopBundle\Controller\Admin; use Context; -use Cookie; use Country; use Currency; use Employee; @@ -228,8 +227,6 @@ protected function setUp(): void */ public function testPagesAreAvailable(string $pageName, string $route): void { - $this->logIn(); - $uri = $this->router->generate($route); $this->client->catchExceptions(false); @@ -338,15 +335,4 @@ public function getDataProvider(): array 'admin_webservice_keys_index' => ['Webservice', 'admin_webservice_keys_index'], ]; } - - /** - * Emulates a real employee logged to the Back Office. - * For survival tests only. - */ - private function logIn(): void - { - $container = self::$kernel->getContainer(); - $cookie = new Cookie('psAdmin', '', 3600); - $container->get('prestashop.adapter.legacy.context')->getContext()->cookie = $cookie; - } } diff --git a/tests/Integration/Utility/AdapterSecurityAdmin.php b/tests/Integration/Utility/AdapterSecurityAdmin.php index c455dc66c858a..e1e8226c0fac2 100644 --- a/tests/Integration/Utility/AdapterSecurityAdmin.php +++ b/tests/Integration/Utility/AdapterSecurityAdmin.php @@ -31,26 +31,41 @@ use PrestaShop\PrestaShop\Adapter\LegacyContext; use Symfony\Component\HttpKernel\Event\RequestEvent; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; +use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken; +use Symfony\Component\Security\Core\User\UserProviderInterface; /** * Admin Middleware security */ class AdapterSecurityAdmin { - /** @phpstan-ignore-next-line */ - public function __construct(LegacyContext $context, TokenStorageInterface $securityTokenStorage) - { + public function __construct( + private readonly LegacyContext $context, + private readonly TokenStorageInterface $securityTokenStorage, + private readonly UserProviderInterface $userProvider + ) { } /** - * Check if employee is logged in - * If not loggedin in, redirect to admin home page + * Aims to authenticate the employee present in the context, only on routes not concerning the API * * @param RequestEvent $event * * @return void */ - public function onKernelRequest(RequestEvent $event) + public function onKernelRequest(RequestEvent $event): void { + $actualFirewall = $event->getRequest()->attributes->get('_firewall_context'); + + if (null !== $actualFirewall && (str_ends_with($actualFirewall, 'api_token') || str_ends_with($actualFirewall, 'api'))) { + return; + } + $employee = $this->context->getContext()->employee; + + if (null !== $employee && null !== $employee->email) { + $user = $this->userProvider->loadUserByIdentifier($employee->email); + $token = new UsernamePasswordToken($user, 'admin', $user->getRoles()); + $this->securityTokenStorage->setToken($token); + } } } diff --git a/tests/UI/campaigns/functional/API/01_clientCredentialGrantFlow/01_internalAuthServer/02_resourceEndpoint.ts b/tests/UI/campaigns/functional/API/01_clientCredentialGrantFlow/01_internalAuthServer/02_resourceEndpoint.ts index b996c9958845a..6baadf4d5d2c5 100644 --- a/tests/UI/campaigns/functional/API/01_clientCredentialGrantFlow/01_internalAuthServer/02_resourceEndpoint.ts +++ b/tests/UI/campaigns/functional/API/01_clientCredentialGrantFlow/01_internalAuthServer/02_resourceEndpoint.ts @@ -132,8 +132,6 @@ describe('API : Internal Auth Server - Resource Endpoint', async () => { const apiResponse = await apiContext.get('api/hook-status/1'); expect(apiResponse.status()).to.eq(401); - expect(api.hasResponseHeader(apiResponse, 'WWW-Authenticate')).to.eq(true); - expect(api.getResponseHeader(apiResponse, 'WWW-Authenticate')).to.be.eq('Bearer'); }); it('should request the endpoint /admin-dev/api/hook-status/1 with invalid access token', async function () { diff --git a/tests/Unit/Core/Security/TokenAuthenticatorTest.php b/tests/Unit/Core/Security/TokenAuthenticatorTest.php index eaddca02cc724..9ec272efa4dae 100644 --- a/tests/Unit/Core/Security/TokenAuthenticatorTest.php +++ b/tests/Unit/Core/Security/TokenAuthenticatorTest.php @@ -26,18 +26,20 @@ namespace Tests\Unit\Core\Security; +use DateTimeImmutable; +use Lcobucci\JWT\Builder; +use Lcobucci\JWT\JwtFacade; +use Lcobucci\JWT\Signer\Hmac\Sha256; +use Lcobucci\JWT\Signer\Key\InMemory; use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; use PrestaShop\PrestaShop\Core\Security\OAuth2\AuthorisationServerInterface; use PrestaShop\PrestaShop\Core\Security\OAuth2\TokenAuthenticator; -use PrestaShopBundle\Security\OAuth2\Entity\JwtTokenUser; -use Psr\Http\Message\ServerRequestInterface; use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Security\Core\Exception\AuthenticationException; -use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException; class TokenAuthenticatorTest extends TestCase { @@ -73,29 +75,45 @@ public function testOnAuthenticationFailure(): void $this->assertSame('Bearer', $response->headers->get('WWW-Authenticate')); } - public function testGetCredentials(): void + public function testSupports(): void { - $credentials = $this->tokenAuthenticator->getCredentials($this->request); - $this->assertTrue($credentials instanceof ServerRequestInterface); + $this->assertFalse($this->tokenAuthenticator->supports($this->request)); + + $this->request->headers->add(['Authorization' => 'toto']); + $this->assertFalse($this->tokenAuthenticator->supports($this->request)); + + $this->request->headers->add(['Authorization' => 'bearer ' . $this->buildTestToken()]); + $this->assertTrue($this->tokenAuthenticator->supports($this->request)); } - public function testGetUser(): void + private function buildTestToken(): string { - $this->authorizationServer->method('getUser')->willReturn(new JwtTokenUser('testUser', [])); - $serverRequestMock = $this->createMock(ServerRequestInterface::class); - $user = $this->tokenAuthenticator->getUser( - $serverRequestMock, - $this->createMock(UserProviderInterface::class) + $key = InMemory::base64Encoded('hiG8DlOKvtih6AxlZn5XKImZ06yu8I3mkOzaJrEuW8yAv8Jnkw330uMt8AEqQ5LB'); + + $token = (new JwtFacade())->issue( + new Sha256(), + $key, + static fn ( + Builder $builder, + DateTimeImmutable $issuedAt + ): Builder => $builder + ->issuedBy('https://api.my-awesome-app.io') + ->permittedFor('https://client-app.io') + ->expiresAt($issuedAt->modify('+10 minutes')) ); - $this->assertInstanceOf(JwtTokenUser::class, $user); + + return $token->toString(); } - public function testCheckCredentials(): void + public function testAuthenticate(): void { - $this->authorizationServer->expects($this->once())->method('isTokenValid'); - $this->tokenAuthenticator->checkCredentials( - $this->createMock(ServerRequestInterface::class), - $this->createMock(UserInterface::class) - ); + $this->expectException(CustomUserMessageAuthenticationException::class); + $this->expectExceptionMessage('No API token provided'); + $this->tokenAuthenticator->authenticate($this->request); + + $this->request->headers->add(['Authorization' => 'bearer ' . $this->buildTestToken()]); + $this->expectException(CustomUserMessageAuthenticationException::class); + $this->expectExceptionMessage('Invalid credentials'); + $this->tokenAuthenticator->authenticate($this->request); } }