diff --git a/config/packages/api_platform.yaml b/config/packages/api_platform.yaml index 2ae82ba..3361b89 100644 --- a/config/packages/api_platform.yaml +++ b/config/packages/api_platform.yaml @@ -6,7 +6,11 @@ api_platform: formats: jsonld: [ 'application/ld+json' ] json: [ 'application/json' ] -# swagger: + swagger: + api_keys: + JWT: + name: Authorization + type: header # versions: [3] enable_swagger_ui: true enable_re_doc: true \ No newline at end of file diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 9f5e75e..74ef5c1 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -64,6 +64,7 @@ security: - { path: ^/api/(login|token/refresh), roles: PUBLIC_ACCESS } # - { path: ^/api/token/refresh, roles: IS_AUTHENTICATED_ANONYMOUSLY } - { path: ^/api/docs, roles: PUBLIC_ACCESS } + - { path: ^/docs, roles: PUBLIC_ACCESS } - { path: ^/api, roles: ROLE_USER } when@test: diff --git a/config/services.yaml b/config/services.yaml index 892f2fe..ec08539 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -23,6 +23,11 @@ services: - '../src/Entity/' - '../src/Kernel.php' + + App\OpenApi\JwtDecorator: + decorates: 'api_platform.openapi.factory' + arguments: [ '@.inner' ] + # App\DataPersister\UserDataPersister: # bind: # $dataPersister: '@api_platform.doctrine.orm.data_persister' diff --git a/src/DataPersister/CustomerDataPersister.php b/src/DataPersister/CustomerDataPersister.php new file mode 100644 index 0000000..ccb138c --- /dev/null +++ b/src/DataPersister/CustomerDataPersister.php @@ -0,0 +1,51 @@ +entityManager = $entityManager; + $this->security = $security; + } + + /** + * @inheritDoc + */ + public function supports($data, array $context = []): bool + { + return $data instanceof Customer; + } + + /** + * @inheritDoc + * @param Customer $data + */ + public function persist($data, array $context = []) + { + $user = $this->security->getUser(); + $data->setAccount($user->getAccount()); + $this->entityManager->persist($data); + $this->entityManager->flush(); + return $data; + } + + /** + * @inheritDoc + */ + public function remove($data, array $context = []) + { + $this->entityManager->remove($data); + $this->entityManager->flush(); + } +} + diff --git a/src/DataPersister/UserDataPersister.php b/src/DataPersister/UserDataPersister.php index 12d4859..4e22346 100644 --- a/src/DataPersister/UserDataPersister.php +++ b/src/DataPersister/UserDataPersister.php @@ -2,31 +2,29 @@ namespace App\DataPersister; -use ApiPlatform\Core\DataPersister\DataPersisterInterface; +use ApiPlatform\Core\DataPersister\ContextAwareDataPersisterInterface; use App\Entity\User; use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Security\Core\Security; -final class UserDataPersister implements DataPersisterInterface +final class UserDataPersister implements ContextAwareDataPersisterInterface { private EntityManagerInterface $entityManager; private UserPasswordHasherInterface $userPasswordHasher; - private DataPersisterInterface $dataPersister; private Security $security; - public function __construct(EntityManagerInterface $entityManager, UserPasswordHasherInterface $userPasswordHasher, DataPersisterInterface $dataPersister, Security $security) + public function __construct(EntityManagerInterface $entityManager, UserPasswordHasherInterface $userPasswordHasher, Security $security) { $this->entityManager = $entityManager; $this->userPasswordHasher = $userPasswordHasher; - $this->dataPersister = $dataPersister; $this->security = $security; } /** * @inheritDoc */ - public function supports($data): bool + public function supports($data, $context = []): bool { return $data instanceof User; } @@ -35,7 +33,7 @@ public function supports($data): bool * @inheritDoc * @param User $data */ - public function persist($data) + public function persist($data, $context = []) { // this only applies in the context of API requests, when persisting an Entity manually, // the traditional method prevails (setting $password to a hashed pwd manually) @@ -49,6 +47,7 @@ public function persist($data) $data->setAccount($user->getAccount()); $this->entityManager->persist($data); $this->entityManager->flush(); + return $data; } /** @@ -56,6 +55,7 @@ public function persist($data) */ public function remove($data, array $context = []) { - $this->dataPersister->remove($data); + $this->entityManager->remove($data); + $this->entityManager->flush(); } } diff --git a/src/Doctrine/CurrentAccountCustomerExtension.php b/src/Doctrine/CurrentAccountCustomerExtension.php index 37f626d..edf28b5 100644 --- a/src/Doctrine/CurrentAccountCustomerExtension.php +++ b/src/Doctrine/CurrentAccountCustomerExtension.php @@ -36,7 +36,7 @@ private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): vo // only return Customers from current user's Account $rootAlias = $queryBuilder->getRootAliases()[0]; - $queryBuilder->andWhere(sprintf('%s.account = :current_account', $rootAlias)); + $queryBuilder->andWhere(sprintf('%s.account = :user_account', $rootAlias)); $queryBuilder->setParameter('user_account', $user->getAccount()); } } diff --git a/src/Doctrine/CurrentUserExtension.php b/src/Doctrine/CurrentUserExtension.php index fccb1d7..6576a7f 100644 --- a/src/Doctrine/CurrentUserExtension.php +++ b/src/Doctrine/CurrentUserExtension.php @@ -32,15 +32,12 @@ public function applyToItem(QueryBuilder $queryBuilder, QueryNameGeneratorInterf private function addWhere(QueryBuilder $queryBuilder, string $resourceClass): void { - if (Account::class !== $resourceClass || $this->security->isGranted('ROLE_SUPER_ADMIN') || null === $user = $this->security->getUser()) { + if (User::class !== $resourceClass || $this->security->isGranted('ROLE_SUPER_ADMIN') || null === $user = $this->security->getUser()) { return; } - - //@TODO: this doesn't actually work; the request is probably wrong - // the goal is to only return the current user's account instead of all accounts + // the goal is to only return the current user's account users instead of all users $rootAlias = $queryBuilder->getRootAliases()[0]; - $queryBuilder->andWhere('u.account = :user_account'); - $queryBuilder->innerJoin(User::class, 'u', Join::WITH, sprintf('u.account = %s.id', $rootAlias)); + $queryBuilder->andWhere(sprintf('%s.account = :user_account', $rootAlias)); $queryBuilder->setParameter('user_account', $user->getAccount()); } } diff --git a/src/Entity/Customer.php b/src/Entity/Customer.php index 86a8a59..fa31122 100644 --- a/src/Entity/Customer.php +++ b/src/Entity/Customer.php @@ -13,19 +13,16 @@ #[ORM\Entity(repositoryClass: CustomerRepository::class)] #[ApiResource( collectionOperations: [ - "get" => [ - 'security' => 'is_granted("ROLE_ADMIN") or object.getAccount() == user.getAccount()', - 'security_message' => 'Sorry, you can only access Customers linked to your own Account.', - ], + "get", "post" ], itemOperations: [ - "get" => [ - 'security' => 'is_granted("ROLE_ADMIN") or object.getAccount() == user.getAccount()', - 'security_message' => 'Sorry, you can only access Customers linked to your own Account.', - ], - "delete" => ["security" => "is_granted('ROLE_ADMIN') or object.getAccount() == user.getAccount()"], - ], + "get", + "delete" => [ + "security" => "is_granted('DELETE_CUSTOMER', object)", + 'security_message' => 'Sorry, you can only delete Customers linked to your own Account.', + ], + ], attributes: [ 'pagination_items_per_page' => 10, 'formats' => ['json', 'jsonld'], @@ -39,6 +36,7 @@ class Customer #[ORM\Id] #[ORM\GeneratedValue] #[ORM\Column(type: 'integer')] + #[Groups(['customer:read'])] private ?int $id; #[ORM\Column(type: 'string', length: 255, unique: true)] @@ -65,7 +63,6 @@ class Customer #[ORM\JoinColumn(nullable: false)] #[Groups(['customer:read'])] #[Assert\Valid()] - #[Assert\NotBlank()] private ?Account $account; #[ORM\Column(type: 'datetime_immutable')] diff --git a/src/OpenApi/JwtDecorator.php b/src/OpenApi/JwtDecorator.php new file mode 100644 index 0000000..d682a09 --- /dev/null +++ b/src/OpenApi/JwtDecorator.php @@ -0,0 +1,87 @@ +decorated)($context); + $schemas = $openApi->getComponents()->getSchemas(); + + $schemas['Token'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'token' => [ + 'type' => 'string', + 'readOnly' => true, + ], + ], + ]); + $schemas['Credentials'] = new \ArrayObject([ + 'type' => 'object', + 'properties' => [ + 'username' => [ + 'type' => 'string', + 'example' => 'johndoe@example.com', + ], + 'password' => [ + 'type' => 'string', + 'example' => 'apassword', + ], + ], + ]); + + $schemas = $openApi->getComponents()->getSecuritySchemes() ?? []; + $schemas['JWT'] = new \ArrayObject([ + 'type' => 'http', + 'scheme' => 'bearer', + 'bearerFormat' => 'JWT', + ]); + + $pathItem = new Model\PathItem( + ref: 'JWT Token', + post: new Model\Operation( + operationId: 'postCredentialsItem', + tags: ['Token'], + responses: [ + '200' => [ + 'description' => 'Get JWT token', + 'content' => [ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/Token', + ], + ], + ], + ], + ], + summary: 'Get JWT token to login.', + requestBody: new Model\RequestBody( + description: 'Generate new JWT Token', + content: new \ArrayObject([ + 'application/json' => [ + 'schema' => [ + '$ref' => '#/components/schemas/Credentials', + ], + ], + ]), + ), + security: [], + ), + ); + $openApi->getPaths()->addPath('/api/login_check', $pathItem); + + return $openApi; + } +} \ No newline at end of file diff --git a/src/Security/Voter/CustomerVoter.php b/src/Security/Voter/CustomerVoter.php new file mode 100644 index 0000000..0c13493 --- /dev/null +++ b/src/Security/Voter/CustomerVoter.php @@ -0,0 +1,53 @@ +security = $security; + } + + protected function supports(string $attribute, $subject): bool + { + return $attribute == self::DELETE_CUSTOMER + && $subject instanceof Customer; + } + + /** + * @throws Exception + */ + protected function voteOnAttribute(string $attribute, $subject, TokenInterface $token): bool + { + $user = $token->getUser(); + // if the user is anonymous, do not grant access + if (!$user instanceof UserInterface) { + return false; + } + + /** @var Customer $subject */ + + if ($attribute == self::DELETE_CUSTOMER) { + if ($subject->getAccount() === $user->getAccount()) { + return true; + } + if ($this->security->isGranted('ROLE_ADMIN')) { + return true; + } + return false; + } + throw new Exception(sprintf('Unhandled attribute "%s"', $attribute)); + } +} diff --git a/src/Security/Voter/UserVoter.php b/src/Security/Voter/UserVoter.php index 1e3b079..f537b40 100644 --- a/src/Security/Voter/UserVoter.php +++ b/src/Security/Voter/UserVoter.php @@ -46,25 +46,15 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ /** @var User $subject */ switch ($attribute) { - case self::VIEW: - if ($subject->getAccount() === $user->getAccount()) { - return true; - } - if ($this->security->isGranted('ROLE_ADMIN')) { - return true; - } - return false; - break; case self::CREATE: + case self::VIEW: if ($subject->getAccount() === $user->getAccount()) { return true; } - if ($this->security->isGranted('ROLE_ADMIN')) { return true; } return false; - break; case self::DELETE: if ($subject === $user) { return true; @@ -73,7 +63,6 @@ protected function voteOnAttribute(string $attribute, $subject, TokenInterface $ return true; } return false; - break; } throw new Exception(sprintf('Unhandled attribute "%s"', $attribute)); diff --git a/src/Test/CustomApiTestCase.php b/src/Test/CustomApiTestCase.php index d8c803d..42d7931 100644 --- a/src/Test/CustomApiTestCase.php +++ b/src/Test/CustomApiTestCase.php @@ -5,6 +5,7 @@ use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\ApiTestCase; use ApiPlatform\Core\Bridge\Symfony\Bundle\Test\Client; use App\Entity\Account; +use App\Entity\Customer; use App\Entity\User; use DateTimeImmutable; use Doctrine\ORM\EntityManagerInterface; @@ -54,6 +55,25 @@ public function createAccount(string $primaryEmail): Account return $account; } + /** + * @throws Exception + */ + public function createCustomer(string $firstName, string $lastName, string $email, string $phoneNumber, Account $account): Customer + { + $em = $this->getEntityManager(); + $customer = new Customer(); + $customer->setFirstName($firstName); + $customer->setLastName($lastName); + $customer->setEmail($email); + $customer->setPhoneNumber($phoneNumber); + $customer->setAccount($account); + $customer->setCreatedAt(new DateTimeImmutable()); + $customer->setUpdatedAt(new DateTimeImmutable()); + + $em->persist($customer); + $em->flush(); + return $customer; + } /** * @coversNothing * @throws TransportExceptionInterface diff --git a/tests/Functional/CustomerTest.php b/tests/Functional/CustomerTest.php index 0f5a5e4..e89b20f 100644 --- a/tests/Functional/CustomerTest.php +++ b/tests/Functional/CustomerTest.php @@ -46,12 +46,38 @@ public function testUserCannotCreateCustomersWithoutJWT() { } + /** + * @throws Exception + * @throws TransportExceptionInterface + */ public function testUserCanDeleteCustomerOnOwnAccount() { + $client = self::createClient(); + $container = static::getContainer(); + $account = $this->createAccount('marie.dupont@fnac.com'); + $user = $this->createUser('vincent.rock@gmail.com', 'camomille', $account); + $customer = $this->createCustomer( + 'Elodie', + 'Lacour', + 'elodie.lacour@gmail.com', + '06 02 04 04 03', + $account + ); + $token = $this->getJWTToken($user, $client,'camomille'); + $response = $client->request('DELETE', '/api/customers/'.$customer->getId(), [ + 'headers' => [ + 'Content-Type' => 'application/json', + 'Authorization' => 'Bearer '.$token + ] + ]); + $this->assertResponseIsSuccessful(); + $this->assertResponseStatusCodeSame(204); + } public function testUserCannotDeleteCustomerOnDifferentAccount() { + } /** @@ -64,7 +90,13 @@ public function testUserCannotDeleteCustomerWithoutAuth() $container = static::getContainer(); $account = $this->createAccount('charles.laborde@fnac.com'); $user = $this->createUser('louise.vandenbeck@gmail.com', 'banana', $account); - $customer = $this->createCustomer(); + $customer = $this->createCustomer( + 'Kévin', + 'Leblanc', + 'kevin.leblanc@gmail.com', + '07 06 05 04 03', + $account + ); $response = $client->request('DELETE', '/api/customers/'.$customer->getId(), [ 'headers' => [ 'Content-Type' => 'application/json',