Added authorization endpoints

This commit is contained in:
Jan Böhmer 2026-07-27 00:15:22 +02:00
parent 01a747da1d
commit 37108dbf56
6 changed files with 406 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1,103 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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)
));
}
}

View file

@ -0,0 +1,74 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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);
}
}

View file

@ -0,0 +1,35 @@
{% extends "main_card.html.twig" %}
{% block title %}{% trans %}oauth.authorize.title{% endtrans %}{% endblock %}
{% block card_title %}<h5>
<i class="fa-solid fa-key fa-fw" aria-hidden="true"></i>
{% trans %}oauth.authorize.title{% endtrans %}
</h5>
{% endblock %}
{% block card_content %}
<p>{% trans with {'%client%': client.name} %}oauth.authorize.description{% endtrans %}</p>
{% if levels is not empty %}
<p>{% trans %}oauth.authorize.scopes_intro{% endtrans %}</p>
<ul>
{% for level in levels %}
<li>{{ (level.translationKey)|trans }}</li>
{% endfor %}
</ul>
{% endif %}
<p class="text-muted">{% trans %}oauth.authorize.revoke_hint{% endtrans %}</p>
<form method="post" action="{{ app.request.requestUri }}">
<input type="hidden" name="_csrf_token" value="{{ csrf_token(csrf_token_id) }}">
<button type="submit" name="oauth_decision" value="deny" class="btn btn-secondary">
{% trans %}oauth.authorize.deny{% endtrans %}
</button>
<button type="submit" name="oauth_decision" value="approve" class="btn btn-primary">
{% trans %}oauth.authorize.approve{% endtrans %}
</button>
</form>
{% endblock %}

View file

@ -0,0 +1,153 @@
<?php
/*
* This file is part of Part-DB (https://github.com/Part-DB/Part-DB-symfony).
*
* Copyright (C) 2019 - 2023 Jan Böhmer (https://github.com/jbtronics)
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
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);
}
}

View file

@ -13823,5 +13823,41 @@ Buerklin-API Authentication server:
<target>Using this info provider can cause additional costs, or has very strict rate limits.</target>
</segment>
</unit>
<unit id="oaAuth01" name="oauth.authorize.title">
<segment>
<source>oauth.authorize.title</source>
<target>Authorize application</target>
</segment>
</unit>
<unit id="oaAuth02" name="oauth.authorize.description">
<segment>
<source>oauth.authorize.description</source>
<target>The application &quot;%client%&quot; wants to access your Part-DB account.</target>
</segment>
</unit>
<unit id="oaAuth03" name="oauth.authorize.scopes_intro">
<segment>
<source>oauth.authorize.scopes_intro</source>
<target>It is requesting the following level of access:</target>
</segment>
</unit>
<unit id="oaAuth04" name="oauth.authorize.revoke_hint">
<segment>
<source>oauth.authorize.revoke_hint</source>
<target>You can revoke this access at any time from your user settings.</target>
</segment>
</unit>
<unit id="oaAuth05" name="oauth.authorize.approve">
<segment>
<source>oauth.authorize.approve</source>
<target>Approve</target>
</segment>
</unit>
<unit id="oaAuth06" name="oauth.authorize.deny">
<segment>
<source>oauth.authorize.deny</source>
<target>Deny</target>
</segment>
</unit>
</file>
</xliff>