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

2
.env
View File

@@ -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=

View File

@@ -103,7 +103,9 @@ export default function App() {
<Typography.Link href='https://github.com/maelgangloff/domain-watchdog/wiki'><Button
type='text'>{t`Documentation`}</Button></Typography.Link>
</Space>
<Typography.Paragraph>{jt`${ProjectLink} is an open source project distributed under the ${LicenseLink} license.`}</Typography.Paragraph>
<Typography.Paragraph style={{marginTop: '1em'}}>
{jt`${ProjectLink} is an open source project distributed under the ${LicenseLink} license.`}
</Typography.Paragraph>
</Layout.Footer>
</Layout>
</Layout>

View File

@@ -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"
},

2
composer.lock generated
View File

@@ -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",

View File

@@ -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],
];

View File

@@ -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 }

View File

@@ -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

View File

@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20240804214457 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

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();

View File

@@ -56,18 +56,21 @@
<h1>Domain Watchdog Error</h1>
</div>
<div class="content">
<p>Hello,</p>
<p>We would like to inform you that an error occurred while ordering the following domain name:</p>
<p><strong>Domain name:</strong> {{ domain.ldhName }}</p>
<p>Hello, <br/>
We would like to inform you that an error occurred while ordering the following domain name:<br/>
<strong>Domain name:</strong> {{ domain.ldhName }}<br/>
</p>
<p>Here are some possible explanations:</p>
<ul>
<li>The Connector configuration you provided is no longer valid.</li>
<li>It is likely that the domain is no longer available for registration.</li>
<li>A temporary interruption is affecting the registration of this domain name.</li>
</ul>
<p>Thank you for your understanding,</p>
<p>Sincerely,</p>
<p>Domain Watchdog</p>
<br/><br/>
<p>Thank you for your understanding,<br/>
Sincerely,<br/>
Domain Watchdog<br/>
</p>
</div>
<div class="footer">
<p>&copy; Domain Watchdog</p>

View File

@@ -56,18 +56,22 @@
<h1>Domain Watchdog Error</h1>
</div>
<div class="content">
<p>Hello,</p>
<p>We would like to inform you that an error occurred while updating the information for the following domain
name:</p>
<p><strong>Domain name:</strong> {{ domain.ldhName }}</p>
<p>Hello, <br/>
We would like to inform you that an error occurred while updating the information for the following domain
name:<br/>
<strong>Domain name:</strong> {{ domain.ldhName }}<br/>
</p>
<p>Here are some possible explanations:</p>
<ul>
<li>It is likely that the domain will be available for registration again.</li>
<li>A temporary outage affects the provision of domain name information.</li>
</ul>
<p>Thank you for your understanding,</p>
<p>Sincerely,</p>
<p>Domain Watchdog</p>
<br/><br/>
<p>Thank you for your understanding,<br/>
Sincerely,<br/>
Domain Watchdog<br/>
</p>
</div>
<div class="footer">
<p>&copy; Domain Watchdog</p>

View File

@@ -0,0 +1,66 @@
<!DOCTYPE html>
<html lang="en">
<head>
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
}
.container {
max-width: 600px;
margin: 0 auto;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
background-color: #f9f9f9;
}
.header {
text-align: center;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
color: #0056b3;
}
.content {
margin-bottom: 20px;
}
.content p {
margin: 0 0 10px;
}
.footer {
text-align: center;
color: #777;
}
</style>
<title>Domain Watchdog - Confirm Email</title>
</head>
<body>
<div class="container">
<div class="header">
<h1>Domain Watchdog - Confirm Email</h1>
</div>
<div class="content">
<p>Hello, <br/>
Please confirm your email address by clicking the following link: <br><br>
<a href="{{ signedUrl|raw }}">Confirm my Email</a>.
This link will expire in {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}.
<br/><br/>
Thank you for your understanding,<br/>
Sincerely,<br/>
Domain Watchdog<br/>
</p>
</div>
<div class="footer">
<p>&copy; Domain Watchdog</p>
</div>
</div>
</body>
</html>

View File

@@ -48,15 +48,16 @@
<h1>Domain Watchdog - Domain Ordered</h1>
</div>
<div class="content">
<p>Hello,</p>
<p>We are pleased to inform you that a domain name present in your Watchlist has been ordered using the
connector you have chosen.</p>
<p><strong>Domain name:</strong> {{ domain.ldhName }}</p>
<p><strong>Connector provider :</strong> {{ provider }}</p>
<br/>
<p>Thank you for your understanding,</p>
<p>Sincerely,</p>
<p>Domain Watchdog</p>
<p>Hello, <br/>
We are pleased to inform you that a domain name present in your Watchlist has been ordered using the
connector you have chosen.<br/>
<strong>Domain name:</strong> {{ domain.ldhName }}<br/>
<strong>Connector provider :</strong> {{ provider }}<br/>
<br/><br/>
Thank you for your understanding,<br/>
Sincerely,<br/>
Domain Watchdog<br/>
</p>
</div>
<div class="footer">
<p>&copy; Domain Watchdog</p>

View File

@@ -48,15 +48,16 @@
<h1>Domain Watchdog Alert</h1>
</div>
<div class="content">
<p>Hello,</p>
<p>We are pleased to inform you that a new action has been detected on a domain name in your watchlist.</p>
<p><strong>Domain name:</strong> {{ event.domain.ldhName }}</p>
<p><strong>Action:</strong> {{ event.action }}</p>
<p><strong>Effective Date:</strong> {{ event.date | date("c") }}</p>
<br/>
<p>Thank you for your understanding,</p>
<p>Sincerely,</p>
<p>Domain Watchdog</p>
<p>Hello, <br/>
We are pleased to inform you that a new action has been detected on a domain name in your watchlist.<br/>
<strong>Domain name:</strong> {{ event.domain.ldhName }}<br/>
<strong>Action:</strong> {{ event.action }}<br/>
<strong>Effective Date:</strong> {{ event.date | date("c") }}<br/>
<br/><br/>
Thank you for your understanding,<br/>
Sincerely,<br/>
Domain Watchdog<br/>
</p>
</div>
<div class="footer">
<p>&copy; Domain Watchdog</p>