From acbe9c7f636da13941265d7000e1e2cf59aa9700 Mon Sep 17 00:00:00 2001 From: Lang <7system7@gmail.com> Date: Sun, 12 Apr 2026 08:49:47 +0200 Subject: [PATCH] chg: dev: refactor all forms to have Symfony Form Types & Validation Constrainsts - & implement Google ReCapthca v3 #4 --- composer.json | 2 + composer.lock | 370 ++++++++++++++++++- config/packages/csrf.yaml | 11 + config/packages/twig.yaml | 1 + config/packages/validator.yaml | 11 + src/Controller/SecurityController.php | 134 +++---- src/Entity/User.php | 6 +- src/EventListener/LoginCaptchaListener.php | 56 +++ src/Form/ForgotPasswordFormType.php | 48 +++ src/Form/RecaptchaType.php | 61 +++ src/Form/RegistrationFormType.php | 80 ++++ src/Form/ResetPasswordFormType.php | 56 +++ src/Service/RecaptchaService.php | 69 ++++ src/Validator/Recaptcha.php | 30 ++ src/Validator/RecaptchaValidator.php | 51 +++ symfony.lock | 24 ++ templates/Security/forgot_password.html.twig | 51 ++- templates/Security/login.html.twig | 19 + templates/Security/profile.html.twig | 204 +++++----- templates/Security/register.html.twig | 227 ++++++------ templates/Security/reset_password.html.twig | 93 +++-- 21 files changed, 1253 insertions(+), 351 deletions(-) create mode 100644 config/packages/csrf.yaml create mode 100644 config/packages/validator.yaml create mode 100644 src/EventListener/LoginCaptchaListener.php create mode 100644 src/Form/ForgotPasswordFormType.php create mode 100644 src/Form/RecaptchaType.php create mode 100644 src/Form/RegistrationFormType.php create mode 100644 src/Form/ResetPasswordFormType.php create mode 100644 src/Service/RecaptchaService.php create mode 100644 src/Validator/Recaptcha.php create mode 100644 src/Validator/RecaptchaValidator.php diff --git a/composer.json b/composer.json index 1936668..e73d1ae 100644 --- a/composer.json +++ b/composer.json @@ -12,6 +12,7 @@ "pentatrion/vite-bundle": "^8.2", "symfony/console": "7.4.*", "symfony/flex": "^2.10.0", + "symfony/form": "7.4.*", "symfony/framework-bundle": "7.4.*", "symfony/http-client": "7.4.*", "symfony/mailer": "7.4.*", @@ -21,6 +22,7 @@ "symfony/security-bundle": "7.4.*", "symfony/translation": "7.4.*", "symfony/twig-bundle": "7.4.*", + "symfony/validator": "7.4.*", "symfony/yaml": "7.4.*" }, "require-dev": { diff --git a/composer.lock b/composer.lock index 38ed0fa..6df18da 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": "a6a374918b3c989ed745ed4f0afd8b09", + "content-hash": "42a3734dddfef28ddaa1352541b4d23f", "packages": [ { "name": "doctrine/cache", @@ -3162,6 +3162,109 @@ ], "time": "2025-11-16T09:38:19+00:00" }, + { + "name": "symfony/form", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/form.git", + "reference": "fbb79fc4de32f091ec697276824331f5de3a87b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/form/zipball/fbb79fc4de32f091ec697276824331f5de3a87b4", + "reference": "fbb79fc4de32f091ec697276824331f5de3a87b4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/options-resolver": "^7.3|^8.0", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-intl-icu": "^1.21", + "symfony/polyfill-mbstring": "~1.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/service-contracts": "^2.5|^3" + }, + "conflict": { + "symfony/console": "<6.4", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<6.4", + "symfony/error-handler": "<6.4", + "symfony/framework-bundle": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/intl": "<7.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/translation-contracts": "<2.5", + "symfony/twig-bridge": "<6.4" + }, + "require-dev": { + "doctrine/collections": "^1.0|^2.0", + "symfony/clock": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/html-sanitizer": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^7.4|^8.0", + "symfony/security-core": "^6.4|^7.0|^8.0", + "symfony/security-csrf": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/uid": "^6.4|^7.0|^8.0", + "symfony/validator": "^6.4.12|^7.1.5|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Form\\": "" + }, + "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 to easily create, process and reuse HTML forms", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/form/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, { "name": "symfony/framework-bundle", "version": "v7.4.8", @@ -4203,6 +4306,77 @@ ], "time": "2026-04-02T18:23:01+00:00" }, + { + "name": "symfony/options-resolver", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/options-resolver.git", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\OptionsResolver\\": "" + }, + "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": "Provides an improved replacement for the array_replace PHP function", + "homepage": "https://symfony.com", + "keywords": [ + "config", + "configuration", + "options" + ], + "support": { + "source": "https://github.com/symfony/options-resolver/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-24T13:12:05+00:00" + }, { "name": "symfony/password-hasher", "version": "v7.4.8", @@ -4361,6 +4535,94 @@ ], "time": "2026-04-10T16:19:22+00:00" }, + { + "name": "symfony/polyfill-intl-icu", + "version": "v1.34.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-icu.git", + "reference": "3510b63d07376b04e57e27e82607d468bb134f78" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-icu/zipball/3510b63d07376b04e57e27e82607d468bb134f78", + "reference": "3510b63d07376b04e57e27e82607d468bb134f78", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance and support of other locales than \"en\"" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Icu\\": "" + }, + "classmap": [ + "Resources/stubs" + ], + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's ICU-related data and classes", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "icu", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-icu/tree/v1.34.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-10T16:50:15+00:00" + }, { "name": "symfony/polyfill-intl-idn", "version": "v1.34.0", @@ -6281,6 +6543,110 @@ ], "time": "2026-03-24T13:12:05+00:00" }, + { + "name": "symfony/validator", + "version": "v7.4.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/validator.git", + "reference": "8f73cbddae916756f319b3e195088da216f0f12f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/validator/zipball/8f73cbddae916756f319b3e195088da216f0f12f", + "reference": "8f73cbddae916756f319b3e195088da216f0f12f", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.0", + "symfony/polyfill-php83": "^1.27", + "symfony/translation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/lexer": "<1.1", + "symfony/dependency-injection": "<6.4", + "symfony/doctrine-bridge": "<7.0", + "symfony/expression-language": "<6.4", + "symfony/http-kernel": "<6.4", + "symfony/intl": "<6.4", + "symfony/property-info": "<6.4", + "symfony/translation": "<6.4.3|>=7.0,<7.0.3", + "symfony/var-exporter": "<6.4.25|>=7.0,<7.3.3", + "symfony/yaml": "<6.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3|^4", + "symfony/cache": "^6.4|^7.0|^8.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/expression-language": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/http-client": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/intl": "^6.4|^7.0|^8.0", + "symfony/mime": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/translation": "^6.4.3|^7.0.3|^8.0", + "symfony/type-info": "^7.1.8", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Validator\\": "" + }, + "exclude-from-classmap": [ + "/Tests/", + "/Resources/bin/" + ] + }, + "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": "Provides tools to validate values", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/validator/tree/v7.4.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T12:55:43+00:00" + }, { "name": "symfony/var-dumper", "version": "v7.4.8", @@ -8207,7 +8573,7 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.2", + "php": ">=8.5", "ext-iconv": "*", "ext-json": "*" }, diff --git a/config/packages/csrf.yaml b/config/packages/csrf.yaml new file mode 100644 index 0000000..dd07de8 --- /dev/null +++ b/config/packages/csrf.yaml @@ -0,0 +1,11 @@ +# Enable stateless CSRF protection for forms and logins/logouts +framework: + form: + csrf_protection: + token_id: submit + + csrf_protection: + stateless_token_ids: + - submit + - authenticate + - logout diff --git a/config/packages/twig.yaml b/config/packages/twig.yaml index 1ad85bb..7e56226 100644 --- a/config/packages/twig.yaml +++ b/config/packages/twig.yaml @@ -4,3 +4,4 @@ twig: strict_variables: '%kernel.debug%' globals: version: "%jotunheimr.version%" + recaptcha_site_key: "%env(RECAPTCHA_SITE_KEY)%" diff --git a/config/packages/validator.yaml b/config/packages/validator.yaml new file mode 100644 index 0000000..d13c8ea --- /dev/null +++ b/config/packages/validator.yaml @@ -0,0 +1,11 @@ +framework: + validation: + # Enables validator auto-mapping support. + # For instance, basic validation constraints will be inferred from Doctrine's metadata. + #auto_mapping: + # App\Entity\: [] + +when@test: + framework: + validation: + not_compromised_password: false diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index e0f1770..e224d94 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -11,6 +11,9 @@ namespace App\Controller; use App\Entity\User; +use App\Form\ForgotPasswordFormType; +use App\Form\RegistrationFormType; +use App\Form\ResetPasswordFormType; use App\Repository\UserRepository; use DateTime; use Doctrine\ORM\EntityManagerInterface; @@ -68,75 +71,45 @@ class SecurityController extends AbstractController return $this->redirectToRoute('MineSeekerBundle_homepage'); } - $errors = []; + $user = new User(); + $form = $this->createForm(RegistrationFormType::class, $user); + $form->handleRequest($request); - if ($request->isMethod('POST')) { - $username = trim((string) $request->request->get('_username', '')); - $email = trim((string) $request->request->get('_email', '')); - $password = (string) $request->request->get('_password', ''); - $passwordConfirm = (string) $request->request->get('_password_confirm', ''); + if ($form->isSubmitted() && $form->isValid()) { + $token = bin2hex(random_bytes(32)); - if (mb_strlen($username) < 3) { - $errors['username'] = 'Username must be at least 3 characters.'; - } elseif ($em->getRepository(User::class)->findOneBy(['username' => $username])) { - $errors['username'] = 'This username is already taken.'; - } + $user + ->setIsVerified(false) + ->setVerificationToken($token) + ->setPassword($hasher->hashPassword($user, $form->get('plainPassword')->getData())); - if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { - $errors['email'] = 'Please enter a valid email address.'; - } elseif ($em->getRepository(User::class)->findOneBy(['email' => $email])) { - $errors['email'] = 'This email address is already registered.'; - } + $em->persist($user); + $em->flush(); - if (mb_strlen($password) < 6) { - $errors['password'] = 'Password must be at least 6 characters.'; - } elseif ($password !== $passwordConfirm) { - $errors['password_confirm'] = 'Passwords do not match.'; - } + $activationUrl = $this->generateUrl( + 'MineSeekerBundle_activate', + ['token' => $token], + UrlGeneratorInterface::ABSOLUTE_URL, + ); - if (empty($errors)) { - $token = bin2hex(random_bytes(32)); + $mailer->send( + new TemplatedEmail() + ->from('noreply@mineseeker.ninja') + ->to($user->getEmail()) + ->subject('Activate your MineSeeker account') + ->htmlTemplate('emails/activation.html.twig') + ->context([ + 'username' => $user->getUsername(), + 'activation_url' => $activationUrl, + ]) + ); - $user = new User() - ->setUsername($username) - ->setEmail($email) - ->setIsVerified(false) - ->setVerificationToken($token); + $this->addFlash('verify_email', $user->getEmail()); - $user->setPassword($hasher->hashPassword($user, $password)); - - $em->persist($user); - $em->flush(); - - $activationUrl = $this->generateUrl( - 'MineSeekerBundle_activate', - ['token' => $token], - UrlGeneratorInterface::ABSOLUTE_URL, - ); - - $mailer->send( - new TemplatedEmail() - ->from('noreply@mineseeker.ninja') - ->to($email) - ->subject('Activate your MineSeeker account') - ->htmlTemplate('emails/activation.html.twig') - ->context([ - 'username' => $username, - 'activation_url' => $activationUrl, - ]) - ); - - $this->addFlash('verify_email', $email); - - return $this->redirectToRoute('MineSeekerBundle_register'); - } + return $this->redirectToRoute('MineSeekerBundle_register'); } - return $this->render('Security/register.html.twig', [ - 'errors' => $errors, - 'last_username' => $request->request->get('_username', ''), - 'last_email' => $request->request->get('_email', ''), - ]); + return $this->render('Security/register.html.twig', ['form' => $form]); } #[Route('/forgot-password', name: 'MineSeekerBundle_forgot_password')] @@ -150,8 +123,11 @@ class SecurityController extends AbstractController return $this->redirectToRoute('MineSeekerBundle_homepage'); } - if ($request->isMethod('POST')) { - $email = trim((string) $request->request->get('_email', '')); + $form = $this->createForm(ForgotPasswordFormType::class); + $form->handleRequest($request); + + if ($form->isSubmitted() && $form->isValid()) { + $email = $form->get('email')->getData(); $user = $userRepository->findOneByEmail($email); if ($user && $user->isVerified()) { @@ -186,7 +162,7 @@ class SecurityController extends AbstractController return $this->redirectToRoute('MineSeekerBundle_forgot_password'); } - return $this->render('Security/forgot_password.html.twig'); + return $this->render('Security/forgot_password.html.twig', ['form' => $form]); } #[Route('/reset-password/{token}', name: 'MineSeekerBundle_reset_password')] @@ -204,32 +180,22 @@ class SecurityController extends AbstractController return $this->redirectToRoute('MineSeekerBundle_forgot_password'); } - $errors = []; + $form = $this->createForm(ResetPasswordFormType::class); + $form->handleRequest($request); - if ($request->isMethod('POST')) { - $password = (string) $request->request->get('_password', ''); - $passwordConfirm = (string) $request->request->get('_password_confirm', ''); + if ($form->isSubmitted() && $form->isValid()) { + $user + ->setPassword($hasher->hashPassword($user, $form->get('plainPassword')->getData())) + ->setResetToken(null) + ->setResetTokenExpiresAt(null); + $em->flush(); - if (mb_strlen($password) < 6) { - $errors['password'] = 'Password must be at least 6 characters.'; - } elseif ($password !== $passwordConfirm) { - $errors['password_confirm'] = 'Passwords do not match.'; - } + $this->addFlash('success', 'Your password has been reset. You can now sign in.'); - if (empty($errors)) { - $user - ->setPassword($hasher->hashPassword($user, $password)) - ->setResetToken(null) - ->setResetTokenExpiresAt(null); - $em->flush(); - - $this->addFlash('success', 'Your password has been reset. You can now sign in.'); - - return $this->redirectToRoute('MineSeekerBundle_login'); - } + return $this->redirectToRoute('MineSeekerBundle_login'); } - return $this->render('Security/reset_password.html.twig', ['errors' => $errors]); + return $this->render('Security/reset_password.html.twig', ['form' => $form]); } #[Route('/activate/{token}', name: 'MineSeekerBundle_activate')] diff --git a/src/Entity/User.php b/src/Entity/User.php index d7dfae0..793681c 100644 --- a/src/Entity/User.php +++ b/src/Entity/User.php @@ -18,9 +18,9 @@ use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\GeneratedValue; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\Table; +use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity; use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface; use Symfony\Component\Security\Core\User\UserInterface; -use Symfony\Component\Validator\Constraints as Assert; /** * Class User @@ -34,6 +34,8 @@ use Symfony\Component\Validator\Constraints as Assert; */ #[Table(name: 'app_user')] #[Entity(repositoryClass: UserRepository::class)] +#[UniqueEntity(fields: ['username'], message: 'This username is already taken.')] +#[UniqueEntity(fields: ['email'], message: 'This email address is already registered.')] class User implements UserInterface, PasswordAuthenticatedUserInterface { #[Id, GeneratedValue, Column] @@ -82,7 +84,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface public function getUserIdentifier(): string { - return (string) $this->username; + return (string)$this->username; } public function getRoles(): array diff --git a/src/EventListener/LoginCaptchaListener.php b/src/EventListener/LoginCaptchaListener.php new file mode 100644 index 0000000..4c771d5 --- /dev/null +++ b/src/EventListener/LoginCaptchaListener.php @@ -0,0 +1,56 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +#[AsEventListener(event: CheckPassportEvent::class)] +readonly class LoginCaptchaListener +{ + public function __construct( + private RecaptchaService $recaptcha, + private RequestStack $requestStack, + ) {} + + public function __invoke(CheckPassportEvent $event): void + { + $request = $this->requestStack->getCurrentRequest(); + + if ($request === null || !$request->isMethod('POST')) { + return; + } + + $token = $request->request->getString('g-recaptcha-response'); + $remoteIp = (string) $request->getClientIp(); + + if (!$this->recaptcha->verify($token, $remoteIp)) { + throw new CustomUserMessageAuthenticationException('CAPTCHA verification failed. Please try again.'); + } + } +} \ No newline at end of file diff --git a/src/Form/ForgotPasswordFormType.php b/src/Form/ForgotPasswordFormType.php new file mode 100644 index 0000000..f0f3c09 --- /dev/null +++ b/src/Form/ForgotPasswordFormType.php @@ -0,0 +1,48 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +class ForgotPasswordFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('email', EmailType::class, [ + 'constraints' => [ + new NotBlank(message: 'Please enter an email address.'), + new Email(message: 'Please enter a valid email address.'), + ], + ]) + ->add('recaptcha', RecaptchaType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Form/RecaptchaType.php b/src/Form/RecaptchaType.php new file mode 100644 index 0000000..59d8107 --- /dev/null +++ b/src/Form/RecaptchaType.php @@ -0,0 +1,61 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +class RecaptchaType extends AbstractType +{ + public function __construct(private readonly RequestStack $requestStack) {} + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder->addEventListener(FormEvents::PRE_SUBMIT, function (PreSubmitEvent $event): void { + $request = $this->requestStack->getCurrentRequest(); + $token = $request?->request->getString('g-recaptcha-response') ?? ''; + $event->setData($token); + }); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'mapped' => false, + 'constraints' => [new Recaptcha()], + ]); + } + + public function getParent(): string + { + return HiddenType::class; + } +} \ No newline at end of file diff --git a/src/Form/RegistrationFormType.php b/src/Form/RegistrationFormType.php new file mode 100644 index 0000000..d19928f --- /dev/null +++ b/src/Form/RegistrationFormType.php @@ -0,0 +1,80 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +class RegistrationFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('username', TextType::class, [ + 'constraints' => [ + new NotBlank(message: 'Please enter a username.'), + new Length( + min: 3, + max: 180, + minMessage: 'Username must be at least {{ limit }} characters.', + maxMessage: 'Username cannot be longer than {{ limit }} characters.', + ), + ], + ]) + ->add('email', EmailType::class, [ + 'constraints' => [ + new NotBlank(message: 'Please enter an email address.'), + new Email(message: 'Please enter a valid email address.'), + ], + ]) + ->add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'mapped' => false, + 'invalid_message' => 'Passwords do not match.', + 'first_options' => ['label' => 'Password'], + 'second_options' => ['label' => 'Confirm Password'], + 'constraints' => [ + new NotBlank(message: 'Please enter a password.'), + new Length( + min: 6, + minMessage: 'Password must be at least {{ limit }} characters.', + ), + ], + ]) + ->add('recaptcha', RecaptchaType::class); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'data_class' => User::class, + ]); + } +} diff --git a/src/Form/ResetPasswordFormType.php b/src/Form/ResetPasswordFormType.php new file mode 100644 index 0000000..36b6dcd --- /dev/null +++ b/src/Form/ResetPasswordFormType.php @@ -0,0 +1,56 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +class ResetPasswordFormType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $builder + ->add('recaptcha', RecaptchaType::class) + ->add('plainPassword', RepeatedType::class, [ + 'type' => PasswordType::class, + 'invalid_message' => 'Passwords do not match.', + 'first_options' => ['label' => 'New Password'], + 'second_options' => ['label' => 'Confirm New Password'], + 'constraints' => [ + new NotBlank(message: 'Please enter a password.'), + new Length( + min: 6, + minMessage: 'Password must be at least {{ limit }} characters.', + ), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([]); + } +} diff --git a/src/Service/RecaptchaService.php b/src/Service/RecaptchaService.php new file mode 100644 index 0000000..7a7f442 --- /dev/null +++ b/src/Service/RecaptchaService.php @@ -0,0 +1,69 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +readonly class RecaptchaService +{ + private const string SITEVERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; + + /** + * Minimum score to accept a request (0.0 = bot, 1.0 = human). + * 0.5 is Google's recommended default threshold. + */ + private const float SCORE_THRESHOLD = 0.5; + + public function __construct( + private HttpClientInterface $httpClient, + private LoggerInterface $logger, + #[Autowire(env: 'RECAPTCHA_SECRET_KEY')] + private string $secretKey, + ) {} + + public function verify(string $token, string $remoteIp = ''): bool + { + if ($token === '') { + return false; + } + + try { + $body = ['secret' => $this->secretKey, 'response' => $token]; + + if ($remoteIp !== '') { + $body['remoteip'] = $remoteIp; + } + + $data = $this->httpClient + ->request('POST', self::SITEVERIFY_URL, ['body' => $body]) + ->toArray(); + + return ($data['success'] ?? false) === true + && ($data['score'] ?? 0.0) >= self::SCORE_THRESHOLD; + } catch (\Throwable $e) { + $this->logger->error('reCAPTCHA verification failed: ' . $e->getMessage()); + + return false; + } + } +} diff --git a/src/Validator/Recaptcha.php b/src/Validator/Recaptcha.php new file mode 100644 index 0000000..e7ba974 --- /dev/null +++ b/src/Validator/Recaptcha.php @@ -0,0 +1,30 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)] +class Recaptcha extends Constraint +{ + public string $message = 'CAPTCHA verification failed. Please try again.'; +} diff --git a/src/Validator/RecaptchaValidator.php b/src/Validator/RecaptchaValidator.php new file mode 100644 index 0000000..e7f02a1 --- /dev/null +++ b/src/Validator/RecaptchaValidator.php @@ -0,0 +1,51 @@ + + * @category Class + * @license https://www.gnu.org/licenses/lgpl-3.0.en.html GNU Lesser General Public License + * @link www.splendidbear.org + * @since 2026. 04. 12. + */ +class RecaptchaValidator extends ConstraintValidator +{ + public function __construct( + private readonly RecaptchaService $recaptcha, + private readonly RequestStack $requestStack, + ) {} + + public function validate(mixed $value, Constraint $constraint): void + { + if (!$constraint instanceof Recaptcha) { + throw new UnexpectedTypeException($constraint, Recaptcha::class); + } + + $request = $this->requestStack->getCurrentRequest(); + $remoteIp = $request !== null ? ((string) $request->getClientIp()) : ''; + + if ($this->recaptcha->verify((string) $value, $remoteIp)) { + return; + } + + $this->context->buildViolation($constraint->message)->addViolation(); + } +} diff --git a/symfony.lock b/symfony.lock index 19a838e..0e4a7da 100644 --- a/symfony.lock +++ b/symfony.lock @@ -197,6 +197,18 @@ "ref": "cc1afd81841db36fbef982fe56b48ade6716fac4" } }, + "symfony/form": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.2", + "ref": "7d86a6723f4a623f59e2bf966b6aad2fc461d36b" + }, + "files": [ + "config/packages/csrf.yaml" + ] + }, "symfony/framework-bundle": { "version": "3.3", "recipe": { @@ -369,6 +381,18 @@ "ref": "f75ac166398e107796ca94cc57fa1edaa06ec47f" } }, + "symfony/validator": { + "version": "7.4", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "main", + "version": "7.0", + "ref": "8c1c4e28d26a124b0bb273f537ca8ce443472bfd" + }, + "files": [ + "config/packages/validator.yaml" + ] + }, "symfony/var-dumper": { "version": "v4.0.9" }, diff --git a/templates/Security/forgot_password.html.twig b/templates/Security/forgot_password.html.twig index 89052ca..b03cdc4 100644 --- a/templates/Security/forgot_password.html.twig +++ b/templates/Security/forgot_password.html.twig @@ -4,7 +4,6 @@ {% block body %}
Enter your email and we'll send you a reset link
- + + {{ form_end(form) }}Remembered it? Sign in
- - {{ app.user.email }} -
- {% endif %} -- Registered commander -
-No games recorded yet. Start playing!
-+ + {{ app.user.email }} +
{% endif %} - ++ Registered commander +
+No games recorded yet. Start playing!
+We sent an activation link to
+{{ email }}
+
+ Click the link in the email to activate your account.
+ The link expires in 24 hours.
+
Join the battle — no subscription required
- {% for email in app.flashes('verify_email') %} -We sent an activation link to
-{{ email }}
-
- Click the link in the email to activate your account.
- The link expires in 24 hours.
-
Join the battle — no subscription required
+{{ error.message }}
+ {% endfor %} + {% endif %} ++ Already have an account? + Sign in +
+- Already have an account? - Sign in -
-Choose a new password for your account
-