From 37108dbf564cc91c1e123db24297972290510c98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20B=C3=B6hmer?= Date: Mon, 27 Jul 2026 00:15:22 +0200 Subject: [PATCH] Added authorization endpoints --- config/packages/security.yaml | 5 + .../OAuth/AuthorizationConsentListener.php | 103 ++++++++++++ .../OAuth/OAuthExternalRedirectListener.php | 74 +++++++++ templates/oauth/authorize.html.twig | 35 ++++ .../AuthorizationConsentListenerTest.php | 153 ++++++++++++++++++ translations/messages.en.xlf | 36 +++++ 6 files changed, 406 insertions(+) create mode 100644 src/EventListener/OAuth/AuthorizationConsentListener.php create mode 100644 src/EventListener/OAuth/OAuthExternalRedirectListener.php create mode 100644 templates/oauth/authorize.html.twig create mode 100644 tests/EventListener/OAuth/AuthorizationConsentListenerTest.php diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 811e2c62..f2da5ad5 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -70,6 +70,11 @@ security: # We get into trouble with the U2F authentication, if the calls to the trees trigger an 2FA login # This settings should not do much harm, because a read only access to show available data structures is not really critical - { path: "^/\\w{2}/tree", role: PUBLIC_ACCESS } + # The OAuth2 consent screen (see App\EventListener\OAuth\AuthorizationConsentListener) needs a + # fully logged-in user - AuthorizationRequestResolveEventFactory throws otherwise. Requiring full + # authentication here (rather than relying on is_authenticated()) triggers Symfony's normal + # redirect-to-login-then-back flow for anonymous requests, same as the user settings pages. + - { path: ^/authorize, role: IS_AUTHENTICATED_FULLY } # Restrict access to API to users, which has the API access permission - { path: "^/api", allow_if: 'is_granted("@api.access_api") and is_authenticated()' } # Restrict access to KICAD to users, which has API access permission diff --git a/src/EventListener/OAuth/AuthorizationConsentListener.php b/src/EventListener/OAuth/AuthorizationConsentListener.php new file mode 100644 index 00000000..738e861c --- /dev/null +++ b/src/EventListener/OAuth/AuthorizationConsentListener.php @@ -0,0 +1,103 @@ +. + */ + +declare(strict_types=1); + +namespace App\EventListener\OAuth; + +use App\Entity\UserSystem\ApiTokenLevel; +use League\Bundle\OAuth2ServerBundle\Event\AuthorizationRequestResolveEvent; +use League\Bundle\OAuth2ServerBundle\OAuth2Events; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Twig\Environment; + +/** + * Renders the consent screen for the OAuth2 authorization code flow (see + * config/packages/league_oauth2_server.yaml) and turns the user's decision into an approval/denial of + * the AuthorizationRequestResolveEvent. + * + * league/oauth2-server-bundle 1.2 has no built-in consent UI or listener for this event at all (unlike + * some other OAuth2 bundles) - the "user" on the event is resolved directly from the current Symfony + * security session by AuthorizationRequestResolveEventFactory, which throws if nobody is logged in; the + * /authorize access_control entry (IS_AUTHENTICATED_FULLY) makes sure a login happens first. + * + * The consent form resubmits to the exact same URL (same query string, so + * league/oauth2-server re-derives an identical AuthorizationRequest - it only reads request params from + * the query string, never the POST body), with the actual decision + a CSRF token in the POST body. + */ +#[AsEventListener(event: OAuth2Events::AUTHORIZATION_REQUEST_RESOLVE)] +class AuthorizationConsentListener +{ + public const CSRF_TOKEN_ID = 'oauth_authorize'; + + public function __construct( + private readonly RequestStack $requestStack, + private readonly Environment $twig, + private readonly CsrfTokenManagerInterface $csrfTokenManager, + ) { + } + + public function __invoke(AuthorizationRequestResolveEvent $event): void + { + $request = $this->requestStack->getMainRequest(); + + if ($request?->isMethod('POST') && $request->request->has('oauth_decision')) { + $token = new CsrfToken(self::CSRF_TOKEN_ID, (string) $request->request->get('_csrf_token')); + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new AccessDeniedHttpException('Invalid CSRF token.'); + } + + $event->resolveAuthorization('approve' === $request->request->get('oauth_decision')); + + return; + } + + $response = new Response($this->twig->render('oauth/authorize.html.twig', [ + 'client' => $event->getClient(), + 'levels' => $this->scopesToLevels($event->getScopes()), + 'csrf_token_id' => self::CSRF_TOKEN_ID, + ])); + + $event->setResponse($response); + } + + /** + * Scopes are just App\Entity\UserSystem\ApiTokenLevel case names lowercased (see + * App\Security\OAuth\OAuthBearerAuthenticator and config/packages/league_oauth2_server.yaml's + * scopes/role_prefix) - map them back for a human-readable consent screen. + * + * @param \League\Bundle\OAuth2ServerBundle\ValueObject\Scope[] $scopes + * @return ApiTokenLevel[] + */ + private function scopesToLevels(array $scopes): array + { + $requested = array_map(static fn ($scope) => (string) $scope, $scopes); + + return array_values(array_filter( + ApiTokenLevel::cases(), + static fn (ApiTokenLevel $level) => \in_array(strtolower($level->name), $requested, true) + )); + } +} diff --git a/src/EventListener/OAuth/OAuthExternalRedirectListener.php b/src/EventListener/OAuth/OAuthExternalRedirectListener.php new file mode 100644 index 00000000..e9e2be9d --- /dev/null +++ b/src/EventListener/OAuth/OAuthExternalRedirectListener.php @@ -0,0 +1,74 @@ +. + */ + +declare(strict_types=1); + +namespace App\EventListener\OAuth; + +use Nelmio\SecurityBundle\ExternalRedirect\ExternalRedirectResponse; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; +use Symfony\Component\HttpKernel\Event\ResponseEvent; + +/** + * Exempts the OAuth2 /authorize and /token redirect responses (league/oauth2-server-bundle) from + * NelmioSecurityBundle's generic external-redirect protection (config/packages/nelmio_security.yaml's + * external_redirects, which 403s any redirect to a host not on its fixed allow_list). + * + * This is safe specifically because league/oauth2-server has *already* strictly validated the redirect + * target against the OAuth client's own pre-registered redirect_uris (AuthCodeGrant::validateRedirectUri()) + * before this response was created - Nelmio's allow_list just has no way to know about that per-client + * validation, since it's a static, app-wide list. This only re-exempts this one response object; it does + * not disable Nelmio's protection anywhere else. + * + * Must run *before* Nelmio\SecurityBundle\EventListener\ExternalRedirectListener (registered at the + * default kernel.response priority of 0), hence the explicit positive priority here. + */ +#[AsEventListener(event: 'kernel.response', priority: 32)] +class OAuthExternalRedirectListener +{ + private const OAUTH_REDIRECT_PATHS = ['/authorize', '/token']; + + public function __invoke(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + if (!\in_array($event->getRequest()->getPathInfo(), self::OAUTH_REDIRECT_PATHS, true)) { + return; + } + + $response = $event->getResponse(); + if ($response instanceof ExternalRedirectResponse || !$response->isRedirect()) { + return; + } + + $target = $response->headers->get('Location'); + $host = \is_string($target) ? parse_url($target, PHP_URL_HOST) : null; + if (!\is_string($host)) { + return; + } + + $exemptResponse = new ExternalRedirectResponse($target, [$host], $response->getStatusCode()); + $exemptResponse->headers->add($response->headers->all()); + + $event->setResponse($exemptResponse); + } +} diff --git a/templates/oauth/authorize.html.twig b/templates/oauth/authorize.html.twig new file mode 100644 index 00000000..d6e756df --- /dev/null +++ b/templates/oauth/authorize.html.twig @@ -0,0 +1,35 @@ +{% extends "main_card.html.twig" %} + +{% block title %}{% trans %}oauth.authorize.title{% endtrans %}{% endblock %} + +{% block card_title %}
+ + {% trans %}oauth.authorize.title{% endtrans %} +
+{% endblock %} + +{% block card_content %} +

{% trans with {'%client%': client.name} %}oauth.authorize.description{% endtrans %}

+ + {% if levels is not empty %} +

{% trans %}oauth.authorize.scopes_intro{% endtrans %}

+ + {% endif %} + +

{% trans %}oauth.authorize.revoke_hint{% endtrans %}

+ +
+ + + + +
+{% endblock %} diff --git a/tests/EventListener/OAuth/AuthorizationConsentListenerTest.php b/tests/EventListener/OAuth/AuthorizationConsentListenerTest.php new file mode 100644 index 00000000..f016262f --- /dev/null +++ b/tests/EventListener/OAuth/AuthorizationConsentListenerTest.php @@ -0,0 +1,153 @@ +. + */ + +declare(strict_types=1); + +namespace App\Tests\EventListener\OAuth; + +use App\Entity\UserSystem\User; +use Doctrine\ORM\EntityManagerInterface; +use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface; +use League\Bundle\OAuth2ServerBundle\Model\Client; +use League\Bundle\OAuth2ServerBundle\ValueObject\Grant; +use League\Bundle\OAuth2ServerBundle\ValueObject\RedirectUri; +use League\Bundle\OAuth2ServerBundle\ValueObject\Scope; +use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; + +/** + * Drives the real GET/POST /authorize HTTP flow (routes/controller provided by + * league/oauth2-server-bundle, consent screen provided by + * App\EventListener\OAuth\AuthorizationConsentListener), rather than calling AuthorizationServer + * directly - this is the only test covering the actual consent screen and its CSRF handling. + */ +final class AuthorizationConsentListenerTest extends WebTestCase +{ + private function createTestClient(\Symfony\Bundle\FrameworkBundle\KernelBrowser $httpClient, string $redirectUri): Client + { + $client = new Client('Test Client', 'test-client-'.bin2hex(random_bytes(8)), null); + $client->setRedirectUris(new RedirectUri($redirectUri)); + $client->setGrants(new Grant('authorization_code'), new Grant('refresh_token')); + $client->setScopes(new Scope('read_only')); + $client->setActive(true); + + static::getContainer()->get(ClientManagerInterface::class)->save($client); + + return $client; + } + + private function authorizeUrl(Client $client, string $redirectUri): string + { + $verifier = rtrim(strtr(base64_encode(random_bytes(40)), '+/', '-_'), '='); + $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '='); + + return '/authorize?'.http_build_query([ + 'response_type' => 'code', + 'client_id' => $client->getIdentifier(), + 'redirect_uri' => $redirectUri, + 'code_challenge' => $challenge, + 'code_challenge_method' => 'S256', + 'scope' => 'read_only', + ]); + } + + public function testUnauthenticatedRequestIsRejected(): void + { + // Note: config/packages/test/security.yaml overrides the "main" firewall's entry_point to + // "http_basic" for the whole test environment, so unauthenticated requests to any + // IS_AUTHENTICATED_FULLY-protected route (including /authorize) get a 401 "Basic realm" + // challenge here, not the production redirect-to-/login behaviour of + // App\Security\AuthenticationEntryPoint. Same convention as + // App\Tests\Controller\AuthorizationTest::testUnauthenticatedIsUnauthorizedOnWriteRoutes. + $httpClient = static::createClient(); + $redirectUri = 'https://client.example.invalid/callback'; + $client = $this->createTestClient($httpClient, $redirectUri); + + $httpClient->request('GET', $this->authorizeUrl($client, $redirectUri)); + self::assertResponseStatusCodeSame(401); + } + + public function testApproveRedirectsWithAuthorizationCode(): void + { + $httpClient = static::createClient(); + $entityManager = static::getContainer()->get(EntityManagerInterface::class); + $admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + self::assertInstanceOf(User::class, $admin); + $httpClient->loginUser($admin); + + $redirectUri = 'https://client.example.invalid/callback'; + $client = $this->createTestClient($httpClient, $redirectUri); + + $crawler = $httpClient->request('GET', $this->authorizeUrl($client, $redirectUri)); + self::assertResponseIsSuccessful(); + + $form = $crawler->selectButton('Approve')->form(); + $httpClient->submit($form); + + self::assertResponseRedirects(); + $location = $httpClient->getResponse()->headers->get('Location'); + self::assertNotNull($location); + self::assertStringStartsWith($redirectUri, $location); + parse_str((string) parse_url($location, PHP_URL_QUERY), $query); + self::assertArrayHasKey('code', $query); + } + + public function testDenyRedirectsWithAccessDeniedError(): void + { + $httpClient = static::createClient(); + $entityManager = static::getContainer()->get(EntityManagerInterface::class); + $admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + self::assertInstanceOf(User::class, $admin); + $httpClient->loginUser($admin); + + $redirectUri = 'https://client.example.invalid/callback'; + $client = $this->createTestClient($httpClient, $redirectUri); + + $crawler = $httpClient->request('GET', $this->authorizeUrl($client, $redirectUri)); + self::assertResponseIsSuccessful(); + + $form = $crawler->selectButton('Deny')->form(); + $httpClient->submit($form); + + self::assertResponseRedirects(); + $location = $httpClient->getResponse()->headers->get('Location'); + self::assertNotNull($location); + parse_str((string) parse_url($location, PHP_URL_QUERY), $query); + self::assertSame('access_denied', $query['error'] ?? null); + } + + public function testMissingCsrfTokenIsRejected(): void + { + $httpClient = static::createClient(); + $entityManager = static::getContainer()->get(EntityManagerInterface::class); + $admin = $entityManager->getRepository(User::class)->findOneBy(['name' => 'admin']); + self::assertInstanceOf(User::class, $admin); + $httpClient->loginUser($admin); + + $redirectUri = 'https://client.example.invalid/callback'; + $client = $this->createTestClient($httpClient, $redirectUri); + $url = $this->authorizeUrl($client, $redirectUri); + + $httpClient->request('GET', $url); + self::assertResponseIsSuccessful(); + + $httpClient->request('POST', $url, ['oauth_decision' => 'approve', '_csrf_token' => 'invalid']); + self::assertResponseStatusCodeSame(403); + } +} diff --git a/translations/messages.en.xlf b/translations/messages.en.xlf index 597816e1..ef2a775f 100644 --- a/translations/messages.en.xlf +++ b/translations/messages.en.xlf @@ -13823,5 +13823,41 @@ Buerklin-API Authentication server: Using this info provider can cause additional costs, or has very strict rate limits. + + + oauth.authorize.title + Authorize application + + + + + oauth.authorize.description + The application "%client%" wants to access your Part-DB account. + + + + + oauth.authorize.scopes_intro + It is requesting the following level of access: + + + + + oauth.authorize.revoke_hint + You can revoke this access at any time from your user settings. + + + + + oauth.authorize.approve + Approve + + + + + oauth.authorize.deny + Deny + +