Skip to content

Commit

Permalink
feat: add registration
Browse files Browse the repository at this point in the history
  • Loading branch information
maelgangloff committed Aug 4, 2024
1 parent 1bb63cd commit 925f370
Show file tree
Hide file tree
Showing 20 changed files with 371 additions and 44 deletions.
2 changes: 2 additions & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,9 @@ LOCK_DSN=flock
###< symfony/lock ###


MAILER_SENDER_NAME="Domain Watchdog"
MAILER_SENDER_EMAIL=[email protected]
REGISTRATION_ENABLED=true
LIMITED_FEATURES=false
OAUTH_CLIENT_ID=
OAUTH_CLIENT_SECRET=
Expand Down
4 changes: 3 additions & 1 deletion assets/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions config/bundles.php
Original file line number Diff line number Diff line change
Expand Up @@ -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],
];
13 changes: 11 additions & 2 deletions config/packages/security.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
32 changes: 32 additions & 0 deletions migrations/Version20240804214457.php
Original file line number Diff line number Diff line change
@@ -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');
}
}
5 changes: 3 additions & 2 deletions src/Controller/DomainRefreshController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
Expand Down Expand Up @@ -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(),
Expand Down
119 changes: 119 additions & 0 deletions src/Controller/RegistrationController.php
Original file line number Diff line number Diff line change
@@ -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');
}
}
28 changes: 27 additions & 1 deletion src/Entity/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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;

/**
Expand All @@ -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;

/**
Expand All @@ -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();
Expand Down Expand Up @@ -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;
}
}
8 changes: 5 additions & 3 deletions src/MessageHandler/ProcessDomainTriggerHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
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]
final readonly class ProcessDomainTriggerHandler
{
public function __construct(
private string $mailerSenderEmail,
private string $mailerSenderName,
private MailerInterface $mailer,
private WatchListRepository $watchListRepository,
private DomainRepository $domainRepository,
Expand Down Expand Up @@ -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')
Expand All @@ -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')
Expand All @@ -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')
Expand Down
Loading

0 comments on commit 925f370

Please sign in to comment.