1 <?php
declare(strict_types
=1);
4 * This file is part of the Rapsys UserBundle package.
6 * (c) Raphaël Gertz <symfony@rapsys.eu>
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
12 namespace Rapsys\UserBundle\Controller
;
14 use Doctrine\DBAL\Exception\UniqueConstraintViolationException
;
16 use Rapsys\UserBundle\RapsysUserBundle
;
18 use Symfony\Bridge\Twig\Mime\TemplatedEmail
;
19 use Symfony\Component\Form\FormError
;
20 use Symfony\Component\HttpFoundation\Request
;
21 use Symfony\Component\HttpFoundation\Response
;
22 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException
;
23 use Symfony\Component\HttpKernel\Exception\GoneHttpException
;
24 use Symfony\Component\Mailer\Exception\TransportExceptionInterface
;
25 use Symfony\Component\Mime\Address
;
26 use Symfony\Component\Routing\Generator\UrlGeneratorInterface
;
27 use Symfony\Component\Security\Http\Authentication\AuthenticationUtils
;
32 class UserController
extends AbstractController
{
36 * @param Request $request The request
37 * @return Response The response
39 public function index(Request
$request): Response
{
41 if (!$this->checker
->isGranted('ROLE_'.strtoupper($this->config
['default']['admin']))) {
43 throw $this->createAccessDeniedException($this->translator
->trans('Unable to list users', [], $this->alias
));
47 $this->context
['count'] = $this->doctrine
->getRepository($this->config
['class']['user'])->findCountAsInt();
49 //With not enough users
50 if ($this->context
['count'] - $this->page
* $this->limit
< 0) {
52 throw $this->createNotFoundException($this->translator
->trans('Unable to find users', [], $this->alias
));
56 $this->context
['groups'] = $this->doctrine
->getRepository($this->config
['class']['user'])->findIndexByGroupId($this->page
, $this->limit
);
61 $this->config
['index']['view']['name'],
63 $this->context+
$this->config
['index']['view']['context']
68 * Confirm account from mail link
70 * @param Request $request The request
71 * @param string $hash The hashed password
72 * @param string $mail The shorted mail address
73 * @return Response The response
75 public function confirm(Request
$request, string $hash, string $mail): Response
{
77 if ($hash != $this->slugger
->hash($mail)) {
79 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'hash', '%value%' => $hash], $this->alias
));
83 $mail = $this->slugger
->unshort($smail = $mail);
86 if (filter_var($mail, FILTER_VALIDATE_EMAIL
) === false) {
88 //XXX: prevent slugger reverse engineering by not displaying decoded mail
89 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'mail', '%value%' => $smail], $this->alias
));
92 //Without existing registrant
93 if (!($user = $this->doctrine
->getRepository($this->config
['class']['user'])->findOneByMail($mail))) {
94 //Add error message mail already exists
95 //XXX: prevent slugger reverse engineering by not displaying decoded mail
96 $this->addFlash('error', $this->translator
->trans('The account do not exists', [], $this->alias
));
98 //Redirect to register view
99 return $this->redirectToRoute($this->config
['route']['register']['name'], $this->config
['route']['register']['context']);
103 $user->setActive(true);
106 $this->manager
->persist($user);
109 $this->manager
->flush();
111 //Add error message mail already exists
112 $this->addFlash('notice', $this->translator
->trans('Your account has been activated', [], $this->alias
));
114 //Redirect to user view
115 return $this->redirectToRoute($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']);
119 * Edit account by shorted mail
121 * @param Request $request The request
122 * @param string $hash The hashed password
123 * @param string $mail The shorted mail address
124 * @return Response The response
126 public function edit(Request
$request, string $hash, string $mail): Response
{
128 if ($hash != $this->slugger
->hash($mail)) {
130 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'hash', '%value%' => $hash], $this->alias
));
134 $mail = $this->slugger
->unshort($smail = $mail);
136 //With existing subscriber
137 if (empty($user = $this->doctrine
->getRepository($this->config
['class']['user'])->findOneByMail($mail))) {
139 //XXX: prevent slugger reverse engineering by not displaying decoded mail
140 throw $this->createNotFoundException($this->translator
->trans('Unable to find account', [], $this->alias
));
143 //Prevent access when not admin, user is not guest and not currently logged user
144 if (!$this->checker
->isGranted('ROLE_'.strtoupper($this->config
['default']['admin'])) && $user != $this->security
->getUser() || !$this->checker
->isGranted('IS_AUTHENTICATED_FULLY')) {
145 //Throw access denied
146 //XXX: prevent slugger reverse engineering by not displaying decoded mail
147 throw $this->createAccessDeniedException($this->translator
->trans('Unable to access user', [], $this->alias
));
150 //Create the EditType form and give the proper parameters
151 $edit = $this->factory
->create($this->config
['edit']['view']['edit'], $user, [
152 //Set action to edit route name and context
153 'action' => $this->generateUrl($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']),
155 'civility_class' => $this->config
['class']['civility'],
156 //Set civility default
157 'civility_default' => $this->doctrine
->getRepository($this->config
['class']['civility'])->findOneByTitle($this->config
['default']['civility']),
159 'mail' => $this->checker
->isGranted('ROLE_'.strtoupper($this->config
['default']['admin'])),
165 'translation_domain' => $this->alias
166 ]+
($this->checker
->isGranted('ROLE_'.strtoupper($this->config
['default']['admin']))?$this->config
['edit']['admin']:$this->config
['edit']['field']));
169 if ($this->checker
->isGranted('ROLE_'.strtoupper($this->config
['default']['admin']))) {
170 //Create the EditType form and give the proper parameters
171 $reset = $this->factory
->create($this->config
['edit']['view']['reset'], $user, [
172 //Set action to edit route name and context
173 'action' => $this->generateUrl($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']),
177 'translation_domain' => $this->alias
181 if ($request->isMethod('POST')) {
182 //Refill the fields in case the form is not valid.
183 $reset->handleRequest($request);
185 //With reset submitted and valid
186 if ($reset->isSubmitted() && $reset->isValid()) {
188 $data = $reset->getData();
191 $data->setPassword($this->hasher
->hashPassword($data, $data->getPassword()));
194 $this->manager
->persist($data);
196 //Flush to get the ids
197 $this->manager
->flush();
200 $this->addFlash('notice', $this->translator
->trans('Account password updated', [], $this->alias
));
202 //Redirect to cleanup the form
203 return $this->redirectToRoute($this->config
['route']['edit']['name'], ['mail' => $smail = $this->slugger
->short($mail), 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']);
208 $this->config
['edit']['view']['context']['reset'] = $reset->createView();
212 if ($request->isMethod('POST')) {
213 //Refill the fields in case the form is not valid.
214 $edit->handleRequest($request);
216 //With edit submitted and valid
217 if ($edit->isSubmitted() && $edit->isValid()) {
219 $data = $edit->getData();
222 $this->manager
->persist($data);
224 //Try saving in database
226 //Flush to get the ids
227 $this->manager
->flush();
230 $this->addFlash('notice', $this->translator
->trans('Account updated', [], $this->alias
));
232 //Redirect to cleanup the form
233 return $this->redirectToRoute($this->config
['route']['edit']['name'], ['mail' => $smail = $this->slugger
->short($mail), 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']);
234 //Catch double slug or mail
235 } catch (UniqueConstraintViolationException
$e) {
236 //Add error message mail already exists
237 $this->addFlash('error', $this->translator
->trans('The account already exists', [], $this->alias
));
241 //XXX: prefer a reset on login to force user unspam action
242 } elseif (!$this->checker
->isGranted('ROLE_'.strtoupper($this->config
['default']['admin']))) {
244 $this->addFlash('notice', $this->translator
->trans('To change your password login with your mail and any password then follow the procedure', [], $this->alias
));
248 return $this->render(
250 $this->config
['edit']['view']['name'],
252 ['register' => $edit->createView()]+
$this->config
['edit']['view']['context']
259 * @param Request $request The request
260 * @param AuthenticationUtils $authenticationUtils The authentication utils
261 * @param ?string $hash The hashed password
262 * @param ?string $mail The shorted mail address
263 * @return Response The response
265 public function login(Request
$request, AuthenticationUtils
$authenticationUtils, ?string $hash, ?string $mail): Response
{
266 //Create the LoginType form and give the proper parameters
267 $login = $this->factory
->create($this->config
['login']['view']['form'], null, [
268 //Set action to login route name and context
269 'action' => $this->generateUrl($this->config
['route']['login']['name'], $this->config
['route']['login']['context']),
273 'translation_domain' => $this->alias
280 if (!empty($mail) && !empty($hash)) {
282 if ($hash != $this->slugger
->hash($mail)) {
284 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'hash', '%value%' => $hash], $this->alias
));
288 $mail = $this->slugger
->unshort($smail = $mail);
291 if (filter_var($mail, FILTER_VALIDATE_EMAIL
) === false) {
293 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'mail', '%value%' => $smail], $this->alias
));
297 $login->get('mail')->setData($mail);
298 //Last username entered by the user
299 } elseif ($lastUsername = $authenticationUtils->getLastUsername()) {
300 $login->get('mail')->setData($lastUsername);
303 //Get the login error if there is one
304 if ($error = $authenticationUtils->getLastAuthenticationError()) {
305 //Get translated error
306 $error = $this->translator
->trans($error->getMessageKey(), [], $this->alias
);
308 //Add error message to mail field
309 $login->get('mail')->addError(new FormError($error));
311 //Create the RecoverType form and give the proper parameters
312 $recover = $this->factory
->create($this->config
['recover']['view']['form'], null, [
313 //Set action to recover route name and context
314 'action' => $this->generateUrl($this->config
['route']['recover']['name'], $this->config
['route']['recover']['context']),
320 'translation_domain' => $this->alias
323 //Get recover mail entity
324 $recover->get('mail')
325 //Set mail from login form
326 ->setData($login->get('mail')->getData())
328 ->addError(new FormError($this->translator
->trans('Use this form to recover your account', [], $this->alias
)));
330 //Add recover form to context
331 $context['recover'] = $recover->createView();
334 $this->addFlash('notice', $this->translator
->trans('To change your password login with your mail and any password then follow the procedure', [], $this->alias
));
338 return $this->render(
340 $this->config
['login']['view']['name'],
342 ['login' => $login->createView(), 'disabled' => $request->query
->get('disabled', 0)]+
$context+
$this->config
['login']['view']['context']
349 * @param Request $request The request
350 * @param ?string $hash The hashed password
351 * @param ?string $pass The shorted password
352 * @param ?string $mail The shorted mail address
353 * @return Response The response
355 public function recover(Request
$request, ?string $hash, ?string $pass, ?string $mail): Response
{
362 //With mail, pass and hash
363 if (!empty($mail) && !empty($pass) && !empty($hash)) {
365 if ($hash != $this->slugger
->hash($mail.$pass)) {
367 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'hash', '%value%' => $hash], $this->alias
));
371 $mail = $this->slugger
->unshort($smail = $mail);
374 if (filter_var($mail, FILTER_VALIDATE_EMAIL
) === false) {
376 //XXX: prevent slugger reverse engineering by not displaying decoded mail
377 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'mail', '%value%' => $smail], $this->alias
));
380 //With existing subscriber
381 if (empty($user = $this->doctrine
->getRepository($this->config
['class']['user'])->findOneByMail($mail))) {
383 //XXX: prevent slugger reverse engineering by not displaying decoded mail
384 throw $this->createNotFoundException($this->translator
->trans('Unable to find account', [], $this->alias
));
387 //With unmatched pass
388 if ($pass != $this->slugger
->hash($user->getPassword())) {
390 //XXX: prevent use of outdated recover link
391 throw new GoneHttpException($this->translator
->trans('Outdated recover link', [], $this->alias
));
395 $context = ['mail' => $smail, 'pass' => $pass, 'hash' => $hash];
398 //Create the LoginType form and give the proper parameters
399 $form = $this->factory
->create($this->config
['recover']['view']['form'], $user, [
400 //Set action to recover route name and context
401 'action' => $this->generateUrl($this->config
['route']['recover']['name'], $context+
$this->config
['route']['recover']['context']),
402 //With user disable mail
403 'mail' => ($user === null),
404 //With user enable password
405 'password' => ($user !== null),
409 'translation_domain' => $this->alias
413 if ($request->isMethod('POST')) {
414 //Refill the fields in case the form is not valid.
415 $form->handleRequest($request);
417 //With form submitted and valid
418 if ($form->isSubmitted() && $form->isValid()) {
420 $data = $form->getData();
423 if ($user !== null) {
424 //Set hashed password
425 $hashed = $this->hasher
->hashPassword($user, $user->getPassword());
428 $pass = $this->slugger
->hash($hashed);
431 $user->setPassword($hashed);
434 $this->manager
->persist($user);
437 $this->manager
->flush();
440 $this->addFlash('notice', $this->translator
->trans('Account password updated', [], $this->alias
));
442 //Redirect to user login
443 return $this->redirectToRoute($this->config
['route']['login']['name'], ['mail' => $smail, 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['login']['context']);
444 //Find user by data mail
445 } elseif ($user = $this->doctrine
->getRepository($this->config
['class']['user'])->findOneByMail($data['mail'])) {
448 'recipient_mail' => $user->getMail(),
449 'recipient_name' => $user->getRecipientName()
450 ] +
array_replace_recursive(
451 $this->config
['context'],
452 $this->config
['recover']['view']['context'],
453 $this->config
['recover']['mail']['context']
456 //Generate each route route
457 foreach($this->config
['recover']['route'] as $route => $tag) {
458 //Only process defined routes
459 if (!empty($this->config
['route'][$route])) {
460 //Process for recover mail url
461 if ($route == 'recover') {
462 //Set the url in context
463 $context[$tag] = $this->router
->generate(
464 $this->config
['route'][$route]['name'],
465 //Prepend recover context with tag
467 'mail' => $smail = $this->slugger
->short($context['recipient_mail']),
468 'pass' => $spass = $this->slugger
->hash($pass = $user->getPassword()),
469 'hash' => $this->slugger
->hash($smail.$spass)
470 ]+
$this->config
['route'][$route]['context'],
471 UrlGeneratorInterface
::ABSOLUTE_URL
478 $context['subject'] = $subject = ucfirst(
479 $this->translator
->trans(
480 $this->config
['recover']['mail']['subject'],
481 $this->slugger
->flatten($context, null, '.', '%', '%'),
487 $message = (new TemplatedEmail())
489 ->from(new Address($this->config
['contact']['address'], $this->translator
->trans($this->config
['contact']['name'], [], $this->alias
)))
491 //XXX: remove the debug set in vendor/symfony/mime/Address.php +46
492 ->to(new Address($context['recipient_mail'], $context['recipient_name']))
494 ->subject($context['subject'])
496 //Set path to twig templates
497 ->htmlTemplate($this->config
['recover']['mail']['html'])
498 ->textTemplate($this->config
['recover']['mail']['text'])
504 $this->addFlash('notice', $this->translator
->trans('Account recovered', [], $this->alias
));
506 //Try sending message
507 //XXX: mail delivery may silently fail
510 $this->mailer
->send($message);
513 $this->addFlash('notice', $this->translator
->trans('Your recovery mail has been sent, to retrieve your account follow the recuperate link inside', [], $this->alias
));
516 $this->addFlash('warning', $this->translator
->trans('If you did not receive a recovery mail, check your Spam or Junk mail folder', [], $this->alias
));
518 //Redirect on home route to cleanup form
519 return $this->redirectToRoute($this->config
['route']['home']['name'], $this->config
['route']['home']['context']);
520 //Catch obvious transport exception
521 } catch(TransportExceptionInterface
$e) {
522 //Add error message mail unreachable
523 $form->get('mail')->addError(new FormError($this->translator
->trans('Unable to reach account', [], $this->alias
)));
530 return $this->render(
532 $this->config
['recover']['view']['name'],
534 ['recover' => $form->createView()]+
$this->config
['recover']['view']['context']
539 * Register an account
541 * @param Request $request The request
542 * @return Response The response
544 public function register(Request
$request): Response
{
546 if (!empty($_POST['register']['mail'])) {
548 $this->logger
->emergency(
549 $this->translator
->trans(
550 'register: mail=%mail% locale=%locale% confirm=%confirm% ip=%ip%',
552 '%mail%' => $postMail = $_POST['register']['mail'],
553 '%locale%' => $request->getLocale(),
554 '%confirm%' => $this->router
->generate(
555 $this->config
['route']['confirm']['name'],
556 //Prepend subscribe context with tag
558 'mail' => $postSmail = $this->slugger
->short($postMail),
559 'hash' => $this->slugger
->hash($postSmail)
560 ]+
$this->config
['route']['confirm']['context'],
561 UrlGeneratorInterface
::ABSOLUTE_URL
563 '%ip%' => $request->getClientIp()
571 $reflection = new \
ReflectionClass($this->config
['class']['user']);
574 $user = $reflection->newInstance('', '');
576 //Create the RegisterType form and give the proper parameters
577 $form = $this->factory
->create($this->config
['register']['view']['form'], $user, [
578 //Set action to register route name and context
579 'action' => $this->generateUrl($this->config
['route']['register']['name'], $this->config
['route']['register']['context']),
583 'civility_class' => $this->config
['class']['civility'],
584 //Set civility default
585 'civility_default' => $this->doctrine
->getRepository($this->config
['class']['civility'])->findOneByTitle($this->config
['default']['civility']),
589 'translation_domain' => $this->alias
590 ]+
($this->checker
->isGranted('ROLE_'.strtoupper($this->config
['default']['admin']))?$this->config
['register']['admin']:$this->config
['register']['field']));
593 if ($request->isMethod('POST')) {
594 //Refill the fields in case the form is not valid.
595 $form->handleRequest($request);
597 //With form submitted and valid
598 if ($form->isSubmitted() && $form->isValid()) {
600 $data = $form->getData();
603 $user->setPassword($this->hasher
->hashPassword($user, $user->getPassword()));
606 $this->manager
->persist($user);
608 //Iterate on default group
609 foreach($this->config
['default']['group'] as $i => $groupTitle) {
611 if (($group = $this->doctrine
->getRepository($this->config
['class']['group'])->findOneByTitle($groupTitle))) {
613 //XXX: see vendor/symfony/security-core/Role/Role.php
614 $user->addGroup($group);
618 //XXX: consider missing group as fatal
619 throw new \
Exception(sprintf('Group %s listed in %s.default.group[%d] not found by title', $groupTitle, $this->alias
, $i));
625 'recipient_mail' => $user->getMail(),
626 'recipient_name' => $user->getRecipientName()
627 ] +
array_replace_recursive(
628 $this->config
['context'],
629 $this->config
['register']['view']['context'],
630 $this->config
['register']['mail']['context']
633 //Generate each route route
634 foreach($this->config
['register']['route'] as $route => $tag) {
635 //Only process defined routes
636 if (!empty($this->config
['route'][$route])) {
637 //Process for confirm mail url
638 if ($route == 'confirm') {
639 //Set the url in context
640 $context[$tag] = $this->router
->generate(
641 $this->config
['route'][$route]['name'],
642 //Prepend register context with tag
644 'mail' => $smail = $this->slugger
->short($context['recipient_mail']),
645 'hash' => $this->slugger
->hash($smail)
646 ]+
$this->config
['route'][$route]['context'],
647 UrlGeneratorInterface
::ABSOLUTE_URL
654 $context['subject'] = $subject = ucfirst(
655 $this->translator
->trans(
656 $this->config
['register']['mail']['subject'],
657 $this->slugger
->flatten($context, null, '.', '%', '%'),
663 $message = (new TemplatedEmail())
665 ->from(new Address($this->config
['contact']['address'], $this->translator
->trans($this->config
['contact']['name'], [], $this->alias
)))
667 //XXX: remove the debug set in vendor/symfony/mime/Address.php +46
668 ->to(new Address($context['recipient_mail'], $context['recipient_name']))
670 ->subject($context['subject'])
672 //Set path to twig templates
673 ->htmlTemplate($this->config
['register']['mail']['html'])
674 ->textTemplate($this->config
['register']['mail']['text'])
679 //Try saving in database
682 $this->manager
->flush();
685 $this->addFlash('notice', $this->translator
->trans('Account created', [], $this->alias
));
687 //Try sending message
688 //XXX: mail delivery may silently fail
691 $this->mailer
->send($message);
693 //Add verification notice
694 $this->addFlash('notice', $this->translator
->trans('Your verification mail has been sent, to activate your account you must follow the confirmation link inside', [], $this->alias
));
697 $this->addFlash('warning', $this->translator
->trans('If you did not receive a verification mail, check your Spam or Junk mail folders', [], $this->alias
));
699 //Redirect on home route to cleanup form
700 return $this->redirectToRoute($this->config
['route']['home']['name'], $this->config
['route']['home']['context']);
701 //Catch obvious transport exception
702 } catch(TransportExceptionInterface
$e) {
703 //Add error message mail unreachable
704 $form->get('mail')->addError(new FormError($this->translator
->trans('Unable to reach account', [], $this->alias
)));
706 //Catch double subscription
707 } catch (UniqueConstraintViolationException
$e) {
708 //Add error message mail already exists
709 $this->addFlash('error', $this->translator
->trans('The account already exists', [], $this->alias
));
715 return $this->render(
717 $this->config
['register']['view']['name'],
719 ['register' => $form->createView()]+
$this->config
['register']['view']['context']