- Added support for authenticating via access tokens

- update symfony to 6.4.*
- some other minor stuff
This commit is contained in:
Skylar Sadlier 2024-02-12 23:37:24 -07:00
parent 4a1c6e1a72
commit 7c45f64a73
21 changed files with 5022 additions and 8217 deletions

View File

@ -8,42 +8,44 @@
"ext-ctype": "*",
"ext-iconv": "*",
"ext-intl": "*",
"doctrine/annotations": "^2.0",
"doctrine/doctrine-bundle": "^2.10",
"doctrine/doctrine-migrations-bundle": "^3.2",
"doctrine/orm": "^2.16",
"gedmo/doctrine-extensions": "^3.13",
"phpdocumentor/reflection-docblock": "^5.3",
"phpstan/phpdoc-parser": "^1.24",
"symfony/asset": "6.3.*",
"symfony/console": "6.3.*",
"symfony/doctrine-messenger": "6.3.*",
"symfony/dotenv": "6.3.*",
"symfony/expression-language": "6.3.*",
"symfony/asset": "6.4.*",
"symfony/console": "6.4.*",
"symfony/doctrine-messenger": "6.4.*",
"symfony/dotenv": "6.4.*",
"symfony/expression-language": "6.4.*",
"symfony/flex": "^2",
"symfony/form": "6.3.*",
"symfony/framework-bundle": "6.3.*",
"symfony/http-client": "6.3.*",
"symfony/intl": "6.3.*",
"symfony/lock": "6.3.*",
"symfony/mailer": "6.3.*",
"symfony/mime": "6.3.*",
"symfony/form": "6.4.*",
"symfony/framework-bundle": "6.4.*",
"symfony/http-client": "6.4.*",
"symfony/intl": "6.4.*",
"symfony/lock": "6.4.*",
"symfony/mailer": "6.4.*",
"symfony/mime": "6.4.*",
"symfony/monolog-bundle": "^3.0",
"symfony/notifier": "6.3.*",
"symfony/process": "6.3.*",
"symfony/property-access": "6.3.*",
"symfony/property-info": "6.3.*",
"symfony/rate-limiter": "6.3.*",
"symfony/runtime": "6.3.*",
"symfony/security-bundle": "6.3.*",
"symfony/serializer": "6.3.*",
"symfony/notifier": "6.4.*",
"symfony/process": "6.4.*",
"symfony/property-access": "6.4.*",
"symfony/property-info": "6.4.*",
"symfony/rate-limiter": "6.4.*",
"symfony/runtime": "6.4.*",
"symfony/security-bundle": "6.4.*",
"symfony/serializer": "6.4.*",
"symfony/stimulus-bundle": "^2.11",
"symfony/string": "6.3.*",
"symfony/translation": "6.3.*",
"symfony/twig-bundle": "6.3.*",
"symfony/validator": "6.3.*",
"symfony/web-link": "6.3.*",
"symfony/string": "6.4.*",
"symfony/translation": "6.4.*",
"symfony/twig-bundle": "6.4.*",
"symfony/uid": "6.4.*",
"symfony/validator": "6.4.*",
"symfony/web-link": "6.4.*",
"symfony/webpack-encore-bundle": "^2.0",
"symfony/yaml": "6.3.*",
"symfony/yaml": "6.4.*",
"twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0"
},
@ -92,17 +94,17 @@
"extra": {
"symfony": {
"allow-contrib": false,
"require": "6.3.*"
"require": "6.4.*"
}
},
"require-dev": {
"phpunit/phpunit": "^9.5",
"symfony/browser-kit": "6.3.*",
"symfony/css-selector": "6.3.*",
"symfony/debug-bundle": "6.3.*",
"symfony/browser-kit": "6.4.*",
"symfony/css-selector": "6.4.*",
"symfony/debug-bundle": "6.4.*",
"symfony/maker-bundle": "^1.0",
"symfony/phpunit-bridge": "^6.3",
"symfony/stopwatch": "6.3.*",
"symfony/web-profiler-bundle": "6.3.*"
"symfony/stopwatch": "6.4.*",
"symfony/web-profiler-bundle": "6.4.*"
}
}

2419
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -20,6 +20,7 @@ services:
$globalFactory: '@limiter.ip_login'
# localFactory is the limiter for username+IP
$localFactory: '@limiter.username_ip_login'
$secret: '%env(APP_SECRET)%'
security:
# https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords
@ -36,6 +37,14 @@ security:
dev:
pattern: ^/(_(profiler|wdt)|css|images|js)/
security: false
api:
pattern: ^/api/
provider: app_user_provider
access_denied_handler: App\Security\AccessTokenDeniedHandler
access_token:
token_handler: App\Security\AccessTokenHandler
failure_handler: App\Security\AccessTokenDeniedHandler
main:
lazy: true
provider: app_user_provider

4
config/packages/uid.yaml Normal file
View File

@ -0,0 +1,4 @@
framework:
uid:
default_uuid_version: 7
time_based_uuid_version: 7

View File

@ -27,3 +27,8 @@ services:
- { name: doctrine.event_subscriber, connection: default }
calls:
- [ setAnnotationReader, [ '@annotation_reader' ] ]
Symfony\Component\Uid\Command\GenerateUlidCommand: ~
Symfony\Component\Uid\Command\GenerateUuidCommand: ~
Symfony\Component\Uid\Command\InspectUlidCommand: ~
Symfony\Component\Uid\Command\InspectUuidCommand: ~

View File

@ -4,7 +4,7 @@ services:
###> doctrine/doctrine-bundle ###
database:
ports:
- "5432"
- "5432:5432"
###< doctrine/doctrine-bundle ###
###> symfony/mailer ###

View File

@ -4,6 +4,7 @@ namespace App\Command;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Utils\UserValidator;
use Doctrine\ORM\EntityManagerInterface;
use Exception;
use Symfony\Component\Console\Attribute\AsCommand;
@ -51,7 +52,7 @@ final class AddUserCommand extends Command
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly ValidatorInterface $validator,
private readonly UserValidator $validator,
private readonly UserRepository $users
) {
parent::__construct();

View File

View File

@ -0,0 +1,21 @@
<?php
namespace App\Controller;
use App\Repository\DomainRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class DomainApiController extends AbstractController
{
#[Route('/api/domains', name: 'app_api_get_domains', methods: ['get'])]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function index(DomainRepository $domainRepository): JsonResponse
{
return new JsonResponse([
'domains' => $domainRepository->findAll()
]);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Controller;
use App\Entity\Domain;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class DomainCommentController extends AbstractController
{
#[Route('/api/domain/{id}/comment', name: 'app_api_comment_add')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function addCommentAPI(ValidatorInterface $validator)
{
$validator->validate([
'' => new NotBlank()
]);
}
#[Route('/domain/{id}/comment', name: 'app_domain_comments')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
public function index(Domain $domain): Response
{
return $this->render('domain_comment/index.html.twig', [
'domain' => $domain,
'controller_name' => 'DomainCommentController',
]);
}
}

View File

@ -14,9 +14,14 @@ use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Component\String\ByteString;
#[Route('/domain')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class DomainController extends AbstractController
{
#[Route('/', name: 'app_api_domain_list', methods: ['GET'])]
public function listCommentsAPI()
{
}
#[Route('/', name: 'app_domain_index', methods: ['GET'])]
public function index(DomainRepository $domainRepository): Response
{

View File

@ -0,0 +1,122 @@
<?php
namespace App\Entity;
use App\Repository\UserAccessTokenRepository;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Types\UlidType;
use Symfony\Component\Uid\Ulid;
#[ORM\Entity(repositoryClass: UserAccessTokenRepository::class)]
class UserAccessToken
{
#[ORM\Id]
#[ORM\Column(type: UlidType::NAME, unique: true)]
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
#[ORM\CustomIdGenerator(class: 'doctrine.ulid_generator')]
private ?string $id = null;
#[ORM\Column(length: 255)]
private ?string $token = null;
#[ORM\Column]
private ?\DateTimeImmutable $createdAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $updatedAt = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $deletedAt = null;
#[ORM\ManyToOne]
#[ORM\JoinColumn(nullable: false)]
private ?User $owner = null;
#[ORM\Column(type: Types::DATETIME_MUTABLE, nullable: true)]
private ?\DateTimeInterface $expiresAt = null;
public function getId(): ?int
{
return $this->id;
}
public function setId(Ulid $id): static
{
$this->id = $id;
return $this;
}
public function getToken(): ?string
{
return $this->token;
}
public function setToken(string $token): static
{
$this->token = $token;
return $this;
}
public function getCreatedAt(): ?\DateTimeImmutable
{
return $this->createdAt;
}
public function setCreatedAt(\DateTimeImmutable $createdAt): static
{
$this->createdAt = $createdAt;
return $this;
}
public function getUpdatedAt(): ?\DateTimeInterface
{
return $this->updatedAt;
}
public function setUpdatedAt(?\DateTimeInterface $updatedAt): static
{
$this->updatedAt = $updatedAt;
return $this;
}
public function getDeletedAt(): ?\DateTimeInterface
{
return $this->deletedAt;
}
public function setDeletedAt(?\DateTimeInterface $deletedAt): static
{
$this->deletedAt = $deletedAt;
return $this;
}
public function getOwner(): ?User
{
return $this->owner;
}
public function setOwner(?User $owner): static
{
$this->owner = $owner;
return $this;
}
public function getExpiresAt(): ?\DateTimeInterface
{
return $this->expiresAt;
}
public function setExpiresAt(?\DateTimeInterface $expiresAt): static
{
$this->expiresAt = $expiresAt;
return $this;
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CredentialsExpiredException;
final class ApiAuthListener
{
#[AsEventListener(event: KernelEvents::EXCEPTION, priority: 2)]
public function onKernelException(ExceptionEvent $event): void
{
$exception = $event->getThrowable();
if (!$exception instanceof BadCredentialsException && !$exception instanceof CredentialsExpiredException) {
return;
}
// ... perform some action (e.g. logging)
if($event->getRequest()->isXmlHttpRequest()) {
$event->setResponse(new JsonResponse(['error' => $exception->getMessage()], 401));
}
// optionally set the custom response
// or stop propagation (prevents the next exception listeners from being called)
// $event->stopPropagation();
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Repository;
use App\Entity\UserAccessToken;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
/**
* @extends ServiceEntityRepository<UserAccessTokenRepository>
*
* @method UserAccessTokenRepository|null find($id, $lockMode = null, $lockVersion = null)
* @method UserAccessTokenRepository|null findOneBy(array $criteria, array $orderBy = null)
* @method UserAccessTokenRepository[] findAll()
* @method UserAccessTokenRepository[] findBy(array $criteria, array $orderBy = null, $limit = null, $offset = null)
*/
class UserAccessTokenRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, UserAccessToken::class);
}
// /**
// * @return UserAccessToken[] Returns an array of UserAccessToken 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): ?UserAccessToken
// {
// return $this->createQueryBuilder('u')
// ->andWhere('u.exampleField = :val')
// ->setParameter('val', $value)
// ->getQuery()
// ->getOneOrNullResult()
// ;
// }
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Security;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
use Symfony\Component\Security\Http\Authentication\AuthenticationFailureHandlerInterface;
use Symfony\Component\Security\Http\Authorization\AccessDeniedHandlerInterface;
class AccessTokenDeniedHandler implements AccessDeniedHandlerInterface, AuthenticationFailureHandlerInterface
{
public function handle(Request $request, AccessDeniedException $accessDeniedException): ?Response
{
return new Response('AccessTokenDeniedHandler', 403);
}
public function onAuthenticationFailure(Request $request, AuthenticationException $exception): Response
{
return new JsonResponse([
'result' => 'error',
'error' => $exception->getMessage()
]);
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Security;
use App\Entity\UserAccessToken;
use App\Repository\UserAccessTokenRepository;
use App\Repository\UserRepository;
use Symfony\Component\Security\Core\Exception\BadCredentialsException;
use Symfony\Component\Security\Core\Exception\CredentialsExpiredException;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class AccessTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(
private UserAccessTokenRepository $repository
) {
}
public function getUserBadgeFrom(string $accessToken): UserBadge
{
/** @var $token UserAccessToken|null */
if (!$accessToken || !$token = $this->repository->findOneBy(['token' => $accessToken])) {
throw new BadCredentialsException('Invalid credentials.');
}
if($token->getDeletedAt() || ( $token->getExpiresAt() && $token->getExpiresAt() <= new \DateTime() ) ) {
throw new CredentialsExpiredException('Token expired.');
}
// and return a UserBadge object containing the user identifier from the found token
return new UserBadge($token->getOwner()->getId(), function() use($token){
return $token->getOwner();
});
}
}

View File

@ -253,6 +253,18 @@
"templates/base.html.twig"
]
},
"symfony/uid": {
"version": "6.4",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "main",
"version": "6.2",
"ref": "d294ad4add3e15d7eb1bae0221588ca89b38e558"
},
"files": [
"config/packages/uid.yaml"
]
},
"symfony/validator": {
"version": "6.3",
"recipe": {

View File

@ -1,5 +1,5 @@
<!DOCTYPE html>
<html data-bs-theme="dark">
<html data-bs-theme="dark" lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@ -28,10 +28,6 @@
<i class="fas fa-tachometer-alt fa-fw me-3"></i>
<span>Dashboard</span>
</a>
<a href="#" class="list-group-item list-group-item-action py-2 ripple">
<i class="fas fa-comments fa-fw me-3"></i>
<span>Comments</span>
</a>
<a href="{{ path('app_domain_index') }}" class="list-group-item list-group-item-action py-2 ripple{{ app.request.pathInfo starts with path('app_domain_index') ? ' active' : ''}}">
<i class="fas fa-globe fa-fw me-3"></i>
<span>Domains</span>

View File

@ -27,6 +27,7 @@
<td>{{ domain.updatedAt ? domain.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ domain.createdAt ? domain.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>
<a href="{{ path('app_domain_comments', {'id': domain.id}) }}">comments</a>
<a href="{{ path('app_domain_show', {'id': domain.id}) }}">show</a>
<a href="{{ path('app_domain_edit', {'id': domain.id}) }}">edit</a>
</td>

View File

@ -0,0 +1,44 @@
{% extends 'base.html.twig' %}
{% block title %}Comment List{% endblock %}
{% block body %}
<h1>{{ domain.domain }} Comment List</h1>
<table class="table">
<thead>
<tr>
<th>Domain</th>
<th>Name</th>
<th>Enabled</th>
<th>DefaultSortPolicy</th>
<th>UpdatedAt</th>
<th>CreatedAt</th>
<th>actions</th>
</tr>
</thead>
<tbody>
{% for comment in [] %}
<tr>
<td>{{ domain.domain }}</td>
<td>{{ domain.name }}</td>
<td>{{ domain.enabled ? 'Yes' : 'No' }}</td>
<td>{{ domain.getDefaultSortPolicyName }}</td>
<td>{{ domain.updatedAt ? domain.updatedAt|date('Y-m-d H:i:s') : '' }}</td>
<td>{{ domain.createdAt ? domain.createdAt|date('Y-m-d H:i:s') : '' }}</td>
<td>
<a href="{{ path('app_domain_comments', {'id': domain.id}) }}">comments</a>
<a href="{{ path('app_domain_show', {'id': domain.id}) }}">show</a>
<a href="{{ path('app_domain_edit', {'id': domain.id}) }}">edit</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="9">no records found</td>
</tr>
{% endfor %}
</tbody>
</table>
<a href="{{ path('app_domain_new') }}">Create new</a>
{% endblock %}

10340
yarn.lock

File diff suppressed because it is too large Load Diff