From 925f3708c0d482a6390e9afc9c1cdd324a0d94fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ma=C3=ABl=20Gangloff?= Date: Mon, 5 Aug 2024 01:30:27 +0200 Subject: [PATCH] feat: add registration --- .env | 2 + assets/App.tsx | 4 +- composer.json | 2 +- composer.lock | 2 +- config/bundles.php | 1 + config/packages/security.yaml | 13 +- config/services.yaml | 3 + migrations/Version20240804214457.php | 32 +++++ src/Controller/DomainRefreshController.php | 5 +- src/Controller/RegistrationController.php | 119 ++++++++++++++++++ src/Entity/User.php | 28 ++++- .../ProcessDomainTriggerHandler.php | 8 +- .../ProcessWatchListTriggerHandler.php | 4 +- src/Security/EmailVerifier.php | 53 ++++++++ src/Security/OAuthAuthenticator.php | 2 +- .../emails/errors/domain_order.html.twig | 15 ++- .../emails/errors/domain_update.html.twig | 18 +-- .../success/confirmation_email.html.twig | 66 ++++++++++ .../emails/success/domain_ordered.html.twig | 19 +-- .../emails/success/domain_updated.html.twig | 19 +-- 20 files changed, 371 insertions(+), 44 deletions(-) create mode 100644 migrations/Version20240804214457.php create mode 100644 src/Controller/RegistrationController.php create mode 100644 src/Security/EmailVerifier.php create mode 100644 templates/emails/success/confirmation_email.html.twig diff --git a/.env b/.env index b34a2b1..196ed35 100644 --- a/.env +++ b/.env @@ -57,7 +57,9 @@ LOCK_DSN=flock ###< symfony/lock ### +MAILER_SENDER_NAME="Domain Watchdog" MAILER_SENDER_EMAIL=notifications@example.com +REGISTRATION_ENABLED=true LIMITED_FEATURES=false OAUTH_CLIENT_ID= OAUTH_CLIENT_SECRET= diff --git a/assets/App.tsx b/assets/App.tsx index 933190f..cead534 100644 --- a/assets/App.tsx +++ b/assets/App.tsx @@ -103,7 +103,9 @@ export default function App() { - {jt`${ProjectLink} is an open source project distributed under the ${LicenseLink} license.`} + + {jt`${ProjectLink} is an open source project distributed under the ${LicenseLink} license.`} + diff --git a/composer.json b/composer.json index 20fe884..6a8c06f 100644 --- a/composer.json +++ b/composer.json @@ -71,7 +71,7 @@ "symfony/web-link": "7.1.*", "symfony/webpack-encore-bundle": "^2.1", "symfony/yaml": "7.1.*", - "symfonycasts/verify-email-bundle": "^1.17", + "symfonycasts/verify-email-bundle": "*", "twig/extra-bundle": "^2.12|^3.0", "twig/twig": "^2.12|^3.0" }, diff --git a/composer.lock b/composer.lock index 757bae3..a35c991 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6b52bd3ad92da490e7a206500d0c0348", + "content-hash": "69d672f9e5a01b48f871fa5c81714f8d", "packages": [ { "name": "api-platform/core", diff --git a/config/bundles.php b/config/bundles.php index 26cc74c..91d52d6 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -18,4 +18,5 @@ Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle::class => ['all' => true], KnpU\OAuth2ClientBundle\KnpUOAuth2ClientBundle::class => ['all' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], + SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], ]; diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 50f1eb3..4acc223 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,6 +1,5 @@ framework: rate_limiter: - # define 2 rate limiters (one for username+IP, the other for IP) username_ip_login: policy: token_bucket limit: 5 @@ -11,8 +10,17 @@ framework: limit: 50 interval: '15 minutes' + user_register: + policy: token_bucket + limit: 1 + rate: { interval: '5 minutes' } + + rdap_requests: + policy: sliding_window + limit: 10 + interval: '1 hour' + services: - # our custom login rate limiter app.login_rate_limiter: class: Symfony\Component\Security\Http\RateLimiter\DefaultLoginRateLimiter arguments: @@ -69,6 +77,7 @@ security: access_control: - { path: ^/api$, roles: PUBLIC_ACCESS } - { path: ^/api/docs, roles: PUBLIC_ACCESS } + - { path: ^/api/register$, roles: PUBLIC_ACCESS } - { path: "^/api/watchlists/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/calendar$", roles: PUBLIC_ACCESS } - { path: "^/api/config$", roles: PUBLIC_ACCESS } - { path: ^/api, roles: IS_AUTHENTICATED_FULLY } diff --git a/config/services.yaml b/config/services.yaml index 3090dd3..175e7b9 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -5,8 +5,10 @@ # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration parameters: mailer_sender_email: '%env(string:MAILER_SENDER_EMAIL)%' + mailer_sender_name: '%env(string:MAILER_SENDER_NAME)' oauth_enabled: '%env(OAUTH_CLIENT_ID)%' limited_features: '%env(bool:LIMITED_FEATURES)%' + registration_enabled: '%env(bool:REGISTRATION_ENABLED)%' services: # default configuration for services in *this* file @@ -15,6 +17,7 @@ services: autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. bind: $mailerSenderEmail: '%mailer_sender_email%' + $mailerSenderName: '%mailer_sender_name%' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name diff --git a/migrations/Version20240804214457.php b/migrations/Version20240804214457.php new file mode 100644 index 0000000..caf0e03 --- /dev/null +++ b/migrations/Version20240804214457.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE "user" ADD is_verified BOOLEAN NOT NULL DEFAULT true;'); + $this->addSql('ALTER TABLE "user" ALTER is_verified DROP DEFAULT'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE "user" DROP is_verified'); + } +} diff --git a/src/Controller/DomainRefreshController.php b/src/Controller/DomainRefreshController.php index 8c3f275..ffe06c1 100644 --- a/src/Controller/DomainRefreshController.php +++ b/src/Controller/DomainRefreshController.php @@ -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 @@ public function __invoke(string $ldhName, KernelInterface $kernel): ?Domain } 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(), diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php new file mode 100644 index 0000000..f2f01a4 --- /dev/null +++ b/src/Controller/RegistrationController.php @@ -0,0 +1,119 @@ + 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'); + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index 79acc08..a273073 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -4,7 +4,9 @@ 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 @@ 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 @@ public function removeConnector(Connector $connector): static return $this; } + + public function isVerified(): bool + { + return $this->isVerified; + } + + public function setVerified(bool $isVerified): static + { + $this->isVerified = $isVerified; + + return $this; + } } diff --git a/src/MessageHandler/ProcessDomainTriggerHandler.php b/src/MessageHandler/ProcessDomainTriggerHandler.php index b4a3db6..c23e004 100644 --- a/src/MessageHandler/ProcessDomainTriggerHandler.php +++ b/src/MessageHandler/ProcessDomainTriggerHandler.php @@ -20,6 +20,7 @@ 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 @@ { public function __construct( private string $mailerSenderEmail, + private string $mailerSenderName, private MailerInterface $mailer, private WatchListRepository $watchListRepository, private DomainRepository $domainRepository, @@ -97,7 +99,7 @@ public function __invoke(ProcessDomainTrigger $message): void 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 @@ private function sendEmailDomainOrdered(Domain $domain, Connector $connector, Us 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 @@ private function sendEmailDomainOrderError(Domain $domain, User $user): void 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') diff --git a/src/MessageHandler/ProcessWatchListTriggerHandler.php b/src/MessageHandler/ProcessWatchListTriggerHandler.php index 959f727..bf6d290 100644 --- a/src/MessageHandler/ProcessWatchListTriggerHandler.php +++ b/src/MessageHandler/ProcessWatchListTriggerHandler.php @@ -16,6 +16,7 @@ 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 @@ public function __construct( 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 @@ public function __invoke(ProcessWatchListTrigger $message): void 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') diff --git a/src/Security/EmailVerifier.php b/src/Security/EmailVerifier.php new file mode 100644 index 0000000..7d09bfb --- /dev/null +++ b/src/Security/EmailVerifier.php @@ -0,0 +1,53 @@ +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(); + } +} diff --git a/src/Security/OAuthAuthenticator.php b/src/Security/OAuthAuthenticator.php index 5216a3e..cc7b205 100644 --- a/src/Security/OAuthAuthenticator.php +++ b/src/Security/OAuthAuthenticator.php @@ -58,7 +58,7 @@ public function authenticate(Request $request): Passport } $user = new User(); - $user->setEmail($userFromToken->getEmail()); + $user->setEmail($userFromToken->getEmail())->setVerified(true); $this->em->persist($user); $this->em->flush(); diff --git a/templates/emails/errors/domain_order.html.twig b/templates/emails/errors/domain_order.html.twig index 2f47145..f313755 100644 --- a/templates/emails/errors/domain_order.html.twig +++ b/templates/emails/errors/domain_order.html.twig @@ -56,18 +56,21 @@

Domain Watchdog Error

-

Hello,

-

We would like to inform you that an error occurred while ordering the following domain name:

-

Domain name: {{ domain.ldhName }}

+

Hello,
+ We would like to inform you that an error occurred while ordering the following domain name:
+ Domain name: {{ domain.ldhName }}
+

Here are some possible explanations:

-

Thank you for your understanding,

-

Sincerely,

-

Domain Watchdog

+

+

Thank you for your understanding,
+ Sincerely,
+ Domain Watchdog
+

-

Hello,

-

We would like to inform you that an error occurred while updating the information for the following domain - name:

-

Domain name: {{ domain.ldhName }}

+

Hello,
+ We would like to inform you that an error occurred while updating the information for the following domain + name:
+ Domain name: {{ domain.ldhName }}
+

Here are some possible explanations:

-

Thank you for your understanding,

-

Sincerely,

-

Domain Watchdog

+

+

Thank you for your understanding,
+ Sincerely,
+ Domain Watchdog
+

+
-

Hello,

-

We are pleased to inform you that a domain name present in your Watchlist has been ordered using the - connector you have chosen.

-

Domain name: {{ domain.ldhName }}

-

Connector provider : {{ provider }}

-
-

Thank you for your understanding,

-

Sincerely,

-

Domain Watchdog

+

Hello,
+ We are pleased to inform you that a domain name present in your Watchlist has been ordered using the + connector you have chosen.
+ Domain name: {{ domain.ldhName }}
+ Connector provider : {{ provider }}
+

+ Thank you for your understanding,
+ Sincerely,
+ Domain Watchdog
+

-

Hello,

-

We are pleased to inform you that a new action has been detected on a domain name in your watchlist.

-

Domain name: {{ event.domain.ldhName }}

-

Action: {{ event.action }}

-

Effective Date: {{ event.date | date("c") }}

-
-

Thank you for your understanding,

-

Sincerely,

-

Domain Watchdog

+

Hello,
+ We are pleased to inform you that a new action has been detected on a domain name in your watchlist.
+ Domain name: {{ event.domain.ldhName }}
+ Action: {{ event.action }}
+ Effective Date: {{ event.date | date("c") }}
+

+ Thank you for your understanding,
+ Sincerely,
+ Domain Watchdog
+