diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 7cbfced..cf42030 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -15,16 +15,17 @@ use App\Form\ForgotPasswordFormType; use App\Form\RegistrationFormType; use App\Form\ResetPasswordFormType; use App\Repository\UserRepository; +use App\Service\Email\SendActivationEmailService; +use App\Service\Email\SendPasswordResetEmailService; +use App\Service\Email\SendUserActivationNotificationService; +use App\Service\Email\SendUserRegistrationNotificationService; use DateTime; use Doctrine\ORM\EntityManagerInterface; use LogicException; -use Symfony\Bridge\Twig\Mime\TemplatedEmail; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; -use Symfony\Component\DependencyInjection\Attribute\Autowire; -use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; -use Symfony\Component\Mailer\MailerInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; @@ -44,21 +45,28 @@ use Symfony\Component\Security\Http\Authentication\AuthenticationUtils; class SecurityController extends AbstractController { public function __construct( - #[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')] - private readonly string $appContactMailAddress, + private readonly EntityManagerInterface $em, + private readonly RequestStack $requestStack, + private readonly UserRepository $userRepository, + private readonly UserPasswordHasherInterface $passwordHasher, + private readonly AuthenticationUtils $authenticationUtils, + private readonly SendActivationEmailService $activationEmail, + private readonly SendPasswordResetEmailService $passwordResetEmail, + private readonly SendUserActivationNotificationService $activationNotificationEmail, + private readonly SendUserRegistrationNotificationService $registrationNotificationEmail, ) { } #[Route('/login', name: 'MineSeekerBundle_login')] - public function login(AuthenticationUtils $authenticationUtils): Response + public function login(): Response { if ($this->getUser()) { return $this->redirectToRoute('MineSeekerBundle_homepage'); } return $this->render('Security/login.html.twig', [ - 'last_username' => $authenticationUtils->getLastUsername(), - 'error' => $authenticationUtils->getLastAuthenticationError(), + 'last_username' => $this->authenticationUtils->getLastUsername(), + 'error' => $this->authenticationUtils->getLastAuthenticationError(), ]); } @@ -69,29 +77,25 @@ class SecurityController extends AbstractController } #[Route('/register', name: 'MineSeekerBundle_register')] - public function register( - Request $request, - UserPasswordHasherInterface $hasher, - EntityManagerInterface $em, - MailerInterface $mailer, - ): Response { + public function register(): Response + { if ($this->getUser()) { return $this->redirectToRoute('MineSeekerBundle_homepage'); } $user = new User(); $form = $this->createForm(RegistrationFormType::class, $user); - $form->handleRequest($request); + $form->handleRequest($this->requestStack->getCurrentRequest()); if ($form->isSubmitted() && $form->isValid()) { $token = bin2hex(random_bytes(32)); $user->isVerified = false; $user->verificationToken = $token; - $user->password = $hasher->hashPassword($user, $form->get('plainPassword')->getData()); + $user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData()); - $em->persist($user); - $em->flush(); + $this->em->persist($user); + $this->em->flush(); $activationUrl = $this->generateUrl( 'MineSeekerBundle_activate', @@ -104,30 +108,8 @@ class SecurityController extends AbstractController $activationUrl = str_replace('http://', 'https://', $activationUrl); } - $mailer->send( - new TemplatedEmail() - ->from('noreply@mineseeker.hu') - ->to($user->email) - ->subject('Activate your MineSeeker account') - ->htmlTemplate('emails/activation.html.twig') - ->context([ - 'username' => $user->getUsername(), - 'activation_url' => $activationUrl, - ]) - ); - - /** Send admin notification about new user registration */ - $mailer->send( - new TemplatedEmail() - ->from('noreply@mineseeker.hu') - ->to($this->appContactMailAddress) - ->subject('🎉 New User Registration: ' . $user->getUsername()) - ->htmlTemplate('emails/user_registration_notification.html.twig') - ->context([ - 'user' => $user, - 'registeredAt' => new DateTime(), - ]) - ); + $this->activationEmail->send($user, $activationUrl); + $this->registrationNotificationEmail->send($user, new DateTime()); $this->addFlash('verify_email', $user->email); @@ -138,28 +120,24 @@ class SecurityController extends AbstractController } #[Route('/forgot-password', name: 'MineSeekerBundle_forgot_password')] - public function forgotPassword( - Request $request, - UserRepository $userRepository, - EntityManagerInterface $em, - MailerInterface $mailer, - ): Response { + public function forgotPassword(): Response + { if ($this->getUser()) { return $this->redirectToRoute('MineSeekerBundle_homepage'); } $form = $this->createForm(ForgotPasswordFormType::class); - $form->handleRequest($request); + $form->handleRequest($this->requestStack->getCurrentRequest()); if ($form->isSubmitted() && $form->isValid()) { $email = $form->get('email')->getData(); - $user = $userRepository->findOneByEmail($email); + $user = $this->userRepository->findOneByEmail($email); if ($user && $user->isVerified) { $token = bin2hex(random_bytes(32)); $user->resetToken = $token; $user->resetTokenExpiresAt = new DateTime('+1 hour'); - $em->flush(); + $this->em->flush(); $resetUrl = $this->generateUrl( 'MineSeekerBundle_reset_password', @@ -172,20 +150,9 @@ class SecurityController extends AbstractController $resetUrl = str_replace('http://', 'https://', $resetUrl); } - $mailer->send( - new TemplatedEmail() - ->from('noreply@mineseeker.hu') - ->to($email) - ->subject('Reset your MineSeeker password') - ->htmlTemplate('emails/reset_password.html.twig') - ->context([ - 'username' => $user->getUsername(), - 'reset_url' => $resetUrl, - ]) - ); + $this->passwordResetEmail->send($email, $user->getUsername(), $resetUrl); } - // Always show the same flash to prevent email enumeration $this->addFlash('reset_sent', $email); return $this->redirectToRoute('MineSeekerBundle_forgot_password'); @@ -195,14 +162,9 @@ class SecurityController extends AbstractController } #[Route('/reset-password/{token}', name: 'MineSeekerBundle_reset_password')] - public function resetPassword( - string $token, - Request $request, - UserRepository $userRepository, - EntityManagerInterface $em, - UserPasswordHasherInterface $hasher, - ): Response { - $user = $userRepository->findOneByResetToken($token); + public function resetPassword(string $token): Response + { + $user = $this->userRepository->findOneByResetToken($token); if (!$user || $user->resetTokenExpiresAt < new DateTime()) { $this->addFlash('error', 'This password reset link is invalid or has expired.'); @@ -210,13 +172,14 @@ class SecurityController extends AbstractController } $form = $this->createForm(ResetPasswordFormType::class); - $form->handleRequest($request); + $form->handleRequest($this->requestStack->getCurrentRequest()); if ($form->isSubmitted() && $form->isValid()) { - $user->password = $hasher->hashPassword($user, $form->get('plainPassword')->getData()); + $user->password = $this->passwordHasher->hashPassword($user, $form->get('plainPassword')->getData()); $user->resetToken = null; $user->resetTokenExpiresAt = null; - $em->flush(); + + $this->em->flush(); $this->addFlash('success', 'Your password has been reset. You can now sign in.'); @@ -227,9 +190,9 @@ class SecurityController extends AbstractController } #[Route('/activate/{token}', name: 'MineSeekerBundle_activate')] - public function activate(string $token, EntityManagerInterface $em, MailerInterface $mailer): Response + public function activate(string $token): Response { - $user = $em->getRepository(User::class)->findOneBy(['verificationToken' => $token]); + $user = $this->em->getRepository(User::class)->findOneBy(['verificationToken' => $token]); if (!$user) { $this->addFlash('error', 'This activation link is invalid or has already been used.'); @@ -238,20 +201,9 @@ class SecurityController extends AbstractController $user->isVerified = true; $user->verificationToken = null; - $em->flush(); + $this->em->flush(); - /** Send admin notification about account activation */ - $mailer->send( - new TemplatedEmail() - ->from('noreply@mineseeker.hu') - ->to($this->appContactMailAddress) - ->subject('✅ User Account Activated: ' . $user->getUsername()) - ->htmlTemplate('emails/user_activation_notification.html.twig') - ->context([ - 'user' => $user, - 'activatedAt' => new DateTime(), - ]) - ); + $this->activationNotificationEmail->send($user, new DateTime()); $this->addFlash('success', 'Your account is now active. Welcome, ' . $user->getUsername() . '!'); diff --git a/src/Service/Email/SendActivationEmailService.php b/src/Service/Email/SendActivationEmailService.php new file mode 100644 index 0000000..bb72ef3 --- /dev/null +++ b/src/Service/Email/SendActivationEmailService.php @@ -0,0 +1,71 @@ + + * @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. 20. + */ +readonly final class SendActivationEmailService +{ + public function __construct( + private LoggerInterface $logger, + private MailerInterface $mailer, + ) { + } + + public function send(User $user, string $activationUrl): void + { + try { + $this->mailer->send( + new TemplatedEmail() + ->from('noreply@mineseeker.hu') + ->to($user->email) + ->subject('Activate your MineSeeker account') + ->htmlTemplate('emails/activation.html.twig') + ->context([ + 'username' => $user->getUsername(), + 'activation_url' => $activationUrl, + ]) + ); + } catch (TransportExceptionInterface|Exception $e) { + $this->logger->error("Failed to send activation email: {$e->getMessage()}", [ + 'exception' => $e, + 'user' => $user->getUsername(), + ]); + throw new RuntimeException("Failed to send activation email: {$e->getMessage()}"); + } + } +} + diff --git a/src/Service/Email/SendPasswordResetEmailService.php b/src/Service/Email/SendPasswordResetEmailService.php new file mode 100644 index 0000000..a96378e --- /dev/null +++ b/src/Service/Email/SendPasswordResetEmailService.php @@ -0,0 +1,70 @@ + + * @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. 20. + */ +readonly final class SendPasswordResetEmailService +{ + public function __construct( + private LoggerInterface $logger, + private MailerInterface $mailer, + ) { + } + + public function send(string $email, string $username, string $resetUrl): void + { + try { + $this->mailer->send( + new TemplatedEmail() + ->from('noreply@mineseeker.hu') + ->to($email) + ->subject('Reset your MineSeeker password') + ->htmlTemplate('emails/reset_password.html.twig') + ->context([ + 'username' => $username, + 'reset_url' => $resetUrl, + ]) + ); + } catch (TransportExceptionInterface|Exception $e) { + $this->logger->error("Failed to send password reset email: {$e->getMessage()}", [ + 'exception' => $e, + 'email' => $email, + ]); + throw new RuntimeException("Failed to send password reset email: {$e->getMessage()}"); + } + } +} + diff --git a/src/Service/Email/SendUserActivationNotificationService.php b/src/Service/Email/SendUserActivationNotificationService.php new file mode 100644 index 0000000..3fbf884 --- /dev/null +++ b/src/Service/Email/SendUserActivationNotificationService.php @@ -0,0 +1,75 @@ + + * @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. 20. + */ +readonly final class SendUserActivationNotificationService +{ + public function __construct( + #[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')] + private string $appContactMailAddress, + private LoggerInterface $logger, + private MailerInterface $mailer, + ) { + } + + public function send(User $user, DateTime $activatedAt): void + { + try { + $this->mailer->send( + new TemplatedEmail() + ->from('noreply@mineseeker.hu') + ->to($this->appContactMailAddress) + ->subject("✅ User Account Activated: {$user->getUsername()}") + ->htmlTemplate('emails/user_activation_notification.html.twig') + ->context([ + 'user' => $user, + 'activatedAt' => $activatedAt, + ]) + ); + } catch (TransportExceptionInterface|Exception $e) { + $this->logger->error("Failed to send user activation notification: {$e->getMessage()}", [ + 'exception' => $e, + 'user' => $user->getUsername(), + ]); + throw new RuntimeException("Failed to send user activation notification: {$e->getMessage()}"); + } + } +} + diff --git a/src/Service/Email/SendUserRegistrationNotificationService.php b/src/Service/Email/SendUserRegistrationNotificationService.php new file mode 100644 index 0000000..8f0a4c6 --- /dev/null +++ b/src/Service/Email/SendUserRegistrationNotificationService.php @@ -0,0 +1,75 @@ + + * @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. 20. + */ +readonly final class SendUserRegistrationNotificationService +{ + public function __construct( + #[Autowire(env: 'APP_CONTACT_MAIL_ADDRESS')] + private string $appContactMailAddress, + private LoggerInterface $logger, + private MailerInterface $mailer, + ) { + } + + public function send(User $user, DateTime $registeredAt): void + { + try { + $this->mailer->send( + new TemplatedEmail() + ->from('noreply@mineseeker.hu') + ->to($this->appContactMailAddress) + ->subject("🎉 New User Registration: {$user->getUsername()}") + ->htmlTemplate('emails/user_registration_notification.html.twig') + ->context([ + 'user' => $user, + 'registeredAt' => $registeredAt, + ]) + ); + } catch (TransportExceptionInterface|Exception $e) { + $this->logger->error("Failed to send user registration notification: {$e->getMessage()}", [ + 'exception' => $e, + 'user' => $user->getUsername(), + ]); + throw new RuntimeException("Failed to send user registration notification: {$e->getMessage()}"); + } + } +} +