Skip to content

Commit

Permalink
Merge pull request PrestaShop#34792 from M0rgan01/34001
Browse files Browse the repository at this point in the history
SF6: Use AbstractAuthenticator for TokenAuthenticator
  • Loading branch information
jolelievre authored Dec 20, 2023
2 parents f04e638 + 1affeec commit 41b1635
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 101 deletions.
10 changes: 5 additions & 5 deletions app/config/security.yml
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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 }
85 changes: 31 additions & 54 deletions src/Core/Security/OAuth2/TokenAuthenticator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -71,58 +58,48 @@ 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;
}

// Every request to the API should be handled by this Authenticator
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()));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
namespace Tests\Integration\PrestaShopBundle\Controller\Admin;

use Context;
use Cookie;
use Country;
use Currency;
use Employee;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
27 changes: 21 additions & 6 deletions tests/Integration/Utility/AdapterSecurityAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 () {
Expand Down
58 changes: 38 additions & 20 deletions tests/Unit/Core/Security/TokenAuthenticatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down Expand Up @@ -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);
}
}

0 comments on commit 41b1635

Please sign in to comment.