Skip to content

Commit

Permalink
feat: use OIDC server with fine-grained authorization
Browse files Browse the repository at this point in the history
  • Loading branch information
vincentchalamon committed Mar 13, 2024
1 parent 8122ec6 commit d4d0104
Show file tree
Hide file tree
Showing 19 changed files with 526 additions and 23 deletions.
2 changes: 2 additions & 0 deletions api/.env
Expand Up @@ -20,6 +20,8 @@ TRUSTED_HOSTS=^(localhost|php)$
OIDC_SERVER_URL=https://localhost/oidc/realms/demo
OIDC_SERVER_URL_INTERNAL=http://php/oidc/realms/demo
OIDC_SWAGGER_CLIENT_ID=api-platform-swagger
OIDC_API_CLIENT_ID=api-platform-api
OIDC_API_CLIENT_SECRET=sEocbxCy7iFS8NzYzWyQ71QgxTDZ9fnU

###> symfony/framework-bundle ###
APP_ENV=dev
Expand Down
11 changes: 11 additions & 0 deletions api/config/packages/framework.yaml
Expand Up @@ -24,8 +24,19 @@ framework:
php_errors:
log: true

http_client:
scoped_clients:
security.authorization.client:
base_uri: '%env(OIDC_SERVER_URL_INTERNAL)%/'

when@test:
framework:
test: true
#session:
# storage_factory_id: session.storage.factory.mock_file

services:
App\Tests\Api\Mock\:
resource: '../../tests/Api/Mock/'
autowire: true
autoconfigure: true
2 changes: 0 additions & 2 deletions api/config/packages/security.yaml
Expand Up @@ -7,8 +7,6 @@ security:
providers:
app_user_provider:
id: 'App\Security\Core\UserProvider'
role_hierarchy:
ROLE_ADMIN: ROLE_USER
firewalls:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
Expand Down
12 changes: 10 additions & 2 deletions api/src/DataFixtures/Factory/UserFactory.php
Expand Up @@ -58,7 +58,16 @@ public function __construct()

public static function createOneAdmin(array $attributes = []): Proxy|User
{
return self::createOne(['roles' => ['ROLE_ADMIN']] + $attributes);
return self::new($attributes)->withAdmin()->create();
}

public function withAdmin(): self
{
return $this->addState([
'email' => 'chuck.norris@example.com',
'firstName' => 'Chuck',
'lastName' => 'Norris',
]);
}

/**
Expand All @@ -71,7 +80,6 @@ protected function getDefaults(): array
'email' => self::faker()->unique()->email(),
'firstName' => self::faker()->firstName(),
'lastName' => self::faker()->lastName(),
'roles' => ['ROLE_USER'],
];
}

Expand Down
8 changes: 1 addition & 7 deletions api/src/DataFixtures/Story/DefaultStory.php
Expand Up @@ -56,7 +56,6 @@ public function build(): void
'email' => 'john.doe@example.com',
'firstName' => 'John',
'lastName' => 'Doe',
'roles' => ['ROLE_USER'],
]);

// Default user has a review on the default book
Expand Down Expand Up @@ -85,11 +84,6 @@ public function build(): void
}

// Create admin user
UserFactory::createOne([
'email' => 'chuck.norris@example.com',
'firstName' => 'Chuck',
'lastName' => 'Norris',
'roles' => ['ROLE_ADMIN'],
]);
UserFactory::createOneAdmin();
}
}
2 changes: 1 addition & 1 deletion api/src/Entity/Book.php
Expand Up @@ -71,7 +71,7 @@
AbstractNormalizer::GROUPS => ['Book:write'],
],
collectDenormalizationErrors: true,
security: 'is_granted("ROLE_ADMIN")'
security: 'is_granted("ADMIN")'
)]
#[ApiResource(
types: ['https://schema.org/Book', 'https://schema.org/Offer'],
Expand Down
4 changes: 2 additions & 2 deletions api/src/Entity/Bookmark.php
Expand Up @@ -36,7 +36,7 @@
operations: [
new GetCollection(),
new Delete(
security: 'is_granted("ROLE_USER") and object.user === user'
security: 'object.user == user'
),
new Post(
processor: BookmarkPersistProcessor::class
Expand All @@ -54,7 +54,7 @@
],
collectDenormalizationErrors: true,
mercure: true,
security: 'is_granted("ROLE_USER")'
security: 'is_granted("USER")'
)]
#[ORM\Entity(repositoryClass: BookmarkRepository::class)]
#[ORM\UniqueConstraint(fields: ['user', 'book'])]
Expand Down
9 changes: 5 additions & 4 deletions api/src/Entity/Review.php
Expand Up @@ -14,6 +14,7 @@
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use ApiPlatform\Metadata\Put;
use ApiPlatform\Metadata\UrlGeneratorInterface;
use ApiPlatform\State\CreateProvider;
use App\Repository\ReviewRepository;
use App\Serializer\IriTransformerNormalizer;
Expand Down Expand Up @@ -76,7 +77,7 @@
AbstractNormalizer::GROUPS => ['Review:write', 'Review:write:admin'],
],
collectDenormalizationErrors: true,
security: 'is_granted("ROLE_ADMIN")'
security: 'is_granted("ADMIN")'
)]
#[ApiResource(
types: ['https://schema.org/Review'],
Expand All @@ -98,7 +99,7 @@
]
),
new Post(
security: 'is_granted("ROLE_USER")',
security: 'is_granted("USER")',
// Mercure publish is done manually in MercureProcessor through ReviewPersistProcessor
processor: ReviewPersistProcessor::class,
provider: CreateProvider::class,
Expand All @@ -111,7 +112,7 @@
'bookId' => new Link(toProperty: 'book', fromClass: Book::class),
'id' => new Link(fromClass: Review::class),
],
security: 'is_granted("ROLE_USER") and user == object.user',
security: 'object.user == user or is_granted("ADMIN")',
// Mercure publish is done manually in MercureProcessor through ReviewPersistProcessor
processor: ReviewPersistProcessor::class
),
Expand All @@ -121,7 +122,7 @@
'bookId' => new Link(toProperty: 'book', fromClass: Book::class),
'id' => new Link(fromClass: Review::class),
],
security: 'is_granted("ROLE_USER") and user == object.user',
security: 'object.user == user or is_granted("ADMIN")',
// Mercure publish is done manually in MercureProcessor through ReviewRemoveProcessor
processor: ReviewRemoveProcessor::class
),
Expand Down
6 changes: 3 additions & 3 deletions api/src/Entity/User.php
Expand Up @@ -30,17 +30,17 @@
new GetCollection(
uriTemplate: '/admin/users{._format}',
itemUriTemplate: '/admin/users/{id}{._format}',
security: 'is_granted("ROLE_ADMIN")',
security: 'is_granted("ADMIN")',
filters: ['app.filter.user.admin.name'],
paginationClientItemsPerPage: true
),
new Get(
uriTemplate: '/admin/users/{id}{._format}',
security: 'is_granted("ROLE_ADMIN")'
security: 'is_granted("ADMIN")'
),
new Get(
uriTemplate: '/users/{id}{._format}',
security: 'is_granted("ROLE_USER") and object.getUserIdentifier() === user.getUserIdentifier()'
security: 'user.sub === object.sub'
),
],
normalizationContext: [
Expand Down
71 changes: 71 additions & 0 deletions api/src/Security/Voter/OIDCPermissionVoter.php
@@ -0,0 +1,71 @@
<?php

namespace App\Security\Voter;

use ApiPlatform\Metadata\IriConverterInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Check user permissions.
*
* @see https://www.keycloak.org/docs/latest/authorization_services/index.html#_service_obtaining_permissions
*/
final class OIDCPermissionVoter extends Voter
{
use OIDCVoterTrait;

public function __construct(
#[Autowire('%env(OIDC_API_CLIENT_ID)%')]
private readonly string $oidcClientId,
private readonly HttpClientInterface $securityAuthorizationClient,
private readonly IriConverterInterface $iriConverter,
private readonly RequestStack $requestStack,
#[Autowire('@security.access_token_extractor.header')]
private readonly AccessTokenExtractorInterface $accessTokenExtractor,
) {}

protected function supports(string $attribute, mixed $subject): bool
{
return !empty($subject);
}

protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$accessToken = $this->getToken($token);
if (!$accessToken) {
return false;
}

if (is_object($subject)) {
$subject = $this->iriConverter->getIriFromResource($subject);
}

if (!is_string($subject)) {
throw new \InvalidArgumentException(sprintf('Invalid subject type, expected "string" or "object", got "%s".', get_debug_type($subject)));
}

try {
$response = $this->securityAuthorizationClient->request('POST', 'protocol/openid-connect/token', [
'auth_bearer' => $accessToken,
'body' => [
'grant_type' => 'urn:ietf:params:oauth:grant-type:uma-ticket',
'audience' => $this->oidcClientId,
'response_mode' => 'decision',
'permission_resource_format' => 'uri',
'permission_resource_matching_uri' => true,
'permission' => sprintf('%s', $subject),
],
]);

return $response->toArray()['result'] ?? false;
} catch (ExceptionInterface) {
return false;
}
}
}
65 changes: 65 additions & 0 deletions api/src/Security/Voter/OIDCRoleVoter.php
@@ -0,0 +1,65 @@
<?php

namespace App\Security\Voter;

use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;

/**
* Check user roles.
*
* @see https://www.keycloak.org/docs/latest/authorization_services/index.html#obtaining-information-about-an-rpt
*/
final class OIDCRoleVoter extends Voter
{
use OIDCVoterTrait;

public function __construct(
#[Autowire('%env(OIDC_API_CLIENT_ID)%')]
private readonly string $oidcClientId,
#[Autowire('%env(OIDC_API_CLIENT_SECRET)%')]
private readonly string $oidcClientSecret,
private readonly HttpClientInterface $securityAuthorizationClient,
private readonly RequestStack $requestStack,
#[Autowire('@security.access_token_extractor.header')]
private readonly AccessTokenExtractorInterface $accessTokenExtractor,
) {}

protected function supports(string $attribute, mixed $subject): bool
{
return empty($subject);
}

protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$accessToken = $this->getToken($token);
if (!$accessToken) {
return false;
}

if (!empty($subject)) {
throw new \InvalidArgumentException(sprintf('Invalid subject type, expected empty string or "null", got "%s".', get_debug_type($subject)));
}

try {
$response = $this->securityAuthorizationClient->request('POST', 'protocol/openid-connect/token/introspect', [
'body' => [
'client_id' => $this->oidcClientId,
'client_secret' => $this->oidcClientSecret,
'token' => $accessToken,
],
]);

$roles = array_map(static fn (string $role): string => strtolower($role), $response->toArray()['realm_access']['roles'] ?? []);

return in_array(strtolower($attribute), $roles, true);
} catch (ExceptionInterface) {
return false;
}
}
}
32 changes: 32 additions & 0 deletions api/src/Security/Voter/OIDCVoterTrait.php
@@ -0,0 +1,32 @@
<?php

namespace App\Security\Voter;

use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\User\UserInterface;

trait OIDCVoterTrait
{
/**
* @throws BadCredentialsException
*/
private function getToken(TokenInterface $token): bool|string
{
// ensure user is authenticated
if (!$token->getUser() instanceof UserInterface) {
return false;
}

$request = $this->requestStack->getCurrentRequest();

// user is authenticated, its token should be valid (validated through AccessTokenAuthenticator)
// todo is there a better way to retrieve the access-token?
$accessToken = $this->accessTokenExtractor->extractAccessToken($request);
if (!$accessToken) {
return false;
}

return $accessToken;
}
}
2 changes: 1 addition & 1 deletion api/tests/Api/Admin/BookTest.php
Expand Up @@ -172,7 +172,7 @@ public static function getAllUsers(): iterable
{
yield [null];
yield [UserFactory::new()];
yield [UserFactory::new(['roles' => ['ROLE_ADMIN']])];
yield [UserFactory::new()->withAdmin()];
}

#[Test]
Expand Down

0 comments on commit d4d0104

Please sign in to comment.