diff --git a/.env b/.env index 3a6f8fb..e39ec66 100644 --- a/.env +++ b/.env @@ -25,7 +25,7 @@ APP_SECRET= # # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db" # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" -DATABASE_URL="mysql://app:!ChangeMe!@database:3306/app?serverVersion=8.0.32&charset=utf8mb4" +DATABASE_URL="mysql://escapepage:b.0nqrxJ%%2FD%%2ALuf9N@localhost:3306/escapepage?serverVersion=8.0.32&charset=utf8mb4" ###< doctrine/doctrine-bundle ### ###> symfony/messenger ### diff --git a/.idea/escapepage.iml b/.idea/escapepage.iml index beb36aa..5dca623 100644 --- a/.idea/escapepage.iml +++ b/.idea/escapepage.iml @@ -138,6 +138,8 @@ + + diff --git a/.idea/php.xml b/.idea/php.xml index 6852c14..26b72af 100644 --- a/.idea/php.xml +++ b/.idea/php.xml @@ -12,7 +12,7 @@ - + @@ -151,14 +151,14 @@ + + - - - + - + @@ -171,10 +171,10 @@ - + - + \ No newline at end of file diff --git a/assets/game1.js b/assets/game1.js index e6d5ffe..0e9a995 100644 --- a/assets/game1.js +++ b/assets/game1.js @@ -69,6 +69,7 @@ document.addEventListener('DOMContentLoaded', async () => { const topic = cfgEl.dataset.topic; const apiPingUrl = cfgEl.dataset.apiPingUrl; const apiEchoUrl = cfgEl.dataset.apiEchoUrl; + const userId = cfgEl.dataset.userId; if (mercurePublicUrl && topic) { subscribeToMercure(mercurePublicUrl, topic); @@ -88,7 +89,7 @@ document.addEventListener('DOMContentLoaded', async () => { if (apiEchoUrl) { const echo = await fetchJson(apiEchoUrl, { method: 'POST', - body: { hello: 'from game1.js', ts: new Date().toISOString() }, + body: { message: 'from game1.js', ts: new Date().toISOString(), user: userId }, }); console.log('[API][game1] echo →', echo); } else { diff --git a/composer.json b/composer.json index 15f4edf..62d0f03 100644 --- a/composer.json +++ b/composer.json @@ -4,7 +4,7 @@ "minimum-stability": "stable", "prefer-stable": true, "require": { - "php": ">=8.5.1", + "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*", "doctrine/dbal": "^3", @@ -25,8 +25,8 @@ "symfony/http-client": "7.3.*", "symfony/intl": "7.3.*", "symfony/mailer": "7.3.*", + "symfony/mercure-bundle": "^0.3", "symfony/mime": "7.3.*", - "symfony/sendgrid-mailer": "7.3.*", "symfony/monolog-bundle": "^3.0", "symfony/notifier": "7.3.*", "symfony/process": "7.3.*", @@ -34,6 +34,7 @@ "symfony/property-info": "7.3.*", "symfony/runtime": "7.3.*", "symfony/security-bundle": "7.3.*", + "symfony/sendgrid-mailer": "7.3.*", "symfony/serializer": "7.3.*", "symfony/stimulus-bundle": "^2.30", "symfony/string": "7.3.*", @@ -42,11 +43,12 @@ "symfony/ux-turbo": "^2.30", "symfony/validator": "7.3.*", "symfony/web-link": "7.3.*", - "symfony/yaml": "7.3.*", - "twig/extra-bundle": "^2.12|^3.0", - "twig/twig": "^2.12|^3.0", "symfony/webpack-encore-bundle": "^2.1", - "symfony/mercure-bundle": "^0.3" + "symfony/yaml": "7.3.*", + "symfonycasts/reset-password-bundle": "^1.24", + "symfonycasts/verify-email-bundle": "^1.18", + "twig/extra-bundle": "^2.12|^3.0", + "twig/twig": "^2.12|^3.0" }, "config": { "allow-plugins": { diff --git a/composer.lock b/composer.lock index b918f0b..05f4ca8 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": "7b090cdc9768a74bdf1ef02cc14e5d8c", + "content-hash": "8e2419832c0841e325a5b748bde61a48", "packages": [ { "name": "composer/semver", @@ -7969,6 +7969,98 @@ ], "time": "2025-12-04T18:07:52+00:00" }, + { + "name": "symfonycasts/reset-password-bundle", + "version": "v1.24.0", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/reset-password-bundle.git", + "reference": "8e5f8f821260ccfe8085563a93b418d3ef9af29f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/reset-password-bundle/zipball/8e5f8f821260ccfe8085563a93b418d3ef9af29f", + "reference": "8e5f8f821260ccfe8085563a93b418d3ef9af29f", + "shasum": "" + }, + "require": { + "php": ">=8.1.10", + "symfony/config": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/dependency-injection": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/deprecation-contracts": "^2.2 | ^3.0", + "symfony/http-kernel": "^5.4 | ^6.0 | ^7.0 | ^8.0" + }, + "require-dev": { + "doctrine/annotations": "^1.0", + "doctrine/doctrine-bundle": "^2.8", + "doctrine/orm": "^2.13", + "symfony/framework-bundle": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/phpunit-bridge": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/process": "^6.4 | ^7.0 | ^8.0", + "symfonycasts/internal-test-helpers": "dev-main" + }, + "type": "symfony-bundle", + "autoload": { + "psr-4": { + "SymfonyCasts\\Bundle\\ResetPassword\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "Symfony bundle that adds password reset functionality.", + "support": { + "issues": "https://github.com/SymfonyCasts/reset-password-bundle/issues", + "source": "https://github.com/SymfonyCasts/reset-password-bundle/tree/v1.24.0" + }, + "time": "2025-11-29T13:26:50+00:00" + }, + { + "name": "symfonycasts/verify-email-bundle", + "version": "v1.18.0", + "source": { + "type": "git", + "url": "https://github.com/SymfonyCasts/verify-email-bundle.git", + "reference": "ae0e6228c240a3fa20f2df5528f2fed97b806cab" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/SymfonyCasts/verify-email-bundle/zipball/ae0e6228c240a3fa20f2df5528f2fed97b806cab", + "reference": "ae0e6228c240a3fa20f2df5528f2fed97b806cab", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/config": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/dependency-injection": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/deprecation-contracts": "^2.2 | ^3.0", + "symfony/http-kernel": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/routing": "^5.4 | ^6.0 | ^7.0 | ^8.0" + }, + "require-dev": { + "doctrine/orm": "^2.7", + "doctrine/persistence": "^2.0", + "symfony/framework-bundle": "^5.4 | ^6.0 | ^7.0 | ^8.0", + "symfony/phpunit-bridge": "^5.4 | ^6.0 | ^7.0 | ^8.0" + }, + "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.18.0" + }, + "time": "2025-11-29T11:53:37+00:00" + }, { "name": "twig/extra-bundle", "version": "v3.22.2", @@ -10502,7 +10594,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": ">=8.5.1", + "php": ">=8.2", "ext-ctype": "*", "ext-iconv": "*" }, diff --git a/config/bundles.php b/config/bundles.php index 003cd9c..a28579b 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -15,4 +15,6 @@ return [ Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true], Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], + SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true], + SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle::class => ['all' => true], ]; diff --git a/config/packages/reset_password.yaml b/config/packages/reset_password.yaml new file mode 100644 index 0000000..e53f102 --- /dev/null +++ b/config/packages/reset_password.yaml @@ -0,0 +1,2 @@ +symfonycasts_reset_password: + request_password_repository: App\Tech\Repository\ResetPasswordRequestRepository diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 367af25..6c029ed 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -4,20 +4,28 @@ security: Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider providers: - users_in_memory: { memory: null } + app_user_provider: + entity: + class: App\Tech\Entity\User + property: username firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: lazy: true - provider: users_in_memory - - # activate different ways to authenticate - # https://symfony.com/doc/current/security.html#the-firewall - - # https://symfony.com/doc/current/security/impersonating_user.html - # switch_user: true + provider: app_user_provider + user_checker: App\Tech\Service\UserChecker + form_login: + login_path: app_login + check_path: app_login + enable_csrf: true + username_parameter: username + password_parameter: password + logout: + path: app_logout + # where to redirect after logout + # target: app_any_route # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used diff --git a/config/routes.yaml b/config/routes.yaml index adbeb26..f0a300d 100644 --- a/config/routes.yaml +++ b/config/routes.yaml @@ -13,6 +13,12 @@ game_controllers: type: attribute prefix: /game +tech_controllers: + resource: + path: ../src/Tech/Controller/ + namespace: App\Tech\Controller + type: attribute + # Uncomment when you add base controllers # base_controllers: # resource: diff --git a/migrations/Version20260103210448.php b/migrations/Version20260103210448.php new file mode 100644 index 0000000..9640451 --- /dev/null +++ b/migrations/Version20260103210448.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE `user` (id INT AUTO_INCREMENT NOT NULL, email VARCHAR(180) NOT NULL, roles JSON NOT NULL, password VARCHAR(255) NOT NULL, is_verified TINYINT(1) NOT NULL, UNIQUE INDEX UNIQ_IDENTIFIER_EMAIL (email), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE messenger_messages (id BIGINT AUTO_INCREMENT NOT NULL, body LONGTEXT NOT NULL, headers LONGTEXT NOT NULL, queue_name VARCHAR(190) NOT NULL, created_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', available_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', delivered_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_75EA56E0FB7336F0E3BD61CE16BA31DBBF396750 (queue_name, available_at, delivered_at, id), 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`'); + $this->addSql('DROP TABLE messenger_messages'); + } +} diff --git a/migrations/Version20260103212025.php b/migrations/Version20260103212025.php new file mode 100644 index 0000000..ead1703 --- /dev/null +++ b/migrations/Version20260103212025.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE email_log (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, email_identifier VARCHAR(255) NOT NULL, sent_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_6FB4883A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE email_log ADD CONSTRAINT FK_6FB4883A76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE email_log DROP FOREIGN KEY FK_6FB4883A76ED395'); + $this->addSql('DROP TABLE email_log'); + } +} diff --git a/migrations/Version20260103214856.php b/migrations/Version20260103214856.php new file mode 100644 index 0000000..9e6d1bd --- /dev/null +++ b/migrations/Version20260103214856.php @@ -0,0 +1,33 @@ +addSql('ALTER TABLE user ADD username VARCHAR(180) NOT NULL'); + $this->addSql('CREATE UNIQUE INDEX UNIQ_IDENTIFIER_USERNAME ON user (username)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP INDEX UNIQ_IDENTIFIER_USERNAME ON `user`'); + $this->addSql('ALTER TABLE `user` DROP username'); + } +} diff --git a/migrations/Version20260103215543.php b/migrations/Version20260103215543.php new file mode 100644 index 0000000..acd9f3c --- /dev/null +++ b/migrations/Version20260103215543.php @@ -0,0 +1,33 @@ +addSql('CREATE TABLE reset_password_request (id INT AUTO_INCREMENT NOT NULL, user_id INT NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', expires_at DATETIME NOT NULL COMMENT \'(DC2Type:datetime_immutable)\', INDEX IDX_7CE748AA76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES `user` (id)'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE reset_password_request DROP FOREIGN KEY FK_7CE748AA76ED395'); + $this->addSql('DROP TABLE reset_password_request'); + } +} diff --git a/logo.png b/public/images/logo.png similarity index 100% rename from logo.png rename to public/images/logo.png diff --git a/src/Game/Controller/GameApiController.php b/src/Game/Controller/GameApiController.php index 04da718..57ba364 100644 --- a/src/Game/Controller/GameApiController.php +++ b/src/Game/Controller/GameApiController.php @@ -3,6 +3,7 @@ declare(strict_types=1); namespace App\Game\Controller; +use App\Game\Service\GameResponseService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\Request; @@ -12,6 +13,12 @@ use Symfony\Component\Routing\Annotation\Route; #[Route('/game/api', name: 'game_api_')] final class GameApiController extends AbstractController { + + public function __construct( + protected GameResponseService $gameResponseService) { + + } + #[Route('/ping', name: 'ping', methods: ['GET'])] public function ping(): JsonResponse { @@ -22,27 +29,18 @@ final class GameApiController extends AbstractController ]); } - #[Route('/echo', name: 'echo', methods: ['POST'])] - public function echo(Request $request): JsonResponse + #[Route('/message', name: 'message', methods: ['POST'])] + public function message(Request $request): JsonResponse { $raw = (string) $request->getContent(); $data = null; if ($raw !== '') { - try { - /** @var array|null $decoded */ - $decoded = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); - $data = $decoded; - } catch (\Throwable $e) { - return $this->json([ - 'ok' => false, - 'error' => 'Invalid JSON: ' . $e->getMessage(), - ], Response::HTTP_BAD_REQUEST); - } + $data = $this->gameResponseService->getGameResponse($raw); } return $this->json([ 'ok' => true, - 'received' => $data, + 'result' => $data, 'ts' => date('c'), ]); } diff --git a/src/Game/Controller/GameController.php b/src/Game/Controller/GameController.php index dbca841..2087205 100644 --- a/src/Game/Controller/GameController.php +++ b/src/Game/Controller/GameController.php @@ -12,6 +12,8 @@ final class GameController extends AbstractController #[Route(path: '', name: 'game')] public function index(): Response { - return $this->render('game/index.html.twig'); + return $this->render('game/index.html.twig', [ + 'user_id' => $this->getUser()?->getId(), + ]); } } diff --git a/src/Game/Service/GameResponseService.php b/src/Game/Service/GameResponseService.php new file mode 100644 index 0000000..d60bdf1 --- /dev/null +++ b/src/Game/Service/GameResponseService.php @@ -0,0 +1,20 @@ +query->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 { + $emailVerifier->handleEmailConfirmation($request, $user); + } catch (VerifyEmailExceptionInterface $exception) { + $this->addFlash('error', $exception->getReason()); + + return $this->redirectToRoute('app_register'); + } + + $this->addFlash('success', 'Your email address has been verified. You can now log in.'); + + return $this->redirectToRoute('app_login'); + } +} diff --git a/src/Tech/Controller/RegistrationController.php b/src/Tech/Controller/RegistrationController.php new file mode 100644 index 0000000..04e84a7 --- /dev/null +++ b/src/Tech/Controller/RegistrationController.php @@ -0,0 +1,55 @@ +createForm(RegistrationFormType::class, $user); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // encode the plain password + $user->setPassword( + $userPasswordHasher->hashPassword( + $user, + $form->get('plainPassword')->getData() + ) + ); + + $entityManager->persist($user); + $entityManager->flush(); + + // generate a signed url and email it to the user + $emailVerifier->sendEmailConfirmation('app_verify_email', $user, + (new TemplatedEmail()) + ->from('noreply@escapepage.dev') + ->to($user->getEmail()) + ->subject('Please Confirm your Email') + ->htmlTemplate('tech/registration/confirmation_email.html.twig') + ); + + $this->addFlash('success', 'A confirmation email has been sent to your email address.'); + + return $this->redirectToRoute('website_home'); + } + + return $this->render('tech/registration/register.html.twig', [ + 'registrationForm' => $form->createView(), + ]); + } +} diff --git a/src/Tech/Controller/ResetPasswordController.php b/src/Tech/Controller/ResetPasswordController.php new file mode 100644 index 0000000..7652561 --- /dev/null +++ b/src/Tech/Controller/ResetPasswordController.php @@ -0,0 +1,176 @@ +createForm(ResetPasswordRequestFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + /** @var string $email */ + $email = $form->get('email')->getData(); + + return $this->processSendingPasswordResetEmail($email, $mailer, $translator + ); + } + + return $this->render('tech/reset_password/request.html.twig', [ + 'requestForm' => $form, + ]); + } + + /** + * Confirmation page after a user has requested a password reset. + */ + #[Route('/check-email', name: 'app_check_email')] + public function checkEmail(): Response + { + // Generate a fake token if the user does not exist or someone hit this page directly. + // This prevents exposing whether or not a user was found with the given email address or not + if (null === ($resetToken = $this->getTokenObjectFromSession())) { + $resetToken = $this->resetPasswordHelper->generateFakeResetToken(); + } + + return $this->render('tech/reset_password/check_email.html.twig', [ + 'resetToken' => $resetToken, + ]); + } + + /** + * Validates and process the reset URL that the user clicked in their email. + */ + #[Route('/reset/{token}', name: 'app_reset_password')] + public function reset(Request $request, UserPasswordHasherInterface $passwordHasher, TranslatorInterface $translator, ?string $token = null): Response + { + if ($token) { + // We store the token in session and remove it from the URL, to avoid the URL being + // loaded in a browser and potentially leaking the token to 3rd party JavaScript. + $this->storeTokenInSession($token); + + return $this->redirectToRoute('app_reset_password'); + } + + $token = $this->getTokenFromSession(); + + if (null === $token) { + throw $this->createNotFoundException('No reset password token found in the URL or in the session.'); + } + + try { + /** @var User $user */ + $user = $this->resetPasswordHelper->validateTokenAndFetchUser($token); + } catch (ResetPasswordExceptionInterface $e) { + $this->addFlash('reset_password_error', sprintf( + '%s - %s', + $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'), + $translator->trans($e->getReason(), [], 'ResetPasswordBundle') + )); + + return $this->redirectToRoute('app_forgot_password_request'); + } + + // The token is valid; allow the user to change their password. + $form = $this->createForm(ChangePasswordFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + // A password reset token should be used only once, remove it. + $this->resetPasswordHelper->removeResetRequest($token); + + /** @var string $plainPassword */ + $plainPassword = $form->get('plainPassword')->getData(); + + // Encode(hash) the plain password, and set it. + $user->setPassword($passwordHasher->hashPassword($user, $plainPassword)); + $this->entityManager->flush(); + + // The session is cleaned up after the password has been changed. + $this->cleanSessionAfterReset(); + + return $this->redirectToRoute('website_home'); + } + + return $this->render('tech/reset_password/reset.html.twig', [ + 'resetForm' => $form, + ]); + } + + private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse + { + $user = $this->entityManager->getRepository(User::class)->findOneBy([ + 'email' => $emailFormData, + ]); + + // Do not reveal whether a user account was found or not. + if (!$user) { + return $this->redirectToRoute('app_check_email'); + } + + try { + $resetToken = $this->resetPasswordHelper->generateResetToken($user); + } catch (ResetPasswordExceptionInterface $e) { + // If you want to tell the user why a reset email was not sent, uncomment + // the lines below and change the redirect to 'app_forgot_password_request'. + // Caution: This may reveal if a user is registered or not. + // + // $this->addFlash('reset_password_error', sprintf( + // '%s - %s', + // $translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE, [], 'ResetPasswordBundle'), + // $translator->trans($e->getReason(), [], 'ResetPasswordBundle') + // )); + + return $this->redirectToRoute('app_check_email'); + } + + $email = (new TemplatedEmail()) + ->from(new Address('noreply@escapepage.com', 'Escapepage')) + ->to((string) $user->getEmail()) + ->subject('Your password reset request') + ->htmlTemplate('tech/reset_password/email.html.twig') + ->context([ + 'resetToken' => $resetToken, + ]) + ; + + $mailer->send($email); + + // Store the token object in session for retrieval in check-email route. + $this->setTokenObjectInSession($resetToken); + + return $this->redirectToRoute('app_check_email'); + } +} diff --git a/src/Tech/Controller/SecurityController.php b/src/Tech/Controller/SecurityController.php new file mode 100644 index 0000000..a7bea2a --- /dev/null +++ b/src/Tech/Controller/SecurityController.php @@ -0,0 +1,32 @@ +getUser()) { + // return $this->redirectToRoute('target_path'); + // } + + // get the login error if there is one + $error = $authenticationUtils->getLastAuthenticationError(); + // last username entered by the user + $lastUsername = $authenticationUtils->getLastUsername(); + + return $this->render('tech/security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]); + } + + #[Route(path: '/logout', name: 'app_logout')] + public function logout(): void + { + throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.'); + } +} diff --git a/src/Tech/Entity/EmailLog.php b/src/Tech/Entity/EmailLog.php new file mode 100644 index 0000000..5a295e4 --- /dev/null +++ b/src/Tech/Entity/EmailLog.php @@ -0,0 +1,68 @@ +id; + } + + public function getUser(): ?User + { + return $this->user; + } + + public function setUser(?User $user): static + { + $this->user = $user; + + return $this; + } + + public function getEmailIdentifier(): ?string + { + return $this->emailIdentifier; + } + + public function setEmailIdentifier(string $emailIdentifier): static + { + $this->emailIdentifier = $emailIdentifier; + + return $this; + } + + public function getSentAt(): ?\DateTimeImmutable + { + return $this->sentAt; + } + + public function setSentAt(\DateTimeImmutable $sentAt): static + { + $this->sentAt = $sentAt; + + return $this; + } +} diff --git a/src/Tech/Entity/ResetPasswordRequest.php b/src/Tech/Entity/ResetPasswordRequest.php new file mode 100644 index 0000000..111ab70 --- /dev/null +++ b/src/Tech/Entity/ResetPasswordRequest.php @@ -0,0 +1,40 @@ +user = $user; + $this->initialize($expiresAt, $selector, $hashedToken); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getUser(): User + { + return $this->user; + } +} diff --git a/src/Tech/Entity/User.php b/src/Tech/Entity/User.php new file mode 100644 index 0000000..c862ac2 --- /dev/null +++ b/src/Tech/Entity/User.php @@ -0,0 +1,140 @@ + The user roles + */ + #[ORM\Column] + private array $roles = []; + + /** + * @var string The hashed password + */ + #[ORM\Column] + private ?string $password = null; + + #[ORM\Column(type: 'boolean')] + private bool $isVerified = false; + + public function getId(): ?int + { + return $this->id; + } + + public function getEmail(): ?string + { + return $this->email; + } + + public function setEmail(string $email): static + { + $this->email = $email; + + return $this; + } + + public function getUsername(): ?string + { + return $this->username; + } + + public function setUsername(string $username): static + { + $this->username = $username; + + return $this; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUserIdentifier(): string + { + return (string) $this->username; + } + + /** + * @see UserInterface + * + * @return list + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + /** + * @param list $roles + */ + public function setRoles(array $roles): static + { + $this->roles = $roles; + + return $this; + } + + /** + * @see PasswordAuthenticatedUserInterface + */ + public function getPassword(): ?string + { + return $this->password; + } + + public function setPassword(string $password): static + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function eraseCredentials(): void + { + // 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): static + { + $this->isVerified = $isVerified; + + return $this; + } +} diff --git a/src/Tech/EventListener/EmailLoggerListener.php b/src/Tech/EventListener/EmailLoggerListener.php new file mode 100644 index 0000000..848a825 --- /dev/null +++ b/src/Tech/EventListener/EmailLoggerListener.php @@ -0,0 +1,54 @@ +getMessage(); + if (!$message instanceof TemplatedEmail) { + return; + } + + $recipients = $message->getTo(); + foreach ($recipients as $recipient) { + if (!$recipient instanceof Address) { + continue; + } + + $user = $this->userRepository->findOneBy(['email' => $recipient->getAddress()]); + if (!$user) { + continue; + } + + $emailLog = new EmailLog(); + $emailLog->setUser($user); + $emailLog->setSentAt(new \DateTimeImmutable()); + + // Try to get the template name, or use the subject as identifier + $identifier = $message->getHtmlTemplate() ?: $message->getTextTemplate() ?: $message->getSubject(); + $emailLog->setEmailIdentifier($identifier); + + $this->entityManager->persist($emailLog); + } + + $this->entityManager->flush(); + } +} diff --git a/src/Tech/Form/ChangePasswordFormType.php b/src/Tech/Form/ChangePasswordFormType.php new file mode 100644 index 0000000..b2bd7f6 --- /dev/null +++ b/src/Tech/Form/ChangePasswordFormType.php @@ -0,0 +1,58 @@ +add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'options' => [ + 'attr' => [ + 'autocomplete' => 'new-password', + ], + ], + 'first_options' => [ + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter a password', + ]), + new Length([ + 'min' => 12, + 'minMessage' => 'Your password should be at least {{ limit }} characters', + // max length allowed by Symfony for security reasons + 'max' => 4096, + ]), + new PasswordStrength(), + new NotCompromisedPassword(), + ], + 'label' => 'New password', + ], + 'second_options' => [ + 'label' => 'Repeat Password', + ], + 'invalid_message' => 'The password fields must match.', + // Instead of being set onto the object directly, + // this is read and encoded in the controller + 'mapped' => false, + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Tech/Form/RegistrationFormType.php b/src/Tech/Form/RegistrationFormType.php new file mode 100644 index 0000000..1120017 --- /dev/null +++ b/src/Tech/Form/RegistrationFormType.php @@ -0,0 +1,56 @@ +add('email', EmailType::class) + ->add('username', TextType::class, [ + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter a username', + ]), + ], + ]) + ->add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'mapped' => false, + 'attr' => ['autocomplete' => 'new-password'], + 'first_options' => ['label' => 'Password'], + 'second_options' => ['label' => 'Repeat Password'], + '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): void + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} diff --git a/src/Tech/Form/ResetPasswordRequestFormType.php b/src/Tech/Form/ResetPasswordRequestFormType.php new file mode 100644 index 0000000..26f51a4 --- /dev/null +++ b/src/Tech/Form/ResetPasswordRequestFormType.php @@ -0,0 +1,31 @@ +add('email', EmailType::class, [ + 'attr' => ['autocomplete' => 'email'], + 'constraints' => [ + new NotBlank([ + 'message' => 'Please enter your email', + ]), + ], + ]) + ; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Tech/Repository/EmailLogRepository.php b/src/Tech/Repository/EmailLogRepository.php new file mode 100644 index 0000000..a15a2e3 --- /dev/null +++ b/src/Tech/Repository/EmailLogRepository.php @@ -0,0 +1,18 @@ + + */ +class EmailLogRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, EmailLog::class); + } +} diff --git a/src/Tech/Repository/ResetPasswordRequestRepository.php b/src/Tech/Repository/ResetPasswordRequestRepository.php new file mode 100644 index 0000000..78f14e3 --- /dev/null +++ b/src/Tech/Repository/ResetPasswordRequestRepository.php @@ -0,0 +1,32 @@ + + */ +class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface +{ + use ResetPasswordRequestRepositoryTrait; + + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, ResetPasswordRequest::class); + } + + /** + * @param User $user + */ + public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface + { + return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken); + } +} diff --git a/src/Tech/Repository/UserRepository.php b/src/Tech/Repository/UserRepository.php new file mode 100644 index 0000000..3417362 --- /dev/null +++ b/src/Tech/Repository/UserRepository.php @@ -0,0 +1,35 @@ + + */ +class UserRepository extends ServiceEntityRepository implements PasswordUpgraderInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, User::class); + } + + /** + * Used to upgrade (rehash) the user's password automatically over time. + */ + public function upgradePassword(PasswordAuthenticatedUserInterface $user, string $newHashedPassword): void + { + if (!$user instanceof User) { + throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', $user::class)); + } + + $user->setPassword($newHashedPassword); + $this->getEntityManager()->persist($user); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Tech/Service/EmailVerifier.php b/src/Tech/Service/EmailVerifier.php new file mode 100644 index 0000000..a798efc --- /dev/null +++ b/src/Tech/Service/EmailVerifier.php @@ -0,0 +1,53 @@ +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); + } + + /** + * @throws VerifyEmailExceptionInterface + */ + public function handleEmailConfirmation(Request $request, User $user): void + { + $this->verifyEmailHelper->validateEmailConfirmation($request->getUri(), (string) $user->getId(), $user->getEmail()); + + $user->setIsVerified(true); + + $this->entityManager->persist($user); + $this->entityManager->flush(); + } +} diff --git a/src/Tech/Service/UserChecker.php b/src/Tech/Service/UserChecker.php new file mode 100644 index 0000000..11a621e --- /dev/null +++ b/src/Tech/Service/UserChecker.php @@ -0,0 +1,26 @@ +isVerified()) { + throw new CustomUserMessageAuthenticationException('Your email address is not verified.'); + } + } + + public function checkPostAuth(UserInterface $user): void + { + } +} diff --git a/symfony.lock b/symfony.lock index cdf33ac..ac5be9a 100644 --- a/symfony.lock +++ b/symfony.lock @@ -356,6 +356,21 @@ "./webpack.config.js" ] }, + "symfonycasts/reset-password-bundle": { + "version": "1.24", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "1.0", + "ref": "97c1627c0384534997ae1047b93be517ca16de43" + }, + "files": [ + "./config/packages/reset_password.yaml" + ] + }, + "symfonycasts/verify-email-bundle": { + "version": "v1.18.0" + }, "twig/extra-bundle": { "version": "v3.21.0" } diff --git a/templates/base.html.twig b/templates/base.html.twig index 242e28f..f80dc10 100644 --- a/templates/base.html.twig +++ b/templates/base.html.twig @@ -14,10 +14,23 @@ {% set pathinfo = app.request.pathinfo %} {{ 'nav.home'|trans }} | - {{ 'nav.game'|trans }} + {{ 'nav.game'|trans }} | + {% if app.user %} + Logout + {% else %} + Register | + Login + {% endif %} + {% for label, messages in app.flashes %} + {% for message in messages %} + + {{ message }} + + {% endfor %} + {% endfor %} {% block body %}{% endblock %}