feat: gate oauth2 authorization on per-client roles
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\EventListener;
|
||||
|
||||
use App\Entity\OAuth2Client;
|
||||
use App\Entity\User;
|
||||
use App\EventListener\AuthorizationCodeListener;
|
||||
use App\Security\Role;
|
||||
use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent;
|
||||
use League\Bundle\OAuth2ServerBundle\Model\ClientInterface;
|
||||
use League\OAuth2\Server\RequestTypes\AuthorizationRequestInterface;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\RequestStack;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Twig\Environment;
|
||||
|
||||
class AuthorizationCodeListenerTest extends TestCase
|
||||
{
|
||||
public function testAnonymousRequestIsRedirectedToLoginWithoutResolving(): void
|
||||
{
|
||||
$event = $this->dispatch(null, $this->client(['ROLE_EMPLOYEE']));
|
||||
|
||||
$response = $event->getResponse();
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame(Response::HTTP_TEMPORARY_REDIRECT, $response->getStatusCode());
|
||||
self::assertFalse($event->getAuthorizationResolution());
|
||||
}
|
||||
|
||||
public function testAccountHoldingOneOfTheRequiredRolesIsApproved(): void
|
||||
{
|
||||
$event = $this->dispatch(
|
||||
$this->user([Role::EMPLOYEE]),
|
||||
$this->client([Role::EMPLOYEE, Role::ADMIN])
|
||||
);
|
||||
|
||||
self::assertTrue($event->getAuthorizationResolution());
|
||||
self::assertNull($event->getResponse());
|
||||
}
|
||||
|
||||
public function testAccountHoldingNoneOfTheRequiredRolesIsDenied(): void
|
||||
{
|
||||
$event = $this->dispatch(
|
||||
$this->user([Role::CUSTOMER]),
|
||||
$this->client([Role::EMPLOYEE, Role::ADMIN])
|
||||
);
|
||||
|
||||
self::assertFalse($event->getAuthorizationResolution());
|
||||
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
/**
|
||||
* A CRM nomination grants nothing until an administrator approves it, so it must not
|
||||
* open a client either.
|
||||
*/
|
||||
public function testPendingMarkerDoesNotSatisfyTheRequirement(): void
|
||||
{
|
||||
$event = $this->dispatch(
|
||||
$this->user([Role::ADMIN.Role::PENDING_SUFFIX]),
|
||||
$this->client([Role::ADMIN])
|
||||
);
|
||||
|
||||
self::assertFalse($event->getAuthorizationResolution());
|
||||
}
|
||||
|
||||
/**
|
||||
* The column starts empty, so "nobody configured this client" and "this client needs
|
||||
* no roles" are the same value. Only one of the two readings is safe.
|
||||
*/
|
||||
public function testClientWithoutConfiguredRolesIsDenied(): void
|
||||
{
|
||||
$event = $this->dispatch($this->user([Role::ADMIN]), $this->client([]));
|
||||
|
||||
self::assertFalse($event->getAuthorizationResolution());
|
||||
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
public function testClientOfAnotherImplementationIsDenied(): void
|
||||
{
|
||||
$foreign = $this->createStub(ClientInterface::class);
|
||||
$foreign->method('getIdentifier')->willReturn('f3f10f8807e191c127d74cc9a247bf45');
|
||||
|
||||
$event = $this->dispatch($this->user([Role::ADMIN]), $foreign);
|
||||
|
||||
self::assertFalse($event->getAuthorizationResolution());
|
||||
self::assertSame(Response::HTTP_FORBIDDEN, $event->getResponse()?->getStatusCode());
|
||||
}
|
||||
|
||||
public function testExternalRequestLogsTheUserOutAfterApproval(): void
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($this->user([Role::EMPLOYEE]));
|
||||
$security->expects(self::once())->method('logout')->with(false);
|
||||
|
||||
$this->dispatch(
|
||||
$this->user([Role::EMPLOYEE]),
|
||||
$this->client([Role::EMPLOYEE]),
|
||||
external: true,
|
||||
security: $security
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A denied user has to stay signed in: they need a session to read the explanation and
|
||||
* navigate away from it.
|
||||
*/
|
||||
public function testExternalRequestDoesNotLogTheUserOutAfterDenial(): void
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->method('getUser')->willReturn($this->user([Role::CUSTOMER]));
|
||||
$security->expects(self::never())->method('logout');
|
||||
|
||||
$this->dispatch(
|
||||
$this->user([Role::CUSTOMER]),
|
||||
$this->client([Role::EMPLOYEE]),
|
||||
external: true,
|
||||
security: $security
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $requiredRoles
|
||||
*/
|
||||
private function client(array $requiredRoles): OAuth2Client
|
||||
{
|
||||
return (new OAuth2Client('EPHub', 'f3f10f8807e191c127d74cc9a247bf45', 'secret'))
|
||||
->setRequiredRoles($requiredRoles);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $roles
|
||||
*/
|
||||
private function user(array $roles): User
|
||||
{
|
||||
return (new User('[email protected]'))->setRoles($roles);
|
||||
}
|
||||
|
||||
private function dispatch(
|
||||
?UserInterface $user,
|
||||
ClientInterface $client,
|
||||
bool $external = false,
|
||||
?Security $security = null,
|
||||
): AuthorizationRequestResolveEvent {
|
||||
$request = new Request();
|
||||
$session = new Session(new MockArraySessionStorage());
|
||||
$session->set('_oauth2', $external);
|
||||
$request->setSession($session);
|
||||
|
||||
$requestStack = new RequestStack();
|
||||
$requestStack->push($request);
|
||||
|
||||
if (null === $security) {
|
||||
$security = $this->createStub(Security::class);
|
||||
$security->method('getUser')->willReturn($user);
|
||||
}
|
||||
|
||||
$urlGenerator = $this->createStub(UrlGeneratorInterface::class);
|
||||
$urlGenerator->method('generate')->willReturn('/');
|
||||
|
||||
$twig = $this->createStub(Environment::class);
|
||||
$twig->method('render')->willReturn('<html lang="de"></html>');
|
||||
|
||||
$listener = new AuthorizationCodeListener(
|
||||
$urlGenerator,
|
||||
$requestStack,
|
||||
$security,
|
||||
$this->createStub(LoggerInterface::class),
|
||||
$twig,
|
||||
);
|
||||
|
||||
$event = new AuthorizationRequestResolveEvent(
|
||||
$this->createStub(AuthorizationRequestInterface::class),
|
||||
[],
|
||||
$client,
|
||||
$user ?? new User('[email protected]'),
|
||||
);
|
||||
|
||||
$listener->onAuthorizationRequestResolve($event);
|
||||
|
||||
return $event;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user