feat: add registration

This commit is contained in:
Maël Gangloff
2024-08-05 01:30:27 +02:00
parent 1bb63cdc3b
commit 925f3708c0
20 changed files with 371 additions and 44 deletions

View File

@@ -23,7 +23,7 @@ class DomainRefreshController extends AbstractController
{
public function __construct(private readonly DomainRepository $domainRepository,
private readonly RDAPService $RDAPService,
private readonly RateLimiterFactory $authenticatedApiLimiter,
private readonly RateLimiterFactory $rdapRequestsLimiter,
private readonly MessageBusInterface $bus,
private readonly LoggerInterface $logger
) {
@@ -63,7 +63,8 @@ class DomainRefreshController extends AbstractController
}
if (false === $kernel->isDebug() && true === $this->getParameter('limited_features')) {
$limiter = $this->authenticatedApiLimiter->create($userId);
$limiter = $this->rdapRequestsLimiter->create($userId);
if (false === $limiter->consume()->isAccepted()) {
$this->logger->warning('User {username} was rate limited by the API.', [
'username' => $this->getUser()->getUserIdentifier(),

View File

@@ -0,0 +1,119 @@
<?php
namespace App\Controller;
use App\Entity\User;
use App\Repository\UserRepository;
use App\Security\EmailVerifier;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Log\LoggerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\TooManyRequestsHttpException;
use Symfony\Component\HttpKernel\Exception\UnauthorizedHttpException;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use Symfony\Component\RateLimiter\RateLimiterFactory;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Serializer\SerializerInterface;
class RegistrationController extends AbstractController
{
public function __construct(
private readonly EmailVerifier $emailVerifier,
private readonly string $mailerSenderEmail,
private readonly string $mailerSenderName,
private readonly RateLimiterFactory $userRegisterLimiter,
private readonly EntityManagerInterface $em,
private readonly SerializerInterface $serializer,
private readonly LoggerInterface $logger,
) {
}
/**
* @throws TransportExceptionInterface
*/
#[Route(
path: '/api/register',
name: 'user_register',
defaults: [
'_api_resource_class' => User::class,
'_api_operation_name' => 'register',
],
methods: ['POST']
)]
public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher): Response
{
if (false === $this->getParameter('registration_enabled')) {
throw new UnauthorizedHttpException('', 'Registration is disabled on this instance');
}
$limiter = $this->userRegisterLimiter->create($request->getClientIp());
if (false === $limiter->consume()->isAccepted()) {
$this->logger->warning('IP address {ip} was rate limited by the Registration API.', [
'ip' => $request->getClientIp(),
]);
throw new TooManyRequestsHttpException();
}
$user = $this->serializer->deserialize($request->getContent(), User::class, 'json', ['groups' => 'user:register']);
if (null === $user->getEmail() || null === $user->getPassword()) {
throw new BadRequestHttpException('Bad request');
}
$user->setPassword(
$userPasswordHasher->hashPassword(
$user,
$user->getPassword()
)
);
$this->em->persist($user);
$this->em->flush();
$this->logger->info('A new user has registered ({username}).', [
'username' => $user->getUserIdentifier(),
]);
$this->emailVerifier->sendEmailConfirmation('app_verify_email', $user,
(new TemplatedEmail())
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->locale('en')
->subject('Please Confirm your Email')
->htmlTemplate('emails/success/confirmation_email.html.twig')
);
return $this->redirectToRoute('index');
}
#[Route('/verify/email', name: 'app_verify_email')]
public function verifyUserEmail(Request $request, UserRepository $userRepository): Response
{
$id = $request->query->get('id');
if (null === $id) {
return $this->redirectToRoute('index');
}
$user = $userRepository->find($id);
if (null === $user) {
return $this->redirectToRoute('index');
}
$this->emailVerifier->handleEmailConfirmation($request, $user);
$this->logger->info('User {username} has validated his email address.', [
'username' => $user->getUserIdentifier(),
]);
return $this->redirectToRoute('index');
}
}

View File

@@ -4,7 +4,9 @@ namespace App\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Post;
use App\Controller\MeController;
use App\Controller\RegistrationController;
use App\Repository\UserRepository;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -26,6 +28,14 @@ use Symfony\Component\Serializer\Attribute\Groups;
normalizationContext: ['groups' => 'user:list'],
read: false
),
new Post(
uriTemplate: '/register',
routeName: 'user_register',
controller: RegistrationController::class,
denormalizationContext: ['groups' => ['user:register']],
read: false,
name: 'register'
),
]
)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
@@ -36,7 +46,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
private ?int $id = null;
#[ORM\Column(length: 180)]
#[Groups(['user:list'])]
#[Groups(['user:list', 'user:register'])]
private ?string $email = null;
/**
@@ -50,6 +60,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* @var string|null The hashed password
*/
#[ORM\Column(nullable: true)]
#[Groups(['user:register'])]
private ?string $password = null;
/**
@@ -64,6 +75,9 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[ORM\OneToMany(targetEntity: Connector::class, mappedBy: 'user', orphanRemoval: true)]
private Collection $connectors;
#[ORM\Column]
private bool $isVerified = false;
public function __construct()
{
$this->watchLists = new ArrayCollection();
@@ -201,4 +215,16 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
return $this;
}
public function isVerified(): bool
{
return $this->isVerified;
}
public function setVerified(bool $isVerified): static
{
$this->isVerified = $isVerified;
return $this;
}
}

View File

@@ -20,6 +20,7 @@ use Symfony\Component\HttpKernel\KernelInterface;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
#[AsMessageHandler]
@@ -27,6 +28,7 @@ final readonly class ProcessDomainTriggerHandler
{
public function __construct(
private string $mailerSenderEmail,
private string $mailerSenderName,
private MailerInterface $mailer,
private WatchListRepository $watchListRepository,
private DomainRepository $domainRepository,
@@ -97,7 +99,7 @@ final readonly class ProcessDomainTriggerHandler
private function sendEmailDomainOrdered(Domain $domain, Connector $connector, User $user): void
{
$email = (new TemplatedEmail())
->from($this->mailerSenderEmail)
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->priority(Email::PRIORITY_HIGHEST)
->subject('A domain name has been ordered')
@@ -117,7 +119,7 @@ final readonly class ProcessDomainTriggerHandler
private function sendEmailDomainOrderError(Domain $domain, User $user): void
{
$email = (new TemplatedEmail())
->from($this->mailerSenderEmail)
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->subject('An error occurred while ordering a domain name')
->htmlTemplate('emails/errors/domain_order.html.twig')
@@ -135,7 +137,7 @@ final readonly class ProcessDomainTriggerHandler
private function sendEmailDomainUpdated(DomainEvent $domainEvent, User $user): void
{
$email = (new TemplatedEmail())
->from($this->mailerSenderEmail)
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->priority(Email::PRIORITY_HIGHEST)
->subject('A domain name has been changed')

View File

@@ -16,6 +16,7 @@ use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\Exception\ExceptionInterface;
use Symfony\Component\Messenger\MessageBusInterface;
use Symfony\Component\Mime\Address;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
#[AsMessageHandler]
@@ -25,6 +26,7 @@ final readonly class ProcessWatchListTriggerHandler
private RDAPService $RDAPService,
private MailerInterface $mailer,
private string $mailerSenderEmail,
private string $mailerSenderName,
private MessageBusInterface $bus,
private WatchListRepository $watchListRepository,
private LoggerInterface $logger
@@ -90,7 +92,7 @@ final readonly class ProcessWatchListTriggerHandler
private function sendEmailDomainUpdateError(Domain $domain, User $user): void
{
$email = (new TemplatedEmail())
->from($this->mailerSenderEmail)
->from(new Address($this->mailerSenderEmail, $this->mailerSenderName))
->to($user->getEmail())
->subject('An error occurred while updating a domain name')
->htmlTemplate('emails/errors/domain_update.html.twig')

View File

@@ -0,0 +1,53 @@
<?php
namespace App\Security;
use App\Entity\User;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
use Symfony\Component\Mailer\MailerInterface;
use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
readonly class EmailVerifier
{
public function __construct(
private VerifyEmailHelperInterface $verifyEmailHelper,
private MailerInterface $mailer,
private EntityManagerInterface $entityManager
) {
}
/**
* @throws TransportExceptionInterface
*/
public function sendEmailConfirmation(string $verifyEmailRouteName, User $user, TemplatedEmail $email): void
{
$signatureComponents = $this->verifyEmailHelper->generateSignature(
$verifyEmailRouteName,
(string) $user->getId(),
$user->getEmail(),
['id' => $user->getId()]
);
$context = $email->getContext();
$context['signedUrl'] = $signatureComponents->getSignedUrl();
$context['expiresAtMessageKey'] = $signatureComponents->getExpirationMessageKey();
$context['expiresAtMessageData'] = $signatureComponents->getExpirationMessageData();
$email->context($context);
$this->mailer->send($email);
}
public function handleEmailConfirmation(Request $request, User $user): void
{
$this->verifyEmailHelper->validateEmailConfirmationFromRequest($request, (string) $user->getId(), $user->getEmail());
$user->setVerified(true);
$this->entityManager->persist($user);
$this->entityManager->flush();
}
}

View File

@@ -58,7 +58,7 @@ class OAuthAuthenticator extends OAuth2Authenticator implements AuthenticationEn
}
$user = new User();
$user->setEmail($userFromToken->getEmail());
$user->setEmail($userFromToken->getEmail())->setVerified(true);
$this->em->persist($user);
$this->em->flush();