Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 150
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SecurityController
0.00% covered (danger)
0.00%
0 / 150
0.00% covered (danger)
0.00%
0 / 13
1190
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 index
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 logout
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 register
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
20
 verifyUserEmail
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
20
 request
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
12
 checkEmail
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 reset
0.00% covered (danger)
0.00%
0 / 30
0.00% covered (danger)
0.00%
0 / 1
42
 processSendingPasswordResetEmail
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 sendEmailConfirmation
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 handleEmailConfirmation
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 generateSignedUrlAndEmailToTheUser
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 resendVerifyEmail
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2
3namespace App\Controller;
4
5use App\Entity\User;
6use App\Form\ChangePasswordFormType;
7use App\Form\RegistrationFormType;
8use App\Form\ResetPasswordRequestFormType;
9use App\Repository\UserRepository;
10use Doctrine\ORM\EntityManagerInterface;
11use Exception;
12use Symfony\Bridge\Twig\Mime\TemplatedEmail;
13use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
14use Symfony\Component\HttpFoundation\RedirectResponse;
15use Symfony\Component\HttpFoundation\Request;
16use Symfony\Component\HttpFoundation\Response;
17use Symfony\Component\Mailer\Exception\TransportExceptionInterface;
18use Symfony\Component\Mailer\MailerInterface;
19use Symfony\Component\Mime\Address;
20use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
21use Symfony\Component\Routing\Annotation\Route;
22use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
23use Symfony\Contracts\Translation\TranslatorInterface;
24use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
25use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
26use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
27use SymfonyCasts\Bundle\VerifyEmail\Exception\VerifyEmailExceptionInterface;
28use SymfonyCasts\Bundle\VerifyEmail\VerifyEmailHelperInterface;
29
30class SecurityController extends AbstractController
31{
32    use ResetPasswordControllerTrait;
33
34    public function __construct(private readonly ResetPasswordHelperInterface $resetPasswordHelper,
35                                private readonly EntityManagerInterface       $entityManager,
36                                private readonly VerifyEmailHelperInterface   $verifyEmailHelper,
37                                private readonly MailerInterface              $mailer,
38    )
39    {
40        // empty body
41    }
42
43    #[Route(path: '/security/login', name: 'security_login')]
44    public function index(AuthenticationUtils $authenticationUtils): Response
45    {
46        // get the login error if there is one
47        if ($error = $authenticationUtils->getLastAuthenticationError()) {
48            $this->addFlash(type: 'error', message: $error->getMessageKey());
49        }
50
51        // last username entered by the user
52        $lastUsername = $authenticationUtils->getLastUsername();
53
54        return $this->render(view: '@default/security/login.html.twig', parameters: [
55            'last_username' => $lastUsername,
56            'error' => '',
57        ]);
58    }
59
60    /**
61     * @throws Exception
62     */
63    #[Route(path: '/security/logout', name: 'security_logout')]
64    public function logout(): never
65    {
66        throw new Exception(message: 'Logout should never be reached.');
67    }
68
69    #[Route(path: '/security/register', name: 'security_register')]
70    public function register(Request $request, UserPasswordHasherInterface $userPasswordHasher, EntityManagerInterface $entityManager): Response
71    {
72        $form = $this->createForm(type: RegistrationFormType::class);
73        $form->handleRequest(request: $request);
74
75        if ($form->isSubmitted() && $form->isValid()) {
76            $user = $form->getData();
77            // hash the plain password
78            $user->setPassword(
79                password: $userPasswordHasher->hashPassword(
80                    user: $user,
81                    plainPassword: $form->get(name: 'password')->getData()
82                )
83            );
84
85            if ($form->get(name: 'agreeTerms')->getData()) {
86                $user->agreeTerms();
87            } // no else, we already confirmed in the form itself
88            $entityManager->persist(entity: $user);
89            $entityManager->flush();
90            $this->generateSignedUrlAndEmailToTheUser($user);
91
92            return $this->render(view: '@default/security/registration_finished.html.twig');
93        }
94
95        return $this->renderForm(view: '@default/security/register.html.twig', parameters: [
96            'registrationForm' => $form,
97        ]);
98    }
99
100    #[Route(path: '/security/verify/email', name: 'security_verify_email')]
101    public function verifyUserEmail(Request $request, TranslatorInterface $translator, UserRepository $userRepository): Response
102    {
103        $id = $request->get(key: 'id');
104
105        if ($id === null) {
106            return $this->redirectToRoute(route: 'app_register');
107        }
108
109        $user = $userRepository->find($id);
110
111        if ($user === null) {
112            return $this->redirectToRoute(route: 'app_register');
113        }
114
115        // validate email confirmation link, sets User::isVerified=true and persists
116        try {
117            $this->handleEmailConfirmation(request: $request, user: $user);
118        } catch (VerifyEmailExceptionInterface $exception) {
119            $this->addFlash(type: 'error', message: $translator->trans(id: $exception->getReason(), parameters: [], domain: 'VerifyEmailBundle'));
120
121            return $this->redirectToRoute(route: 'app_main');
122        }
123
124        $this->addFlash(type: 'success', message: 'Your email address has been verified.');
125
126        return $this->redirectToRoute(route: 'app_main');
127    }
128
129    /**
130     * Display & process form to request a password reset.
131     */
132    #[Route(path: '/security/forgot/password', name: 'security_forgot_password')]
133    public function request(Request $request, MailerInterface $mailer): Response
134    {
135        $form = $this->createForm(type: ResetPasswordRequestFormType::class);
136        $form->handleRequest(request: $request);
137
138        if ($form->isSubmitted() && $form->isValid()) {
139            return $this->processSendingPasswordResetEmail(
140                formData: $form->get(name: 'account')->getData(),
141                mailer: $mailer
142            );
143        }
144
145        return $this->renderForm(view: '@default/security/forgot_password.html.twig', parameters: [
146            'requestForm' => $form,
147        ]);
148    }
149
150    /**
151     * Confirmation page after a user has requested a password reset.
152     */
153    #[Route(path: '/security/recovery/mail/sent', name: 'security_recovery_mail_sent')]
154    public function checkEmail(): Response
155    {
156        // Generate a fake token if the user does not exist or someone hit this page directly.
157        // This prevents exposing whether a user was found with the given email address or username or not
158        if (null === ($resetToken = $this->getTokenObjectFromSession())) {
159            $resetToken = $this->resetPasswordHelper->generateFakeResetToken();
160        }
161
162        return $this->render(view: '@default/security/recovery_mail_sent.html.twig', parameters: [
163            'resetToken' => $resetToken,
164        ]);
165    }
166
167    /**
168     * Validates and process the reset URL that the user clicked in their email.
169     */
170    #[Route(path: '/security/recovery/reset/{token}', name: 'security_recovery_reset')]
171    public function reset(Request $request, UserPasswordHasherInterface $passwordHasher, TranslatorInterface $translator, string $token = null): Response
172    {
173        if ($token) {
174            // We store the token in session and remove it from the URL, to avoid the URL being
175            // loaded in a browser and potentially leaking the token to 3rd party JavaScript.
176            $this->storeTokenInSession(token: $token);
177
178            return $this->redirectToRoute(route: 'security_recovery_reset');
179        }
180
181        $token = $this->getTokenFromSession();
182        if (null === $token) {
183            throw $this->createNotFoundException(message: 'No reset password token found in the URL or in the session.');
184        }
185
186        try {
187            $user = $this->resetPasswordHelper->validateTokenAndFetchUser(fullToken: $token);
188        } catch (ResetPasswordExceptionInterface $e) {
189            $this->addFlash(type: 'reset_password_error', message: sprintf(
190                '%s - %s',
191                $translator->trans(id: ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, parameters: [], domain: 'ResetPasswordBundle'),
192                $translator->trans(id: $e->getReason(), parameters: [], domain: 'ResetPasswordBundle')
193            ));
194
195            return $this->redirectToRoute(route: 'app_forgot_password_request');
196        }
197
198        // The token is valid; allow the user to change their password.
199        $form = $this->createForm(type: ChangePasswordFormType::class);
200        $form->handleRequest(request: $request);
201
202        if ($form->isSubmitted() && $form->isValid()) {
203            // A password reset token should be used only once, remove it.
204            $this->resetPasswordHelper->removeResetRequest(fullToken: $token);
205
206            // Encode(hash) the plain password, and set it.
207            $encodedPassword = $passwordHasher->hashPassword(
208                user: $user,
209                plainPassword: $form->get(name: 'plainPassword')->getData()
210            );
211
212            $user->setPassword($encodedPassword);
213            $this->entityManager->flush();
214
215            // The session is cleaned up after the password has been changed.
216            $this->cleanSessionAfterReset();
217
218            $this->addFlash(type: 'success', message: 'Your password has been changed.');
219
220            return $this->redirectToRoute(route: 'app_main');
221        }
222
223        return $this->renderForm(view: '@default/security/reset_password.html.twig', parameters: [
224            'resetForm' => $form,
225        ]);
226    }
227
228    private function processSendingPasswordResetEmail(string $formData, MailerInterface $mailer): RedirectResponse
229    {
230        $user = $this->entityManager->getRepository(entityName: User::class)->findOneBy(criteria: [
231            'email' => $formData,
232        ]);
233
234        if (!$user) {
235            $user = $this->entityManager->getRepository(entityName: User::class)->findOneBy(criteria: [
236                'username' => $formData,
237            ]);
238        }
239
240        // Do not reveal whether a user account was found or not.
241//        if (!$user) {
242//            return $this->redirectToRoute(route: 'app_check_email');
243//        }
244
245        try {
246            $resetToken = $this->resetPasswordHelper->generateResetToken(user: $user);
247        } catch (ResetPasswordExceptionInterface $e) {
248            $this->addFlash(type: 'reset_password_error', message: sprintf(
249                '%s - %s',
250                ResetPasswordExceptionInterface::MESSAGE_PROBLEM_HANDLE,
251                $e->getReason()
252            ));
253
254            return $this->redirectToRoute(route: 'security_forgot_password');
255        }
256
257        $email = (new TemplatedEmail())
258            ->from(new Address(address: 'tracer@24unix.net', name: '24unix.net'))
259            ->to($user->getEmail())
260            ->subject(subject: 'Your password reset request')
261            ->htmlTemplate(template: '@default/security/mail/recovery.html.twig')
262            ->context(context: [
263                'resetToken' => $resetToken,
264            ]);
265
266        try {
267            $mailer->send(message: $email);
268        } catch (TransportExceptionInterface $e) {
269            $this->addFlash(type: 'error', message: $e->getMessage());
270        }
271
272        // Store the token object in session for retrieval in check-email route.
273        $this->setTokenObjectInSession(token: $resetToken);
274
275        return $this->redirectToRoute(route: 'security_recovery_mail_sent');
276    }
277
278
279    public function sendEmailConfirmation(string $verifyEmailRouteName, User /* UserInterface */ $user, TemplatedEmail $email): void
280    {
281        $signatureComponents = $this->verifyEmailHelper->generateSignature(
282            routeName: $verifyEmailRouteName,
283            userId: $user->getId(),
284            userEmail: $user->getEmail(),
285            extraParams: ['id' => $user->getId()]
286        );
287
288        $context = $email->getContext();
289        $context['signedUrl'] = $signatureComponents->getSignedUrl();
290        $context['expiresAtMessageKey'] = $signatureComponents->getExpirationMessageKey();
291        $context['expiresAtMessageData'] = $signatureComponents->getExpirationMessageData();
292
293        $email->context(context: $context);
294
295        try {
296            $this->mailer->send(message: $email);
297        } catch (TransportExceptionInterface $e) {
298            die($e->getMessage());
299        }
300    }
301
302    /**
303     * @throws VerifyEmailExceptionInterface
304     */
305    public function handleEmailConfirmation(Request $request, User /*UserInterface*/ $user): void
306    {
307            $this->verifyEmailHelper->validateEmailConfirmation(signedUrl: $request->getUri(), userId: $user->getId(), userEmail: $user->getEmail());
308            $user->setIsVerified(isVerified: true);
309            $this->entityManager->persist(entity: $user);
310            $this->entityManager->flush();
311    }
312
313    /**
314     * @param mixed $user
315     * @return void
316     */
317    public function generateSignedUrlAndEmailToTheUser(mixed $user): void
318    {
319        $this->sendEmailConfirmation(verifyEmailRouteName: 'security_verify_email', user: $user,
320            email: (new TemplatedEmail())
321                ->from(new Address(address: 'info@24unix.net', name: '24unix.net'))
322                ->to($user->getEmail())
323                ->subject(subject: 'Please Confirm your Email')
324                ->htmlTemplate(template: '@default/security/mail/registration.html.twig')
325                ->context(context: [
326                    'username' => $user->getUsername()
327                ])
328        );
329    }
330
331
332    #[Route('/security/resend/verify_email', name: 'security_resend_verify_email')]
333    public function resendVerifyEmail(Request $request, UserRepository $userRepository): Response
334    {
335
336        if ($request->isMethod('POST')) {
337
338            $email = $request->getSession()->get('non_verified_email');
339            $user = $userRepository->findOneBy(['email' => $email]);
340            if (!$user) {
341                throw $this->createNotFoundException('user not found for email');
342            }
343
344            $this->generateSignedUrlAndEmailToTheUser(user: $user);
345            $this->addFlash('success', 'eMail has been sent.');
346
347            return $this->redirectToRoute('app_main');
348        }
349        return $this->render('@default/security/resend_activation.html.twig');
350    }
351}