From 25d00877ae2e1660f4c330512c649ef13856fca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Fastr=C3=A9?= Date: Tue, 10 Jul 2018 12:53:44 +0200 Subject: [PATCH] add email to user and allow to connect through email or username --- Entity/User.php | 56 +++++++++++ Form/UserType.php | 1 + Resources/config/doctrine/User.orm.yml | 15 +++ Resources/config/services/security.yml | 5 + Resources/config/services/validator.yml | 6 ++ Resources/config/validation.yml | 3 + .../migrations/Version20180709181423.php | 87 +++++++++++++++++ Resources/views/User/edit.html.twig | 1 + Resources/views/User/new.html.twig | 1 + Security/UserProvider/UserProvider.php | 83 ++++++++++++++++ .../UserUniqueEmailAndUsernameConstraint.php | 42 ++++++++ .../Validator/UserUniqueEmailAndUsername.php | 95 +++++++++++++++++++ 12 files changed, 395 insertions(+) create mode 100644 Resources/migrations/Version20180709181423.php create mode 100644 Security/UserProvider/UserProvider.php create mode 100644 Validation/Constraint/UserUniqueEmailAndUsernameConstraint.php create mode 100644 Validation/Validator/UserUniqueEmailAndUsername.php diff --git a/Entity/User.php b/Entity/User.php index 13f49b59e..c814d7a01 100644 --- a/Entity/User.php +++ b/Entity/User.php @@ -22,6 +22,24 @@ class User implements AdvancedUserInterface { */ private $username; + /** + * + * @var string + */ + private $usernameCanonical; + + /** + * + * @var string + */ + private $email; + + /** + * + * @var string + */ + private $emailCanonical; + /** * * @var string @@ -115,9 +133,47 @@ class User implements AdvancedUserInterface { return $this->username; } + public function getUsernameCanonical() + { + return $this->usernameCanonical; + } + + public function getEmail() + { + return $this->email; + } + + public function getEmailCanonical() + { + return $this->emailCanonical; + } + + public function setUsernameCanonical($usernameCanonical) + { + $this->usernameCanonical = $usernameCanonical; + + return $this; + } + + public function setEmail($email) + { + $this->email = $email; + + return $this; + } + + public function setEmailCanonical($emailCanonical) + { + $this->emailCanonical = $emailCanonical; + + return $this; + } + + function setPassword($password) { $this->password = $password; + return $this; } diff --git a/Form/UserType.php b/Form/UserType.php index 1fd36d9e0..cbb027c25 100644 --- a/Form/UserType.php +++ b/Form/UserType.php @@ -19,6 +19,7 @@ class UserType extends AbstractType { $builder ->add('username') + ->add('email') ; if ($options['is_creation']) { $builder->add('plainPassword', UserPasswordType::class, array( diff --git a/Resources/config/doctrine/User.orm.yml b/Resources/config/doctrine/User.orm.yml index 05b434260..08d8064d3 100644 --- a/Resources/config/doctrine/User.orm.yml +++ b/Resources/config/doctrine/User.orm.yml @@ -14,6 +14,21 @@ Chill\MainBundle\Entity\User: username: type: string length: 80 + usernameCanonical: + name: username_canonical + type: string + length: 80 + unique: true + email: + type: string + length: 150 + nullable: true + emailCanonical: + name: email_canonical + type: string + length: 150 + nullable: true + unique: true password: type: string length: 255 diff --git a/Resources/config/services/security.yml b/Resources/config/services/security.yml index e9e3d03fd..a4b3f112f 100644 --- a/Resources/config/services/security.yml +++ b/Resources/config/services/security.yml @@ -9,4 +9,9 @@ services: chill.main.role_provider: class: Chill\MainBundle\Security\RoleProvider + + chill.main.user_provider: + class: Chill\MainBundle\Security\UserProvider\UserProvider + arguments: + $em: '@Doctrine\ORM\EntityManagerInterface' \ No newline at end of file diff --git a/Resources/config/services/validator.yml b/Resources/config/services/validator.yml index f09304375..c15b2181e 100644 --- a/Resources/config/services/validator.yml +++ b/Resources/config/services/validator.yml @@ -5,3 +5,9 @@ services: - "@chill.main.security.authorization.helper" tags: - { name: "validator.constraint_validator" } + + Chill\MainBundle\Validation\Validator\UserUniqueEmailAndUsername: + arguments: + $em: '@Doctrine\ORM\EntityManagerInterface' + tags: + - { name: "validator.constraint_validator" } diff --git a/Resources/config/validation.yml b/Resources/config/validation.yml index cd94af646..141d489fc 100644 --- a/Resources/config/validation.yml +++ b/Resources/config/validation.yml @@ -16,9 +16,12 @@ Chill\MainBundle\Entity\User: - Length: max: 70 min: 3 + email: + - Email: ~ constraints: - Callback: callback: isGroupCenterPresentOnce + - \Chill\MainBundle\Validation\Constraint\UserUniqueEmailAndUsernameConstraint: ~ Chill\MainBundle\Entity\RoleScope: constraints: diff --git a/Resources/migrations/Version20180709181423.php b/Resources/migrations/Version20180709181423.php new file mode 100644 index 000000000..93ca04e12 --- /dev/null +++ b/Resources/migrations/Version20180709181423.php @@ -0,0 +1,87 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('ALTER TABLE users ADD usernameCanonical VARCHAR(80) DEFAULT NULL'); + $this->addSql('UPDATE users SET usernameCanonical=LOWER(UNACCENT(username))'); + $this->addSql('ALTER TABLE users ALTER usernameCanonical DROP NOT NULL'); + $this->addSql('ALTER TABLE users ALTER usernameCanonical SET DEFAULT NULL'); + $this->addSql('ALTER TABLE users ADD email VARCHAR(150) DEFAULT NULL'); + $this->addSql('ALTER TABLE users ADD emailCanonical VARCHAR(150) DEFAULT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9F5A5DC32 ON users (usernameCanonical)'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_1483A5E9885281E ON users (emailCanonical)'); + + $this->addSql(<<<'SQL' + CREATE OR REPLACE FUNCTION canonicalize_user_on_update() RETURNS TRIGGER AS + $BODY$ + BEGIN + IF NEW.username <> OLD.username OR NEW.email <> OLD.email OR OLD.emailcanonical IS NULL OR OLD.usernamecanonical IS NULL THEN + UPDATE users SET usernamecanonical=LOWER(UNACCENT(NEW.username)), emailcanonical=LOWER(UNACCENT(NEW.email)) WHERE id=NEW.id; + END IF; + + RETURN NEW; + END; + $BODY$ LANGUAGE PLPGSQL +SQL + ); + + $this->addSql(<<addSql(<<<'SQL' + CREATE OR REPLACE FUNCTION canonicalize_user_on_insert() RETURNS TRIGGER AS + $BODY$ + BEGIN + UPDATE users SET usernamecanonical=LOWER(UNACCENT(NEW.username)), emailcanonical=LOWER(UNACCENT(NEW.email)) WHERE id=NEW.id; + + RETURN NEW; + END; + $BODY$ LANGUAGE PLPGSQL; +SQL + ); + + $this->addSql(<<abortIf($this->connection->getDatabasePlatform()->getName() !== 'postgresql', 'Migration can only be executed safely on \'postgresql\'.'); + + $this->addSql('DROP INDEX UNIQ_1483A5E9F5A5DC32'); + $this->addSql('DROP INDEX UNIQ_1483A5E9885281E'); + $this->addSql('ALTER TABLE users DROP usernameCanonical'); + $this->addSql('ALTER TABLE users DROP email'); + $this->addSql('ALTER TABLE users DROP emailCanonical'); + $this->addSql('DROP TRIGGER canonicalize_user_on_insert ON users'); + $this->addSql('DROP FUNCTION canonicalize_user_on_insert()'); + $this->addSql('DROP TRIGGER canonicalize_user_on_update ON users'); + $this->addSql('DROP FUNCTION canonicalize_user_on_update()'); + + } +} diff --git a/Resources/views/User/edit.html.twig b/Resources/views/User/edit.html.twig index c52caec2d..1f9a48ce1 100644 --- a/Resources/views/User/edit.html.twig +++ b/Resources/views/User/edit.html.twig @@ -8,6 +8,7 @@ {{ form_start(edit_form) }} {{ form_row(edit_form.username) }} + {{ form_row(edit_form.email) }} {{ form_row(edit_form.enabled, { 'label': "User'status"}) }} {{ form_widget(edit_form.submit, { 'attr': { 'class' : 'sc-button green center' } } ) }} diff --git a/Resources/views/User/new.html.twig b/Resources/views/User/new.html.twig index 87398d973..2b25bcbc5 100644 --- a/Resources/views/User/new.html.twig +++ b/Resources/views/User/new.html.twig @@ -7,6 +7,7 @@ {{ form_start(form) }} {{ form_row(form.username) }} + {{ form_row(form.email) }} {{ form_row(form.plainPassword.password) }} {{ form_widget(form.submit, { 'attr' : { 'class': 'sc-button blue' } }) }} {{ form_end(form) }} diff --git a/Security/UserProvider/UserProvider.php b/Security/UserProvider/UserProvider.php new file mode 100644 index 000000000..e3377d225 --- /dev/null +++ b/Security/UserProvider/UserProvider.php @@ -0,0 +1,83 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\Security\UserProvider; + +use Symfony\Component\Security\Core\User\UserProviderInterface; +use Symfony\Component\Security\Core\User\UserInterface; +use Doctrine\ORM\EntityManagerInterface; +use Chill\MainBundle\Entity\User; +use Symfony\Component\Security\Core\Exception\UsernameNotFoundException; +use Symfony\Component\Security\Core\Exception\UnsupportedUserException; + +/** + * + * + * @author Julien Fastré + */ +class UserProvider implements UserProviderInterface +{ + /** + * + * @var EntityManagerInterface + */ + protected $em; + + public function __construct(EntityManagerInterface $em) + { + $this->em = $em; + } + + + public function loadUserByUsername($username): UserInterface + { + $user = $this->em->createQuery(sprintf( + "SELECT u FROM %s u " + . "WHERE u.usernameCanonical = UNACCENT(LOWER(:pattern)) " + . "OR " + . "u.emailCanonical = UNACCENT(LOWER(:pattern))", + User::class)) + ->setParameter('pattern', $username) + ->getSingleResult(); + + if (NULL === $user) { + throw new UsernameNotFoundException(sprintf('Username "%s" does not exist.', $username)); + } + + return $user; + } + + public function refreshUser(UserInterface $user): UserInterface + { + if (!$user instanceof User) { + throw new UnsupportedUserException("Unsupported user class: cannot reload this user"); + } + + $reloadedUser = $this->em->getRepository(User::class)->find($user->getId()); + + if (NULL === $reloadedUser) { + throw new UsernameNotFoundException(sprintf('User with ID "%s" could not be reloaded.', $user->getId())); + } + + return $reloadedUser; + } + + public function supportsClass($class): bool + { + return $class === User::class; + } +} diff --git a/Validation/Constraint/UserUniqueEmailAndUsernameConstraint.php b/Validation/Constraint/UserUniqueEmailAndUsernameConstraint.php new file mode 100644 index 000000000..1cd481dd7 --- /dev/null +++ b/Validation/Constraint/UserUniqueEmailAndUsernameConstraint.php @@ -0,0 +1,42 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\Validation\Constraint; + +use Symfony\Component\Validator\Constraint; +use Chill\MainBundle\Validation\Validator\UserUniqueEmailAndUsername; + +/** + * + * + * @author Julien Fastré + */ +class UserUniqueEmailAndUsernameConstraint extends Constraint +{ + public $messageDuplicateUsername = "A user with the same or a close username already exists"; + public $messageDuplicateEmail = "A user with the same or a close email already exists"; + + public function validatedBy() + { + return UserUniqueEmailAndUsername::class; + } + + public function getTargets() + { + return [ self::CLASS_CONSTRAINT ]; + } +} diff --git a/Validation/Validator/UserUniqueEmailAndUsername.php b/Validation/Validator/UserUniqueEmailAndUsername.php new file mode 100644 index 000000000..637cdc563 --- /dev/null +++ b/Validation/Validator/UserUniqueEmailAndUsername.php @@ -0,0 +1,95 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +namespace Chill\MainBundle\Validation\Validator; + +use Symfony\Component\Validator\ConstraintValidator; +use Symfony\Component\Validator\Constraint; +use Chill\MainBundle\Entity\User; +use Doctrine\ORM\EntityManagerInterface; + +/** + * + * + * @author Julien Fastré + */ +class UserUniqueEmailAndUsername extends ConstraintValidator +{ + /** + * + * @var EntityManagerInterface + */ + protected $em; + + public function __construct(EntityManagerInterface $em) + { + $this->em = $em; + } + + + public function validate($value, Constraint $constraint) + { + if (!$value instanceof User) { + throw new \UnexpectedValueException("This validation should happens " + . "only on class ".User::class); + } + + $countUsersByUsername = $this->em->createQuery( + sprintf( + "SELECT COUNT(u) FROM %s u " + . "WHERE u.usernameCanonical = LOWER(UNACCENT(:username)) " + . "AND u != :user", + User::class) + ) + ->setParameter('username', $value->getUsername()) + ->setParameter('user', $value) + ->getSingleScalarResult(); + + if ($countUsersByUsername > 0) { + $this->context + ->buildViolation($constraint->messageDuplicateUsername) + ->setParameters([ + '%username%' => $value->getUsername() + ]) + ->atPath('username') + ->addViolation() + ; + } + + $countUsersByEmail = $this->em->createQuery( + sprintf( + "SELECT COUNT(u) FROM %s u " + . "WHERE u.emailCanonical = LOWER(UNACCENT(:email)) " + . "AND u != :user", + User::class) + ) + ->setParameter('email', $value->getEmail()) + ->setParameter('user', $value) + ->getSingleScalarResult(); + + if ($countUsersByEmail > 0) { + $this->context + ->buildViolation($constraint->messageDuplicateEmail) + ->setParameters([ + '%email%' => $value->getEmail() + ]) + ->atPath('email') + ->addViolation() + ; + } + } +}