diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 0e4cf3d..401f552 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -1,7 +1,15 @@ security: + encoders: + App\Entity\User: + algorithm: auto + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers providers: - users_in_memory: { memory: null } + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: email firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ @@ -9,7 +17,14 @@ security: main: anonymous: true lazy: true - provider: users_in_memory + provider: app_user_provider + guard: + authenticators: + - App\Security\PokedexAuthenticator + logout: + path: app_logout + # where to redirect after logout + # target: app_any_route # activate different ways to authenticate # https://symfony.com/doc/current/security.html#firewalls-authentication @@ -21,4 +36,6 @@ security: # Note: Only the *first* access control that matches will be used access_control: # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } + - { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/register, roles: IS_AUTHENTICATED_ANONYMOUSLY } + - { path: ^/, roles: IS_AUTHENTICATED_FULLY } diff --git a/migrations/Version20210416121426.php b/migrations/Version20210416121426.php new file mode 100644 index 0000000..ce35635 --- /dev/null +++ b/migrations/Version20210416121426.php @@ -0,0 +1,31 @@ +addSql('CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles LONGTEXT NOT NULL COMMENT \'(DC2Type:json)\', password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_8D93D649E7927C74 (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + } + + public function down(Schema $schema) : void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE user'); + } +} diff --git a/src/Controller/RegistrationController.php b/src/Controller/RegistrationController.php new file mode 100644 index 0000000..148c746 --- /dev/null +++ b/src/Controller/RegistrationController.php @@ -0,0 +1,52 @@ +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(); + // 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(), + ]); + } +} diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php new file mode 100644 index 0000000..d79875f --- /dev/null +++ b/src/Controller/SecurityController.php @@ -0,0 +1,36 @@ +getUser()) { + return $this->redirectToRoute('pokemon_index'); + } + + // get the login error if there is one + $error = $authenticationUtils->getLastAuthenticationError(); + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); + } + + /** + * @Route("/logout", name="app_logout") + */ + public function logout() + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/src/Entity/User.php b/src/Entity/User.php new file mode 100644 index 0000000..9afc42a --- /dev/null +++ b/src/Entity/User.php @@ -0,0 +1,119 @@ +id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): self + { + $this->email = $email; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUsername(): string + { + return (string) $this->email; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see UserInterface + */ + public function getPassword(): string + { + return (string) $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * Returning a salt is only needed, if you are not using a modern + * hashing algorithm (e.g. bcrypt or sodium) in your security.yaml. + * + * @see UserInterface + */ + public function getSalt(): ?string + { + return null; + } + + /** + * @see UserInterface + */ + public function eraseCredentials() + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } +} 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/Repository/UserRepository.php b/src/Repository/UserRepository.php new file mode 100644 index 0000000..1a38975 --- /dev/null +++ b/src/Repository/UserRepository.php @@ -0,0 +1,67 @@ +setPassword($newEncodedPassword); + $this->_em->persist($user); + $this->_em->flush(); + } + + // /** + // * @return User[] Returns an array of User objects + // */ + /* + public function findByExampleField($value) + { + return $this->createQueryBuilder('u') + ->andWhere('u.exampleField = :val') + ->setParameter('val', $value) + ->orderBy('u.id', 'ASC') + ->setMaxResults(10) + ->getQuery() + ->getResult() + ; + } + */ + + /* + public function findOneBySomeField($value): ?User + { + return $this->createQueryBuilder('u') + ->andWhere('u.exampleField = :val') + ->setParameter('val', $value) + ->getQuery() + ->getOneOrNullResult() + ; + } + */ +} diff --git a/src/Security/PokedexAuthenticator.php b/src/Security/PokedexAuthenticator.php new file mode 100644 index 0000000..d8968d9 --- /dev/null +++ b/src/Security/PokedexAuthenticator.php @@ -0,0 +1,110 @@ +entityManager = $entityManager; + $this->urlGenerator = $urlGenerator; + $this->csrfTokenManager = $csrfTokenManager; + $this->passwordEncoder = $passwordEncoder; + } + + public function supports(Request $request) + { + return self::LOGIN_ROUTE === $request->attributes->get('_route') + && $request->isMethod('POST'); + } + + public function getCredentials(Request $request) + { + $credentials = [ + 'email' => $request->request->get('email'), + 'password' => $request->request->get('password'), + 'csrf_token' => $request->request->get('_csrf_token'), + ]; + $request->getSession()->set( + Security::LAST_USERNAME, + $credentials['email'] + ); + + return $credentials; + } + + public function getUser($credentials, UserProviderInterface $userProvider) + { + $token = new CsrfToken('authenticate', $credentials['csrf_token']); + if (!$this->csrfTokenManager->isTokenValid($token)) { + throw new InvalidCsrfTokenException(); + } + + $user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => $credentials['email']]); + + if (!$user) { + // fail authentication with a custom error + throw new CustomUserMessageAuthenticationException('Email could not be found.'); + } + + return $user; + } + + public function checkCredentials($credentials, UserInterface $user) + { + return $this->passwordEncoder->isPasswordValid($user, $credentials['password']); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function getPassword($credentials): ?string + { + return $credentials['password']; + } + + public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey) + { + if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) { + return new RedirectResponse($targetPath); + } + + // For example : return new RedirectResponse($this->urlGenerator->generate('some_route')); + // throw new \Exception('provide a valid redirect inside '.__FILE__); + + return new RedirectResponse($this->urlGenerator->generate('pokemon_index')); + + } + + protected function getLoginUrl() + { + return $this->urlGenerator->generate(self::LOGIN_ROUTE); + } +} diff --git a/templates/base.html.twig b/templates/base.html.twig index 42c6fac..a50fbea 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -25,7 +25,10 @@
diff --git a/templates/registration/register.html.twig b/templates/registration/register.html.twig new file mode 100644 index 0000000..b8cb4f8 --- /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') %} +