diff --git a/.env b/.env index 8f9106b..e89f76e 100644 --- a/.env +++ b/.env @@ -26,3 +26,7 @@ APP_SECRET=8a390490e448f181dd8d3e6bd38efe6a # DATABASE_URL="mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7" DATABASE_URL="postgresql://postgres:develop@127.0.0.1:5432/plantex?serverVersion=13&charset=utf8" ###< doctrine/doctrine-bundle ### + +###> symfony/mailer ### +# MAILER_DSN=smtp://localhost +###< symfony/mailer ### diff --git a/composer.json b/composer.json index 6d95b18..c117dda 100644 --- a/composer.json +++ b/composer.json @@ -16,12 +16,14 @@ "symfony/flex": "^1.3.1", "symfony/form": "5.2.*", "symfony/framework-bundle": "5.2.*", + "symfony/mailer": "5.2.*", "symfony/monolog-bundle": "^3.7", "symfony/proxy-manager-bridge": "5.2.*", "symfony/security-bundle": "5.2.*", "symfony/twig-bundle": "5.2.*", "symfony/validator": "5.2.*", "symfony/yaml": "5.2.*", + "symfonycasts/verify-email-bundle": "^1.4", "twig/extra-bundle": "^2.12|^3.0", "twig/twig": "^2.12|^3.0" }, diff --git a/composer.lock b/composer.lock index 28ad0b3..1d20f5f 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": "ae1f737931efbb77621413dce9f11178", + "content-hash": "0546cf4b1fd84c5611f7eb5e87455072", "packages": [ { "name": "composer/package-versions-deprecated", @@ -1424,6 +1424,74 @@ }, "time": "2020-07-30T16:57:33+00:00" }, + { + "name": "egulias/email-validator", + "version": "3.1.1", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "c81f18a3efb941d8c4d2e025f6183b5c6d697307" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/c81f18a3efb941d8c4d2e025f6183b5c6d697307", + "reference": "c81f18a3efb941d8c4d2e025f6183b5c6d697307", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^1.2", + "php": ">=7.2", + "symfony/polyfill-intl-idn": "^1.15" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.2", + "phpunit/phpunit": "^8.5.8|^9.3.3", + "vimeo/psalm": "^4" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/3.1.1" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2021-04-01T18:37:14+00:00" + }, { "name": "friendsofphp/proxy-manager-lts", "version": "v1.0.3", @@ -3710,6 +3778,170 @@ ], "time": "2021-02-18T22:42:36+00:00" }, + { + "name": "symfony/mailer", + "version": "v5.2.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "04475b8368b6c7a559581ee8a0650c919ec79274" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/04475b8368b6c7a559581ee8a0650c919ec79274", + "reference": "04475b8368b6c7a559581ee8a0650c919ec79274", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10|^3", + "php": ">=7.2.5", + "psr/log": "~1.0", + "symfony/event-dispatcher": "^4.4|^5.0", + "symfony/mime": "^5.2.6", + "symfony/polyfill-php80": "^1.15", + "symfony/service-contracts": "^1.1|^2" + }, + "conflict": { + "symfony/http-kernel": "<4.4" + }, + "require-dev": { + "symfony/amazon-mailer": "^4.4|^5.0", + "symfony/google-mailer": "^4.4|^5.0", + "symfony/http-client-contracts": "^1.1|^2", + "symfony/mailchimp-mailer": "^4.4|^5.0", + "symfony/mailgun-mailer": "^4.4|^5.0", + "symfony/mailjet-mailer": "^4.4|^5.0", + "symfony/messenger": "^4.4|^5.0", + "symfony/postmark-mailer": "^4.4|^5.0", + "symfony/sendgrid-mailer": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v5.2.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-03-12T13:18:39+00:00" + }, + { + "name": "symfony/mime", + "version": "v5.2.6", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "1b2092244374cbe48ae733673f2ca0818b37197b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/1b2092244374cbe48ae733673f2ca0818b37197b", + "reference": "1b2092244374cbe48ae733673f2ca0818b37197b", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<4.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/property-access": "^4.4|^5.1", + "symfony/property-info": "^4.4|^5.1", + "symfony/serializer": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v5.2.6" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-03-12T13:18:39+00:00" + }, { "name": "symfony/monolog-bridge", "version": "v5.2.5", @@ -4110,6 +4342,93 @@ ], "time": "2021-01-22T09:19:47+00:00" }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "2d63434d922daf7da8dd863e7907e67ee3031483" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/2d63434d922daf7da8dd863e7907e67ee3031483", + "reference": "2d63434d922daf7da8dd863e7907e67ee3031483", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "symfony/polyfill-intl-normalizer": "^1.10", + "symfony/polyfill-php72": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-22T09:19:47+00:00" + }, { "name": "symfony/polyfill-intl-normalizer", "version": "v1.22.1", @@ -6029,6 +6348,56 @@ ], "time": "2021-03-06T07:59:01+00:00" }, + { + "name": "symfonycasts/verify-email-bundle", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/verify-email-bundle.git", + "reference": "3935f7375b2fa795f349bb4281ba8bcb754f4c91" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/verify-email-bundle/zipball/3935f7375b2fa795f349bb4281ba8bcb754f4c91", + "reference": "3935f7375b2fa795f349bb4281ba8bcb754f4c91", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/config": "^4.4 | ^5.0", + "symfony/dependency-injection": "^4.4 | ^5.0", + "symfony/deprecation-contracts": "^2.2", + "symfony/http-kernel": "^4.4 | ^5.0", + "symfony/routing": "^4.4 | ^5.0" + }, + "conflict": { + "symfony/framework-bundle": "<4.4" + }, + "require-dev": { + "doctrine/orm": "^2.7", + "doctrine/persistence": "^2.0", + "friendsofphp/php-cs-fixer": "^2.17", + "symfony/framework-bundle": "^4.4 | ^5.0", + "symfony/phpunit-bridge": "^5.0", + "vimeo/psalm": "^4.3" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "SymfonyCasts\\Bundle\\VerifyEmail\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Simple, stylish Email Verification for Symfony", + "support": { + "issues": "https://github.com/SymfonyCasts/verify-email-bundle/issues", + "source": "https://github.com/SymfonyCasts/verify-email-bundle/tree/v1.4.0" + }, + "time": "2021-04-12T17:34:34+00:00" + }, { "name": "twig/extra-bundle", "version": "v3.3.0", diff --git a/config/bundles.php b/config/bundles.php index 75fcb3f..77fb671 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -11,4 +11,5 @@ return [ Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], + SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], ]; diff --git a/config/packages/mailer.yaml b/config/packages/mailer.yaml new file mode 100644 index 0000000..56a650d --- /dev/null +++ b/config/packages/mailer.yaml @@ -0,0 +1,3 @@ +framework: + mailer: + dsn: '%env(MAILER_DSN)%' diff --git a/migrations/Version20210422132314.php b/migrations/Version20210422132314.php new file mode 100644 index 0000000..8896d21 --- /dev/null +++ b/migrations/Version20210422132314.php @@ -0,0 +1,32 @@ +addSql('ALTER TABLE "user" ADD is_verified BOOLEAN NOT NULL DEFAULT TRUE'); + } + + public function down(Schema $schema) : void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('CREATE SCHEMA public'); + $this->addSql('ALTER TABLE "user" DROP is_verified'); + } +} diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php new file mode 100644 index 0000000..b58a402 --- /dev/null +++ b/src/Controller/RegistrationController.php @@ -0,0 +1,101 @@ +emailVerifier = $emailVerifier; + } + + #[Route('/register', name: 'app_register')] + public function register(Request $request, UserPasswordEncoderInterface $passwordEncoder, GuardAuthenticatorHandler $guardHandler, AppAuthenticator $authenticator): Response + { + $user = new User(); + $form = $this->createForm(RegistrationFormType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // encode the plain password + $user->setPassword( + $passwordEncoder->encodePassword( + $user, + $form->get('plainPassword')->getData() + ) + ); + + $entityManager = $this->getDoctrine()->getManager(); + $entityManager->persist($user); + $entityManager->flush(); + + // generate a signed url and email it to the user + $this->emailVerifier->sendEmailConfirmation('app_verify_email', $user, + (new TemplatedEmail()) + ->from(new Address('no-reply@example.com', 'plantex no-reply')) + ->to($user->getEmail()) + ->subject('Please Confirm your Email') + ->htmlTemplate('registration/confirmation_email.html.twig') + ); + // do anything else you need here, like send an email + + return $guardHandler->authenticateUserAndHandleSuccess( + $user, + $request, + $authenticator, + 'main' // firewall name in security.yaml + ); + } + + return $this->render('registration/register.html.twig', [ + 'registrationForm' => $form->createView(), + ]); + } + + #[Route('/verify/email', name: 'app_verify_email')] + public function verifyUserEmail(Request $request, UserRepository $userRepository): Response + { + $id = $request->get('id'); + + if (null === $id) { + return $this->redirectToRoute('app_register'); + } + + $user = $userRepository->find($id); + + if (null === $user) { + return $this->redirectToRoute('app_register'); + } + + // validate email confirmation link, sets User::isVerified=true and persists + try { + $this->emailVerifier->handleEmailConfirmation($request, $user); + } catch (VerifyEmailExceptionInterface $exception) { + $this->addFlash('verify_email_error', $exception->getReason()); + + return $this->redirectToRoute('app_register'); + } + + // @TODO Change the redirect on success and handle or remove the flash message in your templates + $this->addFlash('success', 'Your email address has been verified.'); + + return $this->redirectToRoute('user'); + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php index e9af047..6604ed3 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -4,11 +4,13 @@ namespace App\Entity; use App\Repository\UserRepository; use Doctrine\ORM\Mapping as ORM; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\UserInterface; /** * @ORM\Entity(repositoryClass=UserRepository::class) * @ORM\Table(name="`user`") + * @UniqueEntity(fields={"email"}, message="There is already an account with this email") */ class User implements UserInterface { @@ -35,6 +37,11 @@ class User implements UserInterface */ private $password; + /** + * @ORM\Column(type="boolean") + */ + private $isVerified = false; + public function getId(): ?int { return $this->id; @@ -115,4 +122,16 @@ class User implements UserInterface // If you store any temporary, sensitive data on the user, clear it here // $this->plainPassword = null; } + + public function isVerified(): bool + { + return $this->isVerified; + } + + public function setIsVerified(bool $isVerified): self + { + $this->isVerified = $isVerified; + + return $this; + } } diff --git a/src/Form/RegistrationFormType.php b/src/Form/RegistrationFormType.php new file mode 100644 index 0000000..80102b9 --- /dev/null +++ b/src/Form/RegistrationFormType.php @@ -0,0 +1,54 @@ +add('email') + ->add('agreeTerms', CheckboxType::class, [ + 'mapped' => false, + 'constraints' => [ + new IsTrue([ + 'message' => 'You should agree to our terms.', + ]), + ], + ]) + ->add('plainPassword', PasswordType::class, [ + // instead of being set onto the object directly, + // this is read and encoded in the controller + 'mapped' => false, + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter a password', + ]), + new Length([ + 'min' => 6, + 'minMessage' => 'Your password should be at least {{ limit }} characters', + // max length allowed by Symfony for security reasons + 'max' => 4096, + ]), + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver) + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} diff --git a/src/Security/EmailVerifier.php b/src/Security/EmailVerifier.php new file mode 100644 index 0000000..d5a7508 --- /dev/null +++ b/src/Security/EmailVerifier.php @@ -0,0 +1,57 @@ +verifyEmailHelper = $helper; + $this->mailer = $mailer; + $this->entityManager = $manager; + } + + public function sendEmailConfirmation(string $verifyEmailRouteName, UserInterface $user, TemplatedEmail $email): void + { + $signatureComponents = $this->verifyEmailHelper->generateSignature( + $verifyEmailRouteName, + $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); + } + + /** + * @throws VerifyEmailExceptionInterface + */ + public function handleEmailConfirmation(Request $request, UserInterface $user): void + { + $this->verifyEmailHelper->validateEmailConfirmation($request->getUri(), $user->getId(), $user->getEmail()); + + $user->setIsVerified(true); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + } +} diff --git a/symfony.lock b/symfony.lock index acef62f..2533d8f 100644 --- a/symfony.lock +++ b/symfony.lock @@ -82,6 +82,9 @@ "doctrine/sql-formatter": { "version": "1.1.1" }, + "egulias/email-validator": { + "version": "3.1.1" + }, "friendsofphp/proxy-manager-lts": { "version": "v1.0.3" }, @@ -222,6 +225,18 @@ "symfony/intl": { "version": "v5.2.4" }, + "symfony/mailer": { + "version": "4.3", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "master", + "version": "4.3", + "ref": "15658c2a0176cda2e7dba66276a2030b52bd81b2" + }, + "files": [ + "config/packages/mailer.yaml" + ] + }, "symfony/maker-bundle": { "version": "1.0", "recipe": { @@ -231,6 +246,9 @@ "ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f" } }, + "symfony/mime": { + "version": "v5.2.6" + }, "symfony/monolog-bridge": { "version": "v5.2.5" }, @@ -261,6 +279,9 @@ "symfony/polyfill-intl-icu": { "version": "v1.22.1" }, + "symfony/polyfill-intl-idn": { + "version": "v1.22.1" + }, "symfony/polyfill-intl-normalizer": { "version": "v1.22.1" }, @@ -391,6 +412,9 @@ "symfony/yaml": { "version": "v5.2.5" }, + "symfonycasts/verify-email-bundle": { + "version": "v1.4.0" + }, "twig/extra-bundle": { "version": "v3.3.0" }, diff --git a/templates/registration/confirmation_email.html.twig b/templates/registration/confirmation_email.html.twig new file mode 100644 index 0000000..7c79d8a --- /dev/null +++ b/templates/registration/confirmation_email.html.twig @@ -0,0 +1,11 @@ +

Hi! Please confirm your email!

+ +

+ Please confirm your email address by clicking the following link:

+ Confirm my Email. + This link will expire in {{ expiresAtMessageKey|trans(expiresAtMessageData, 'VerifyEmailBundle') }}. +

+ +

+ Cheers! +

diff --git a/templates/registration/register.html.twig b/templates/registration/register.html.twig new file mode 100644 index 0000000..9a619fd --- /dev/null +++ b/templates/registration/register.html.twig @@ -0,0 +1,21 @@ +{% extends 'base.html.twig' %} + +{% block title %}Register{% endblock %} + +{% block body %} + {% for flashError in app.flashes('verify_email_error') %} + + {% endfor %} + +

Register

+ + {{ form_start(registrationForm) }} + {{ form_row(registrationForm.email) }} + {{ form_row(registrationForm.plainPassword, { + label: 'Password' + }) }} + {{ form_row(registrationForm.agreeTerms) }} + + + {{ form_end(registrationForm) }} +{% endblock %}