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 @@ return [
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 @@ 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(),
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 @@ 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;
+ }
}
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\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')
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\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')
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 @@ 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();
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:
The Connector configuration you provided is no longer valid.
It is likely that the domain is no longer available for registration.
A temporary interruption is affecting the registration of this domain name.
-
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:
It is likely that the domain will be available for registration again.
A temporary outage affects the provision of domain name information.
-
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
+