refonte
This commit is contained in:
30
src/ApiPlatform/OpenApiFactoryDecorator.php
Normal file
30
src/ApiPlatform/OpenApiFactoryDecorator.php
Normal file
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\ApiPlatform;
|
||||
|
||||
use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
|
||||
use ApiPlatform\OpenApi\Model\SecurityScheme;
|
||||
use ApiPlatform\OpenApi\OpenApi;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
|
||||
#[AsDecorator('api_platform.openapi.factory')]
|
||||
class OpenApiFactoryDecorator implements OpenApiFactoryInterface
|
||||
{
|
||||
|
||||
public function __construct(private OpenApiFactoryInterface $decorated)
|
||||
{
|
||||
}
|
||||
|
||||
public function __invoke(array $context = []): OpenApi
|
||||
{
|
||||
$openApi = $this->decorated->__invoke($context);
|
||||
|
||||
$securitySchemes = $openApi->getComponents()->getSecuritySchemes() ?: new \ArrayObject();
|
||||
$securitySchemes['access_token'] = new SecurityScheme(
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
);
|
||||
|
||||
return $openApi;
|
||||
}
|
||||
}
|
||||
0
src/ApiResource/.gitignore
vendored
Normal file
0
src/ApiResource/.gitignore
vendored
Normal file
0
src/Controller/.gitignore
vendored
Normal file
0
src/Controller/.gitignore
vendored
Normal file
37
src/Controller/SecurityController.php
Normal file
37
src/Controller/SecurityController.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use ApiPlatform\Api\IriConverterInterface;
|
||||
use App\Entity\User;
|
||||
use Exception;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\CurrentUser;
|
||||
|
||||
class SecurityController extends AbstractController
|
||||
{
|
||||
#[Route('/login', name: 'app_login', methods: ['GET', 'POST'])]
|
||||
public function login(IriConverterInterface $iriConverter, #[CurrentUser] User $user = null): Response
|
||||
{
|
||||
if(!$user){
|
||||
return $this->json([
|
||||
'error' => 'Invalid credentials'
|
||||
], 401);
|
||||
}
|
||||
|
||||
return new Response(null, 204, [
|
||||
'Location' => $iriConverter->getIriFromResource($user),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
#[Route('/logout', name: 'app_logout', methods: ['GET'])]
|
||||
public function logout(): void
|
||||
{
|
||||
throw new Exception('This method can be blank.');
|
||||
}
|
||||
}
|
||||
86
src/DataFixtures/AppFixtures.php
Normal file
86
src/DataFixtures/AppFixtures.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\DataFixtures;
|
||||
|
||||
use App\Factory\ApiTokenFactory;
|
||||
use App\Factory\CommentFactory;
|
||||
use App\Factory\CompanyFactory;
|
||||
use App\Factory\DocumentFactory;
|
||||
use App\Factory\FolderFactory;
|
||||
use App\Factory\ProjectDocumentFactory;
|
||||
use App\Factory\ProjectFactory;
|
||||
use App\Factory\UserFactory;
|
||||
use Doctrine\Bundle\FixturesBundle\Fixture;
|
||||
use Doctrine\Persistence\ObjectManager;
|
||||
|
||||
class AppFixtures extends Fixture
|
||||
{
|
||||
public function load(ObjectManager $manager): void
|
||||
{
|
||||
CompanyFactory::createMany(5);
|
||||
UserFactory::createMany(20, function () {
|
||||
return [
|
||||
'company' => CompanyFactory::random()
|
||||
];
|
||||
});
|
||||
ApiTokenFactory::createMany(60, function () {
|
||||
return [
|
||||
'ownedBy' => UserFactory::random()
|
||||
];
|
||||
});
|
||||
$projects = ProjectFactory::createMany(100, function () {
|
||||
return [
|
||||
'ownedBy' => UserFactory::random()
|
||||
];
|
||||
});
|
||||
|
||||
$documents = DocumentFactory::createMany(100);
|
||||
|
||||
foreach ($documents as $document) {
|
||||
ProjectDocumentFactory::createMany(3, function () use ($document) {
|
||||
return [
|
||||
'document' => $document,
|
||||
'project' => ProjectFactory::random()
|
||||
];
|
||||
});
|
||||
|
||||
CommentFactory::createMany(1, function () use ($document) {
|
||||
return [
|
||||
'projectDocument' => ProjectDocumentFactory::random(),
|
||||
'postedBy' => UserFactory::random()
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($projects as $project) {
|
||||
$projectFolder = FolderFactory::createOne([
|
||||
'label' => 'Root Folder Project ' . $project->getId(),
|
||||
'slug' => 'root-folder-project-' . $project->getId(),
|
||||
'project' => $project,
|
||||
'createdBy' => $project->getOwnedBy()
|
||||
]);
|
||||
$child = FolderFactory::createOne([
|
||||
'label' => 'Subfolder - Parent Folder: ' . $projectFolder->getId(),
|
||||
'slug' => 'subfolder-parent-folder-' . $projectFolder->getId(),
|
||||
'createdBy' => $project->getOwnedBy()
|
||||
]);
|
||||
$grandChild = FolderFactory::createOne([
|
||||
'label' => 'Subfolder - Parent Folder: ' . $child->getId(),
|
||||
'slug' => 'subfolder-parent-folder-' . $child->getId(),
|
||||
'createdBy' => $project->getOwnedBy()
|
||||
]);
|
||||
|
||||
$grandChild->addDocument(DocumentFactory::createOne()->object());
|
||||
$grandChild->addDocument(DocumentFactory::createOne()->object());
|
||||
$grandChild->addDocument(DocumentFactory::createOne()->object());
|
||||
|
||||
$child->addChild(
|
||||
$grandChild->object()
|
||||
);
|
||||
|
||||
$projectFolder->addChild(
|
||||
$child->object()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
0
src/Entity/.gitignore
vendored
Normal file
0
src/Entity/.gitignore
vendored
Normal file
107
src/Entity/ApiToken.php
Normal file
107
src/Entity/ApiToken.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Repository\ApiTokenRepository;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Random\RandomException;
|
||||
|
||||
#[ORM\Entity(repositoryClass: ApiTokenRepository::class)]
|
||||
class ApiToken
|
||||
{
|
||||
|
||||
private const string PERSONAL_ACCESS_TOKEN_PREFIX = 'ipm_';
|
||||
public const string SCOPE_USER_EDIT = 'ROLE_USER_EDIT';
|
||||
public const string SCOPE_PROJECT_CREATE = 'ROLE_PROJECT_CREATE';
|
||||
public const string SCOPE_PROJECT_EDIT = 'ROLE_PROJECT_EDIT';
|
||||
|
||||
public const array SCOPES = [
|
||||
self::SCOPE_USER_EDIT => 'Edit user',
|
||||
self::SCOPE_PROJECT_CREATE => 'Create project',
|
||||
self::SCOPE_PROJECT_EDIT => 'Edit project',
|
||||
];
|
||||
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'apiTokens')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private ?User $ownedBy = null;
|
||||
|
||||
#[ORM\Column(nullable: true)]
|
||||
private ?\DateTimeImmutable $expiresAt = null;
|
||||
|
||||
#[ORM\Column(length: 68)]
|
||||
private ?string $token = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private array $scopes = [];
|
||||
|
||||
/**
|
||||
* @throws RandomException
|
||||
*/
|
||||
public function __construct(string $tokenType = self::PERSONAL_ACCESS_TOKEN_PREFIX)
|
||||
{
|
||||
$this->token = $tokenType . bin2hex(random_bytes(32));
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getOwnedBy(): ?User
|
||||
{
|
||||
return $this->ownedBy;
|
||||
}
|
||||
|
||||
public function setOwnedBy(?User $ownedBy): static
|
||||
{
|
||||
$this->ownedBy = $ownedBy;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getExpiresAt(): ?\DateTimeImmutable
|
||||
{
|
||||
return $this->expiresAt;
|
||||
}
|
||||
|
||||
public function setExpiresAt(?\DateTimeImmutable $expiresAt): static
|
||||
{
|
||||
$this->expiresAt = $expiresAt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getToken(): ?string
|
||||
{
|
||||
return $this->token;
|
||||
}
|
||||
|
||||
public function setToken(string $token): static
|
||||
{
|
||||
$this->token = $token;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getScopes(): array
|
||||
{
|
||||
return $this->scopes;
|
||||
}
|
||||
|
||||
public function setScopes(array $scopes): static
|
||||
{
|
||||
$this->scopes = $scopes;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isValid(): bool
|
||||
{
|
||||
return $this->expiresAt === null || $this->expiresAt > new \DateTimeImmutable();
|
||||
}
|
||||
}
|
||||
364
src/Entity/User.php
Normal file
364
src/Entity/User.php
Normal file
@@ -0,0 +1,364 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use ApiPlatform\Metadata\ApiResource;
|
||||
use ApiPlatform\Metadata\Delete;
|
||||
use ApiPlatform\Metadata\Get;
|
||||
use ApiPlatform\Metadata\GetCollection;
|
||||
use ApiPlatform\Metadata\Patch;
|
||||
use ApiPlatform\Metadata\Post;
|
||||
use ApiPlatform\Metadata\Put;
|
||||
use App\Repository\UserRepository;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
use Symfony\Component\Serializer\Annotation\Groups;
|
||||
use Symfony\Component\Serializer\Attribute\SerializedName;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
#[ORM\Entity(repositoryClass: UserRepository::class)]
|
||||
#[ORM\Table(name: '`user`')]
|
||||
#[ApiResource(
|
||||
operations: [
|
||||
new Get(),
|
||||
new GetCollection(),
|
||||
new Post(
|
||||
validationContext: ['groups' => ['Default', 'postValidation']]
|
||||
),
|
||||
new Put(),
|
||||
new Patch(),
|
||||
new Delete()
|
||||
],
|
||||
normalizationContext: ['groups' => ['user:read']],
|
||||
denormalizationContext: ['groups' => ['user:write']],
|
||||
security: "is_granted('ROLE_USER')"
|
||||
)]
|
||||
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
|
||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column]
|
||||
private ?int $id = null;
|
||||
|
||||
#[ORM\Column(length: 180, unique: true)]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
#[Assert\NotBlank]
|
||||
#[Assert\Email]
|
||||
private ?string $email = null;
|
||||
|
||||
#[ORM\Column]
|
||||
private array $roles = [];
|
||||
|
||||
/**
|
||||
* @var ?string The hashed password
|
||||
*/
|
||||
#[ORM\Column]
|
||||
private ?string $password = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $firstName = null;
|
||||
|
||||
#[ORM\Column(length: 255)]
|
||||
#[Groups(['user:read', 'user:write'])]
|
||||
#[Assert\NotBlank]
|
||||
private ?string $lastName = null;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'ownedBy', targetEntity: ApiToken::class)]
|
||||
private Collection $apiTokens;
|
||||
|
||||
private ?array $accessTokenScopes = null;
|
||||
|
||||
#[ORM\ManyToOne(inversedBy: 'employees')]
|
||||
#[ORM\JoinColumn(nullable: true, onDelete: 'SET NULL')]
|
||||
#[Groups(['user:read'])]
|
||||
private ?Company $company = null;
|
||||
|
||||
#[Groups(['user:write'])]
|
||||
#[SerializedName('password')]
|
||||
#[Assert\NotBlank(groups: ['postValidation'])]
|
||||
private ?string $plainPassword = null;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'ownedBy', targetEntity: Project::class)]
|
||||
#[Groups(['user:read'])]
|
||||
private Collection $projects;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'postedBy', targetEntity: Comment::class)]
|
||||
private Collection $comments;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'createdBy', targetEntity: Folder::class)]
|
||||
private Collection $folders;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->apiTokens = new ArrayCollection();
|
||||
$this->projects = new ArrayCollection();
|
||||
$this->comments = new ArrayCollection();
|
||||
$this->folders = new ArrayCollection();
|
||||
}
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getEmail(): ?string
|
||||
{
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function setEmail(string $email): static
|
||||
{
|
||||
$this->email = $email;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* A visual identifier that represents this user.
|
||||
*
|
||||
* @see UserInterface
|
||||
*/
|
||||
public function getUserIdentifier(): string
|
||||
{
|
||||
return (string) $this->email;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see UserInterface
|
||||
*/
|
||||
public function getRoles(): array
|
||||
{
|
||||
if($this->accessTokenScopes !== null){
|
||||
$roles = $this->roles;
|
||||
$roles[] = 'ROLE_FRONT_USER';
|
||||
}else{
|
||||
$roles = $this->accessTokenScopes;
|
||||
}
|
||||
|
||||
// guarantee every user at least has ROLE_USER
|
||||
$roles[] = 'ROLE_USER';
|
||||
|
||||
return array_unique($roles);
|
||||
}
|
||||
|
||||
public function setRoles(array $roles): static
|
||||
{
|
||||
$this->roles = $roles;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see PasswordAuthenticatedUserInterface
|
||||
*/
|
||||
public function getPassword(): string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function setPassword(string $password): static
|
||||
{
|
||||
$this->password = $password;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @see UserInterface
|
||||
*/
|
||||
public function eraseCredentials(): void
|
||||
{
|
||||
// If you store any temporary, sensitive data on the user, clear it here
|
||||
$this->plainPassword = null;
|
||||
}
|
||||
|
||||
public function getFirstName(): ?string
|
||||
{
|
||||
return $this->firstName;
|
||||
}
|
||||
|
||||
public function setFirstName(string $firstName): static
|
||||
{
|
||||
$this->firstName = $firstName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getLastName(): ?string
|
||||
{
|
||||
return $this->lastName;
|
||||
}
|
||||
|
||||
public function setLastName(string $lastName): static
|
||||
{
|
||||
$this->lastName = $lastName;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, ApiToken>
|
||||
*/
|
||||
public function getApiTokens(): Collection
|
||||
{
|
||||
return $this->apiTokens;
|
||||
}
|
||||
|
||||
public function addApiToken(ApiToken $apiToken): static
|
||||
{
|
||||
if (!$this->apiTokens->contains($apiToken)) {
|
||||
$this->apiTokens->add($apiToken);
|
||||
$apiToken->setOwnedBy($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeApiToken(ApiToken $apiToken): static
|
||||
{
|
||||
if ($this->apiTokens->removeElement($apiToken)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($apiToken->getOwnedBy() === $this) {
|
||||
$apiToken->setOwnedBy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[]
|
||||
*/
|
||||
#[Groups(['user:read'])]
|
||||
public function getValidTokenStrings(): array
|
||||
{
|
||||
return $this->getApiTokens()
|
||||
->filter(fn (ApiToken $token) => $token->isValid())
|
||||
->map(fn (ApiToken $token) => $token->getToken())
|
||||
->toArray();
|
||||
}
|
||||
|
||||
public function markAsTokenAuthenticated(array $scopes): void
|
||||
{
|
||||
$this->accessTokenScopes = $scopes;
|
||||
}
|
||||
|
||||
public function getCompany(): ?Company
|
||||
{
|
||||
return $this->company;
|
||||
}
|
||||
|
||||
public function setCompany(?Company $company): static
|
||||
{
|
||||
$this->company = $company;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPlainPassword(): ?string
|
||||
{
|
||||
return $this->plainPassword;
|
||||
}
|
||||
|
||||
public function setPlainPassword(string $plainPassword): void
|
||||
{
|
||||
$this->plainPassword = $plainPassword;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Project>
|
||||
*/
|
||||
public function getProjects(): Collection
|
||||
{
|
||||
return $this->projects;
|
||||
}
|
||||
|
||||
public function addProject(Project $project): static
|
||||
{
|
||||
if (!$this->projects->contains($project)) {
|
||||
$this->projects->add($project);
|
||||
$project->setOwnedBy($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeProject(Project $project): static
|
||||
{
|
||||
if ($this->projects->removeElement($project)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($project->getOwnedBy() === $this) {
|
||||
$project->setOwnedBy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Comment>
|
||||
*/
|
||||
public function getComments(): Collection
|
||||
{
|
||||
return $this->comments;
|
||||
}
|
||||
|
||||
public function addComment(Comment $comment): static
|
||||
{
|
||||
if (!$this->comments->contains($comment)) {
|
||||
$this->comments->add($comment);
|
||||
$comment->setPostedBy($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeComment(Comment $comment): static
|
||||
{
|
||||
if ($this->comments->removeElement($comment)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($comment->getPostedBy() === $this) {
|
||||
$comment->setPostedBy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<int, Folder>
|
||||
*/
|
||||
public function getFolders(): Collection
|
||||
{
|
||||
return $this->folders;
|
||||
}
|
||||
|
||||
public function addFolder(Folder $folder): static
|
||||
{
|
||||
if (!$this->folders->contains($folder)) {
|
||||
$this->folders->add($folder);
|
||||
$folder->setCreatedBy($this);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function removeFolder(Folder $folder): static
|
||||
{
|
||||
if ($this->folders->removeElement($folder)) {
|
||||
// set the owning side to null (unless already changed)
|
||||
if ($folder->getCreatedBy() === $this) {
|
||||
$folder->setCreatedBy(null);
|
||||
}
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
49
src/EventListener/ExceptionListener.php
Normal file
49
src/EventListener/ExceptionListener.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventListener;
|
||||
|
||||
use ApiPlatform\Exception\ItemNotFoundException;
|
||||
use ApiPlatform\Symfony\Validator\Exception\ValidationException;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Component\HttpFoundation\Exception\BadRequestException;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
|
||||
use ApiPlatform\Exception\FilterValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
use Symfony\Component\Serializer\Exception\NotNormalizableValueException;
|
||||
|
||||
class ExceptionListener
|
||||
{
|
||||
public function __construct(private LoggerInterface $logger)
|
||||
{
|
||||
}
|
||||
|
||||
public function onKernelException(ExceptionEvent $event): void
|
||||
{
|
||||
$exception = $event->getThrowable();
|
||||
|
||||
$response = match(true) {
|
||||
$exception instanceof FilterValidationException,
|
||||
$exception instanceof BadRequestException => $this->createResponse($exception, Response::HTTP_BAD_REQUEST),
|
||||
$exception instanceof NotFoundHttpException,
|
||||
$exception instanceof ItemNotFoundException => $this->createResponse($exception, Response::HTTP_NOT_FOUND),
|
||||
$exception instanceof AccessDeniedHttpException => $this->createResponse($exception, Response::HTTP_FORBIDDEN),
|
||||
$exception instanceof ValidationException,
|
||||
$exception instanceof NotNormalizableValueException => $this->createResponse($exception, Response::HTTP_UNPROCESSABLE_ENTITY),
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($response) {
|
||||
$event->setResponse($response);
|
||||
}else{
|
||||
$this->logger->error($exception->getMessage(), ['exception' => $exception]);
|
||||
}
|
||||
}
|
||||
|
||||
private function createResponse(\Throwable $exception, int $statusCode): Response
|
||||
{
|
||||
$this->logger->info($exception->getMessage(), ['exception' => $exception]);
|
||||
return new Response(json_encode(['message' => $exception->getMessage()]), $statusCode, ['Content-Type' => 'application/json']);
|
||||
}
|
||||
}
|
||||
23
src/EventSubscriber/LogoutSubscriber.php
Normal file
23
src/EventSubscriber/LogoutSubscriber.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\EventSubscriber;
|
||||
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Security\Http\Event\LogoutEvent;
|
||||
|
||||
class LogoutSubscriber implements EventSubscriberInterface
|
||||
{
|
||||
public function onLogoutEvent(LogoutEvent $event): void
|
||||
{
|
||||
$response = new Response(null, 204);
|
||||
$event->setResponse($response);
|
||||
}
|
||||
|
||||
public static function getSubscribedEvents(): array
|
||||
{
|
||||
return [
|
||||
LogoutEvent::class => 'onLogoutEvent',
|
||||
];
|
||||
}
|
||||
}
|
||||
69
src/Factory/ApiTokenFactory.php
Normal file
69
src/Factory/ApiTokenFactory.php
Normal file
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Factory;
|
||||
|
||||
use App\Entity\ApiToken;
|
||||
use App\Repository\ApiTokenRepository;
|
||||
use Zenstruck\Foundry\ModelFactory;
|
||||
use Zenstruck\Foundry\Proxy;
|
||||
use Zenstruck\Foundry\RepositoryProxy;
|
||||
|
||||
/**
|
||||
* @extends ModelFactory<ApiToken>
|
||||
*
|
||||
* @method ApiToken|Proxy create(array|callable $attributes = [])
|
||||
* @method static ApiToken|Proxy createOne(array $attributes = [])
|
||||
* @method static ApiToken|Proxy find(object|array|mixed $criteria)
|
||||
* @method static ApiToken|Proxy findOrCreate(array $attributes)
|
||||
* @method static ApiToken|Proxy first(string $sortedField = 'id')
|
||||
* @method static ApiToken|Proxy last(string $sortedField = 'id')
|
||||
* @method static ApiToken|Proxy random(array $attributes = [])
|
||||
* @method static ApiToken|Proxy randomOrCreate(array $attributes = [])
|
||||
* @method static ApiTokenRepository|RepositoryProxy repository()
|
||||
* @method static ApiToken[]|Proxy[] all()
|
||||
* @method static ApiToken[]|Proxy[] createMany(int $number, array|callable $attributes = [])
|
||||
* @method static ApiToken[]|Proxy[] createSequence(iterable|callable $sequence)
|
||||
* @method static ApiToken[]|Proxy[] findBy(array $attributes)
|
||||
* @method static ApiToken[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
|
||||
* @method static ApiToken[]|Proxy[] randomSet(int $number, array $attributes = [])
|
||||
*/
|
||||
final class ApiTokenFactory extends ModelFactory
|
||||
{
|
||||
/**
|
||||
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
|
||||
*
|
||||
* @todo inject services if required
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
|
||||
*
|
||||
* @todo add your default values here
|
||||
*/
|
||||
protected function getDefaults(): array
|
||||
{
|
||||
return [
|
||||
'ownedBy' => UserFactory::new(),
|
||||
'scopes' => [],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
|
||||
*/
|
||||
protected function initialize(): self
|
||||
{
|
||||
return $this
|
||||
// ->afterInstantiate(function(ApiToken $apiToken): void {})
|
||||
;
|
||||
}
|
||||
|
||||
protected static function getClass(): string
|
||||
{
|
||||
return ApiToken::class;
|
||||
}
|
||||
}
|
||||
80
src/Factory/UserFactory.php
Normal file
80
src/Factory/UserFactory.php
Normal file
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
namespace App\Factory;
|
||||
|
||||
use App\Entity\User;
|
||||
use App\Repository\UserRepository;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
use Zenstruck\Foundry\ModelFactory;
|
||||
use Zenstruck\Foundry\Proxy;
|
||||
use Zenstruck\Foundry\RepositoryProxy;
|
||||
|
||||
/**
|
||||
* @extends ModelFactory<User>
|
||||
*
|
||||
* @method User|Proxy create(array|callable $attributes = [])
|
||||
* @method static User|Proxy createOne(array $attributes = [])
|
||||
* @method static User|Proxy find(object|array|mixed $criteria)
|
||||
* @method static User|Proxy findOrCreate(array $attributes)
|
||||
* @method static User|Proxy first(string $sortedField = 'id')
|
||||
* @method static User|Proxy last(string $sortedField = 'id')
|
||||
* @method static User|Proxy random(array $attributes = [])
|
||||
* @method static User|Proxy randomOrCreate(array $attributes = [])
|
||||
* @method static UserRepository|RepositoryProxy repository()
|
||||
* @method static User[]|Proxy[] all()
|
||||
* @method static User[]|Proxy[] createMany(int $number, array|callable $attributes = [])
|
||||
* @method static User[]|Proxy[] createSequence(iterable|callable $sequence)
|
||||
* @method static User[]|Proxy[] findBy(array $attributes)
|
||||
* @method static User[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
|
||||
* @method static User[]|Proxy[] randomSet(int $number, array $attributes = [])
|
||||
*/
|
||||
final class UserFactory extends ModelFactory
|
||||
{
|
||||
const array FIRST_NAMES = ["ALAIN", "ALEXANDRE", "ANDRÉ", "ANNIE", "ANTHONY", "AUDREY", "AURÉLIE", "BERNARD", "BRIGITTE", "BRUNO", "CATHERINE", "CEDRIC", "CHANTAL", "CHRISTELLE", "CHRISTIAN", "CHRISTIANE", "CHRISTINE", "CHRISTOPHE", "CLAUDE", "CORINNE", "CÉLINE", "DANIEL", "DANIELLE", "DAVID", "DENISE", "DIDIER", "DOMINIQUE", "ELODIE", "EMILIE", "ENZO", "ERIC", "FABRICE", "FLORENCE", "FRANCK", "FRANÇOISE", "FRÉDÉRIC", "GEORGES", "GERMAINE", "GUILLAUME", "GUY", "GÉRARD", "HENRI", "ISABELLE", "JACQUELINE", "JACQUES", "JEAN", "JEAN-CLAUDE", "JEAN-PIERRE", "JEANNE", "JEANNINE", "JEREMY", "JEROME", "JONATHAN", "JOSEPH", "JULIE", "JULIEN", "KARINE", "KEVIN", "LAETITIA", "LAURA", "LAURENCE", "LAURENT", "LOUIS", "LUCAS", "LÉA", "MADELEINE", "MANON", "MARCEL", "MARCELLE", "MARGUERITE", "MARIE", "MARINE", "MARTINE", "MAURICE", "MAXIME", "MICHEL", "MICHÈLE", "MONIQUE", "NATHALIE", "NICOLAS", "NICOLE", "ODETTE", "OLIVIER", "PASCAL", "PASCALE", "PATRICIA", "PATRICK", "PAUL", "PAULETTE", "PHILIPPE", "PIERRE", "RENÉ", "ROBERT", "ROGER", "ROMAIN", "SANDRA", "SANDRINE", "SERGE", "SOPHIE", "STÉPHANE", "STÉPHANIE", "SUZANNE", "SYLVIE", "SÉBASTIEN", "THIERRY", "THOMAS", "THÉO", "VALÉRIE", "VIRGINIE", "VÉRONIQUE", "YVETTE", "YVONNE"];
|
||||
const array LAST_NAMES = ["Adam", "Andre", "Antoine", "Arnaud", "Aubert", "Aubry", "Bailly", "Barbier", "Baron", "Barre", "Barthelemy", "Benard", "Benoit", "Berger", "Bernard", "Bertin", "Bertrand", "Besson", "Blanc", "Blanchard", "Bonnet", "Boucher", "Bouchet", "Boulanger", "Bourgeois", "Bouvier", "Boyer", "Breton", "Brun", "Brunet", "Carlier", "Caron", "Carpentier", "Carre", "Charles", "Charpentier", "Chauvin", "Chevalier", "Chevallier", "Clement", "Colin", "Collet", "Collin", "Cordier", "Cousin", "Da Silva", "Daniel", "David", "Delaunay", "Denis", "Deschamps", "Dubois", "Dufour", "Dumas", "Dumont", "Dupont", "Dupuis", "Dupuy", "Durand", "Duval", "Etienne", "Fabre", "Faure", "Fernandez", "Fleury", "Fontaine", "Fournier", "Francois", "Gaillard", "Garcia", "Garnier", "Gauthier", "Gautier", "Gay", "Gerard", "Germain", "Gilbert", "Gillet", "Girard", "Giraud", "Gonzalez", "Grondin", "Guerin", "Guichard", "Guillaume", "Guillot", "Guyot", "Hamon", "Henry", "Herve", "Hoarau", "Hubert", "Huet", "Humbert", "Jacob", "Jacquet", "Jean", "Joly", "Julien", "Klein", "Lacroix", "Lambert", "Lamy", "Langlois", "Laporte", "Laurent", "Le Gall", "Le Goff", "Le Roux", "Leblanc", "Lebrun", "Leclerc", "Leclercq", "Lecomte", "Lefebvre", "Lefevre", "Leger", "Legrand", "Lejeune", "Lemaire", "Lemaitre", "Lemoine", "Leroux", "Leroy", "Leveque", "Lopez", "Louis", "Lucas", "Maillard", "Mallet", "Marchal", "Marchand", "Marechal", "Marie", "Martin", "Martinez", "Marty", "Masson", "Mathieu", "Menard", "Mercier", "Meunier", "Meyer", "Michaud", "Michel", "Millet", "Monnier", "Moreau", "Morel", "Morin", "Moulin", "Muller", "Nicolas", "Noel", "Olivier", "Paris", "Pasquier", "Payet", "Pelletier", "Perez", "Perret", "Perrier", "Perrin", "Perrot", "Petit", "Philippe", "Picard", "Pichon", "Pierre", "Poirier", "Poulain", "Prevost", "Remy", "Renard", "Renaud", "Renault", "Rey", "Reynaud", "Richard", "Riviere", "Robert", "Robin", "Roche", "Rodriguez", "Roger", "Rolland", "Rousseau", "Roussel", "Roux", "Roy", "Royer", "Sanchez", "Schmitt", "Schneider", "Simon", "Tessier", "Thomas", "Vasseur", "Vidal", "Vincent", "Weber"];
|
||||
|
||||
/**
|
||||
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#factories-as-services
|
||||
*
|
||||
*/
|
||||
public function __construct(private readonly UserPasswordHasherInterface $passwordHasher)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#model-factories
|
||||
*
|
||||
*/
|
||||
protected function getDefaults(): array
|
||||
{
|
||||
return [
|
||||
'email' => self::faker()->unique()->email(),
|
||||
'password' => 'password',
|
||||
'roles' => [],
|
||||
'firstName' => self::faker()->randomElement(self::FIRST_NAMES),
|
||||
'lastName' => self::faker()->randomElement(self::LAST_NAMES),
|
||||
'company' => CompanyFactory::randomOrCreate(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @see https://symfony.com/bundles/ZenstruckFoundryBundle/current/index.html#initialization
|
||||
*/
|
||||
protected function initialize(): self
|
||||
{
|
||||
return $this
|
||||
->afterInstantiate(function(User $user): void {
|
||||
$user->setPassword($this->passwordHasher->hashPassword(
|
||||
$user,
|
||||
$user->getPassword()
|
||||
));
|
||||
})
|
||||
;
|
||||
}
|
||||
|
||||
protected static function getClass(): string
|
||||
{
|
||||
return User::class;
|
||||
}
|
||||
}
|
||||
35
src/Kernel.php
Normal file
35
src/Kernel.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Override;
|
||||
use Doctrine\ORM\Mapping\ClassMetadataInfo;
|
||||
use Doctrine\DBAL\Platforms\PostgreSQLPlatform;
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
|
||||
use Symfony\Component\DependencyInjection\ContainerBuilder;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
|
||||
#[Override]
|
||||
protected function build(ContainerBuilder $container): void
|
||||
{
|
||||
$container->addCompilerPass(new class() implements CompilerPassInterface {
|
||||
public function process(ContainerBuilder $container): void
|
||||
{
|
||||
$container->getDefinition('doctrine.orm.default_configuration')
|
||||
->addMethodCall(
|
||||
'setIdentityGenerationPreferences',
|
||||
[
|
||||
[
|
||||
PostgreSQLPlatform::class => ClassMetadataInfo::GENERATOR_TYPE_SEQUENCE,
|
||||
],
|
||||
]
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
0
src/Repository/.gitignore
vendored
Normal file
0
src/Repository/.gitignore
vendored
Normal file
48
src/Repository/ApiTokenRepository.php
Normal file
48
src/Repository/ApiTokenRepository.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\ApiToken;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<ApiToken>
|
||||
*
|
||||
* @method ApiToken|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method ApiToken|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method ApiToken[] findAll()
|
||||
* @method ApiToken[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class ApiTokenRepository extends ServiceEntityRepository
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, ApiToken::class);
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @return ApiToken[] Returns an array of ApiToken objects
|
||||
// */
|
||||
// public function findByExampleField($value): array
|
||||
// {
|
||||
// return $this->createQueryBuilder('a')
|
||||
// ->andWhere('a.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->orderBy('a.id', 'ASC')
|
||||
// ->setMaxResults(10)
|
||||
// ->getQuery()
|
||||
// ->getResult()
|
||||
// ;
|
||||
// }
|
||||
|
||||
// public function findOneBySomeField($value): ?ApiToken
|
||||
// {
|
||||
// return $this->createQueryBuilder('a')
|
||||
// ->andWhere('a.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->getQuery()
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
67
src/Repository/UserRepository.php
Normal file
67
src/Repository/UserRepository.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\User;
|
||||
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||
use Doctrine\Persistence\ManagerRegistry;
|
||||
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
||||
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
|
||||
use Symfony\Component\Security\Core\User\PasswordUpgraderInterface;
|
||||
|
||||
/**
|
||||
* @extends ServiceEntityRepository<User>
|
||||
*
|
||||
* @implements PasswordUpgraderInterface<User>
|
||||
*
|
||||
* @method User|null find($id, $lockMode = null, $lockVersion = null)
|
||||
* @method User|null findOneBy(array $criteria, array $orderBy = null)
|
||||
* @method User[] findAll()
|
||||
* @method User[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
|
||||
*/
|
||||
class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface
|
||||
{
|
||||
public function __construct(ManagerRegistry $registry)
|
||||
{
|
||||
parent::__construct($registry, User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to upgrade (rehash) the user's password automatically over time.
|
||||
*/
|
||||
public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void
|
||||
{
|
||||
if (!$user instanceof User) {
|
||||
throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class));
|
||||
}
|
||||
|
||||
$user->setPassword($newHashedPassword);
|
||||
$this->getEntityManager()->persist($user);
|
||||
$this->getEntityManager()->flush();
|
||||
}
|
||||
|
||||
// /**
|
||||
// * @return User[] Returns an array of User objects
|
||||
// */
|
||||
// public function findByExampleField($value): array
|
||||
// {
|
||||
// return $this->createQueryBuilder('u')
|
||||
// ->andWhere('u.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->orderBy('u.id', 'ASC')
|
||||
// ->setMaxResults(10)
|
||||
// ->getQuery()
|
||||
// ->getResult()
|
||||
// ;
|
||||
// }
|
||||
|
||||
// public function findOneBySomeField($value): ?User
|
||||
// {
|
||||
// return $this->createQueryBuilder('u')
|
||||
// ->andWhere('u.exampleField = :val')
|
||||
// ->setParameter('val', $value)
|
||||
// ->getQuery()
|
||||
// ->getOneOrNullResult()
|
||||
// ;
|
||||
// }
|
||||
}
|
||||
35
src/Security/ApiTokenHandler.php
Normal file
35
src/Security/ApiTokenHandler.php
Normal file
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Security;
|
||||
|
||||
use App\Repository\ApiTokenRepository;
|
||||
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
|
||||
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
|
||||
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
|
||||
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
|
||||
|
||||
readonly class ApiTokenHandler implements AccessTokenHandlerInterface
|
||||
{
|
||||
|
||||
public function __construct(private ApiTokenRepository $apiTokenRepository)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public function getUserBadgeFrom(#[\SensitiveParameter] string $accessToken): UserBadge
|
||||
{
|
||||
$token = $this->apiTokenRepository->findOneBy(['token' => $accessToken]);
|
||||
|
||||
if(!$token) {
|
||||
throw new BadCredentialsException();
|
||||
}
|
||||
|
||||
if(!$token->isValid()){
|
||||
throw new CustomUserMessageAuthenticationException('Token expired');
|
||||
}
|
||||
|
||||
$token->getOwnedBy()->markAsTokenAuthenticated($token->getScopes());
|
||||
|
||||
return new UserBadge($token->getOwnedBy()->getUserIdentifier());
|
||||
}
|
||||
}
|
||||
26
src/State/UserHashPasswordProcessor.php
Normal file
26
src/State/UserHashPasswordProcessor.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\State;
|
||||
|
||||
use ApiPlatform\Metadata\Operation;
|
||||
use ApiPlatform\State\ProcessorInterface;
|
||||
use App\Entity\User;
|
||||
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||
|
||||
#[AsDecorator('api_platform.doctrine.orm.state.persist_processor')]
|
||||
readonly class UserHashPasswordProcessor implements ProcessorInterface
|
||||
{
|
||||
public function __construct(private ProcessorInterface $decoratedProcessor, private UserPasswordHasherInterface $passwordHasher)
|
||||
{
|
||||
}
|
||||
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
|
||||
{
|
||||
if($data instanceof User && $data->getPlainPassword() !== null) {
|
||||
$data->setPassword($this->passwordHasher->hashPassword($data, $data->getPlainPassword()));
|
||||
}
|
||||
|
||||
$this->decoratedProcessor->process($data, $operation, $uriVariables, $context);
|
||||
return $data;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user