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
;
15 use Symfony\Bridge\Twig\Mime\TemplatedEmail
;
16 use Symfony\Component\Form\FormError
;
17 use Symfony\Component\HttpFoundation\Request
;
18 use Symfony\Component\HttpFoundation\Response
;
19 use Symfony\Component\HttpKernel\Exception\BadRequestHttpException
;
20 use Symfony\Component\Mailer\Exception\TransportExceptionInterface
;
21 use Symfony\Component\Mime\Address
;
22 use Symfony\Component\Routing\Generator\UrlGeneratorInterface
;
23 use Symfony\Component\Security\Http\Authentication\AuthenticationUtils
;
25 use Rapsys\UserBundle\RapsysUserBundle
;
30 class UserController
extends AbstractController
{
34 * @param Request $request The request
35 * @return Response The response
37 public function index(Request
$request): Response
{
39 if (!$this->checker
->isGranted($this->config
['default']['admin'])) {
41 throw $this->createAccessDeniedException($this->translator
->trans('Unable to list users'));
45 $this->context
['count'] = $this->doctrine
->getRepository($this->config
['class']['user'])->findCountAsInt();
47 //With not enough users
48 if ($this->context
['count'] - $this->page
* $this->limit
< 0) {
50 throw $this->createNotFoundException($this->translator
->trans('Unable to find users'));
54 $this->context
['users'] = $this->doctrine
->getRepository($this->config
['class']['user'])->findAllAsArray($this->page
, $this->limit
);
59 $this->config
['index']['view']['name'],
61 $this->context+
$this->config
['index']['view']['context']
66 * Confirm account from mail link
68 * @param Request $request The request
69 * @param string $hash The hashed password
70 * @param string $mail The shorted mail address
71 * @return Response The response
73 public function confirm(Request
$request, string $hash, string $mail): Response
{
75 if ($hash != $this->slugger
->hash($mail)) {
77 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'hash', '%value%' => $hash]));
81 $mail = $this->slugger
->unshort($smail = $mail);
84 if (filter_var($mail, FILTER_VALIDATE_EMAIL
) === false) {
86 //XXX: prevent slugger reverse engineering by not displaying decoded mail
87 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'mail', '%value%' => $smail]));
90 //Without existing registrant
91 if (!($user = $this->doctrine
->getRepository($this->config
['class']['user'])->findOneByMail($mail))) {
92 //Add error message mail already exists
93 //XXX: prevent slugger reverse engineering by not displaying decoded mail
94 $this->addFlash('error', $this->translator
->trans('Account do not exists'));
96 //Redirect to register view
97 return $this->redirectToRoute($this->config
['route']['register']['name'], $this->config
['route']['register']['context']);
101 $user->setActive(true);
104 $this->manager
->persist($user);
107 $this->manager
->flush();
109 //Add error message mail already exists
110 $this->addFlash('notice', $this->translator
->trans('Your account has been activated'));
112 //Redirect to user view
113 return $this->redirectToRoute($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']);
117 * Edit account by shorted mail
119 * @param Request $request The request
120 * @param string $hash The hashed password
121 * @param string $mail The shorted mail address
122 * @return Response The response
124 public function edit(Request
$request, string $hash, string $mail): Response
{
126 if ($hash != $this->slugger
->hash($mail)) {
128 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'hash', '%value%' => $hash]));
132 $mail = $this->slugger
->unshort($smail = $mail);
134 //With existing subscriber
135 if (empty($user = $this->doctrine
->getRepository($this->config
['class']['user'])->findOneByMail($mail))) {
137 //XXX: prevent slugger reverse engineering by not displaying decoded mail
138 throw $this->createNotFoundException($this->translator
->trans('Unable to find account'));
141 //Prevent access when not admin, user is not guest and not currently logged user
142 if (!$this->checker
->isGranted($this->config
['default']['admin']) && $user != $this->security
->getUser() || !$this->checker
->isGranted('IS_AUTHENTICATED_FULLY')) {
143 //Throw access denied
144 //XXX: prevent slugger reverse engineering by not displaying decoded mail
145 throw $this->createAccessDeniedException($this->translator
->trans('Unable to access user'));
148 //Create the EditType form and give the proper parameters
149 $edit = $this->createForm($this->config
['edit']['view']['edit'], $user, [
150 //Set action to edit route name and context
151 'action' => $this->generateUrl($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']),
153 'civility_class' => $this->config
['class']['civility'],
154 //Set civility default
155 'civility_default' => $this->doctrine
->getRepository($this->config
['class']['civility'])->findOneByTitle($this->config
['default']['civility']),
158 ]+
($this->checker
->isGranted($this->config
['default']['admin'])?$this->config
['edit']['admin']:$this->config
['edit']['field']));
161 if ($this->checker
->isGranted($this->config
['default']['admin'])) {
162 //Create the EditType form and give the proper parameters
163 $reset = $this->createForm($this->config
['edit']['view']['reset'], $user, [
164 //Set action to edit route name and context
165 'action' => $this->generateUrl($this->config
['route']['edit']['name'], ['mail' => $smail, 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']),
171 if ($request->isMethod('POST')) {
172 //Refill the fields in case the form is not valid.
173 $reset->handleRequest($request);
175 //With reset submitted and valid
176 if ($reset->isSubmitted() && $reset->isValid()) {
178 $data = $reset->getData();
181 $data->setPassword($this->hasher
->hashPassword($data, $data->getPassword()));
184 $this->manager
->persist($data);
186 //Flush to get the ids
187 $this->manager
->flush();
190 $this->addFlash('notice', $this->translator
->trans('Account password updated'));
192 //Redirect to cleanup the form
193 return $this->redirectToRoute($this->config
['route']['edit']['name'], ['mail' => $smail = $this->slugger
->short($mail), 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']);
198 $this->config
['edit']['view']['context']['reset'] = $reset->createView();
202 if ($request->isMethod('POST')) {
203 //Refill the fields in case the form is not valid.
204 $edit->handleRequest($request);
206 //With edit submitted and valid
207 if ($edit->isSubmitted() && $edit->isValid()) {
209 $data = $edit->getData();
212 $this->manager
->persist($data);
214 //Try saving in database
216 //Flush to get the ids
217 $this->manager
->flush();
220 $this->addFlash('notice', $this->translator
->trans('Account updated'));
222 //Redirect to cleanup the form
223 return $this->redirectToRoute($this->config
['route']['edit']['name'], ['mail' => $smail = $this->slugger
->short($mail), 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['edit']['context']);
224 //Catch double slug or mail
225 } catch (UniqueConstraintViolationException
$e) {
226 //Add error message mail already exists
227 $this->addFlash('error', $this->translator
->trans('Account already exists'));
231 //XXX: prefer a reset on login to force user unspam action
232 } elseif (!$this->checker
->isGranted($this->config
['default']['admin'])) {
234 $this->addFlash('notice', $this->translator
->trans('To change your password login with your mail and any password then follow the procedure'));
238 return $this->render(
240 $this->config
['edit']['view']['name'],
242 ['edit' => $edit->createView(), 'sent' => $request->query
->get('sent', 0)]+
$this->config
['edit']['view']['context']
249 * @param Request $request The request
250 * @param AuthenticationUtils $authenticationUtils The authentication utils
251 * @param ?string $hash The hashed password
252 * @param ?string $mail The shorted mail address
253 * @return Response The response
255 public function login(Request
$request, AuthenticationUtils
$authenticationUtils, ?string $hash, ?string $mail): Response
{
256 //Create the LoginType form and give the proper parameters
257 $login = $this->createForm($this->config
['login']['view']['form'], null, [
258 //Set action to login route name and context
259 'action' => $this->generateUrl($this->config
['route']['login']['name'], $this->config
['route']['login']['context']),
268 if (!empty($mail) && !empty($hash)) {
270 if ($hash != $this->slugger
->hash($mail)) {
272 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'hash', '%value%' => $hash]));
276 $mail = $this->slugger
->unshort($smail = $mail);
279 if (filter_var($mail, FILTER_VALIDATE_EMAIL
) === false) {
281 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'mail', '%value%' => $smail]));
285 $login->get('mail')->setData($mail);
286 //Last username entered by the user
287 } elseif ($lastUsername = $authenticationUtils->getLastUsername()) {
288 $login->get('mail')->setData($lastUsername);
291 //Get the login error if there is one
292 if ($error = $authenticationUtils->getLastAuthenticationError()) {
293 //Get translated error
294 $error = $this->translator
->trans($error->getMessageKey());
296 //Add error message to mail field
297 $login->get('mail')->addError(new FormError($error));
299 //Create the RecoverType form and give the proper parameters
300 $recover = $this->createForm($this->config
['recover']['view']['form'], null, [
301 //Set action to recover route name and context
302 'action' => $this->generateUrl($this->config
['route']['recover']['name'], $this->config
['route']['recover']['context']),
309 //Get recover mail entity
310 $recover->get('mail')
311 //Set mail from login form
312 ->setData($login->get('mail')->getData())
314 ->addError(new FormError($this->translator
->trans('Use this form to recover your account')));
316 //Add recover form to context
317 $context['recover'] = $recover->createView();
320 $this->addFlash('notice', $this->translator
->trans('To change your password login with your mail and any password then follow the procedure'));
324 return $this->render(
326 $this->config
['login']['view']['name'],
328 ['login' => $login->createView(), 'disabled' => $request->query
->get('disabled', 0), 'sent' => $request->query
->get('sent', 0)]+
$context+
$this->config
['login']['view']['context']
335 * @param Request $request The request
336 * @param ?string $hash The hashed password
337 * @param ?string $pass The shorted password
338 * @param ?string $mail The shorted mail address
339 * @return Response The response
341 public function recover(Request
$request, ?string $hash, ?string $pass, ?string $mail): Response
{
348 //With mail, pass and hash
349 if (!empty($mail) && !empty($pass) && !empty($hash)) {
351 if ($hash != $this->slugger
->hash($mail.$pass)) {
353 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'hash', '%value%' => $hash]));
357 $mail = $this->slugger
->unshort($smail = $mail);
360 if (filter_var($mail, FILTER_VALIDATE_EMAIL
) === false) {
362 //XXX: prevent slugger reverse engineering by not displaying decoded mail
363 throw new BadRequestHttpException($this->translator
->trans('Invalid %field% field: %value%', ['%field%' => 'mail', '%value%' => $smail]));
366 //With existing subscriber
367 if (empty($user = $this->doctrine
->getRepository($this->config
['class']['user'])->findOneByMail($mail))) {
369 //XXX: prevent slugger reverse engineering by not displaying decoded mail
370 throw $this->createNotFoundException($this->translator
->trans('Unable to find account'));
373 //With unmatched pass
374 if ($pass != $this->slugger
->hash($user->getPassword())) {
376 //XXX: prevent use of outdated recover link
377 throw $this->createNotFoundException($this->translator
->trans('Outdated recover link'));
381 $context = ['mail' => $smail, 'pass' => $pass, 'hash' => $hash];
384 //Create the LoginType form and give the proper parameters
385 $form = $this->createForm($this->config
['recover']['view']['form'], $user, [
386 //Set action to recover route name and context
387 'action' => $this->generateUrl($this->config
['route']['recover']['name'], $context+
$this->config
['route']['recover']['context']),
388 //With user disable mail
389 'mail' => ($user === null),
390 //With user enable password
391 'password' => ($user !== null),
397 if ($request->isMethod('POST')) {
398 //Refill the fields in case the form is not valid.
399 $form->handleRequest($request);
401 //With form submitted and valid
402 if ($form->isSubmitted() && $form->isValid()) {
404 $data = $form->getData();
407 if ($user !== null) {
408 //Set hashed password
409 $hashed = $this->hasher
->hashPassword($user, $user->getPassword());
412 $pass = $this->slugger
->hash($hashed);
415 $user->setPassword($hashed);
418 $this->manager
->persist($user);
421 $this->manager
->flush();
424 $this->addFlash('notice', $this->translator
->trans('Account password updated'));
426 //Redirect to user login
427 return $this->redirectToRoute($this->config
['route']['login']['name'], ['mail' => $smail, 'hash' => $this->slugger
->hash($smail)]+
$this->config
['route']['login']['context']);
428 //Find user by data mail
429 } elseif ($user = $this->doctrine
->getRepository($this->config
['class']['user'])->findOneByMail($data['mail'])) {
432 'recipient_mail' => $user->getMail(),
433 'recipient_name' => $user->getRecipientName()
434 ] +
array_replace_recursive(
435 $this->config
['context'],
436 $this->config
['recover']['view']['context'],
437 $this->config
['recover']['mail']['context']
440 //Generate each route route
441 foreach($this->config
['recover']['route'] as $route => $tag) {
442 //Only process defined routes
443 if (!empty($this->config
['route'][$route])) {
444 //Process for recover mail url
445 if ($route == 'recover') {
446 //Set the url in context
447 $context[$tag] = $this->router
->generate(
448 $this->config
['route'][$route]['name'],
449 //Prepend recover context with tag
451 'mail' => $smail = $this->slugger
->short($context['recipient_mail']),
452 'pass' => $spass = $this->slugger
->hash($pass = $user->getPassword()),
453 'hash' => $this->slugger
->hash($smail.$spass)
454 ]+
$this->config
['route'][$route]['context'],
455 UrlGeneratorInterface
::ABSOLUTE_URL
461 //Iterate on keys to translate
462 foreach($this->config
['translate'] as $translate) {
464 $keys = explode('.', $translate);
467 $current =& $context;
469 //Iterate on each subkey
471 //Skip unset translation keys
472 if (!isset($current[current($keys)])) {
476 //Set current to subkey
477 $current =& $current[current($keys)];
478 } while(next($keys));
481 $current = $this->translator
->trans($current);
488 $context['subject'] = $subject = ucfirst(
489 $this->translator
->trans(
490 $this->config
['recover']['mail']['subject'],
491 $this->slugger
->flatten($context, null, '.', '%', '%')
496 $message = (new TemplatedEmail())
498 ->from(new Address($this->config
['contact']['address'], $this->config
['contact']['name']))
500 //XXX: remove the debug set in vendor/symfony/mime/Address.php +46
501 ->to(new Address($context['recipient_mail'], $context['recipient_name']))
503 ->subject($context['subject'])
505 //Set path to twig templates
506 ->htmlTemplate($this->config
['recover']['mail']['html'])
507 ->textTemplate($this->config
['recover']['mail']['text'])
512 //Try sending message
513 //XXX: mail delivery may silently fail
516 $this->mailer
->send($message);
519 $this->addFlash('notice', $this->translator
->trans('Your recovery mail has been sent, to retrieve your account follow the recuperate link inside'));
522 $this->addFlash('warning', $this->translator
->trans('If you did not receive a recovery mail, check your Spam or Junk mail folder'));
524 //Redirect on the same route with sent=1 to cleanup form
525 return $this->redirectToRoute($request->get('_route'), ['sent' => 1]+
$request->get('_route_params'), 302);
526 //Catch obvious transport exception
527 } catch(TransportExceptionInterface
$e) {
528 //Add error message mail unreachable
529 $form->get('mail')->addError(new FormError($this->translator
->trans('Unable to reach account')));
536 return $this->render(
538 $this->config
['recover']['view']['name'],
540 ['recover' => $form->createView(), 'sent' => $request->query
->get('sent', 0)]+
$this->config
['recover']['view']['context']
545 * Register an account
547 * @param Request $request The request
548 * @return Response The response
550 public function register(Request
$request): Response
{
552 if (!empty($_POST['register']['mail'])) {
554 $this->logger
->emergency(
555 $this->translator
->trans(
556 'register: mail=%mail% locale=%locale% confirm=%confirm%',
558 '%mail%' => $postMail = $_POST['register']['mail'],
559 '%locale%' => $request->getLocale(),
560 '%confirm%' => $this->router
->generate(
561 $this->config
['route']['confirm']['name'],
562 //Prepend subscribe context with tag
564 'mail' => $postSmail = $this->slugger
->short($postMail),
565 'hash' => $this->slugger
->hash($postSmail)
566 ]+
$this->config
['route']['confirm']['context'],
567 UrlGeneratorInterface
::ABSOLUTE_URL
575 $reflection = new \
ReflectionClass($this->config
['class']['user']);
578 $user = $reflection->newInstance('', '');
580 //Create the RegisterType form and give the proper parameters
581 $form = $this->createForm($this->config
['register']['view']['form'], $user, [
582 //Set action to register route name and context
583 'action' => $this->generateUrl($this->config
['route']['register']['name'], $this->config
['route']['register']['context']),
585 'civility_class' => $this->config
['class']['civility'],
586 //Set civility default
587 'civility_default' => $this->doctrine
->getRepository($this->config
['class']['civility'])->findOneByTitle($this->config
['default']['civility']),
590 ]+
($this->checker
->isGranted($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, RapsysUserBundle
::getAlias(), $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
653 //Iterate on keys to translate
654 foreach($this->config
['translate'] as $translate) {
656 $keys = explode('.', $translate);
659 $current =& $context;
661 //Iterate on each subkey
663 //Skip unset translation keys
664 if (!isset($current[current($keys)])) {
668 //Set current to subkey
669 $current =& $current[current($keys)];
670 } while(next($keys));
673 $current = $this->translator
->trans($current);
680 $context['subject'] = $subject = ucfirst(
681 $this->translator
->trans(
682 $this->config
['register']['mail']['subject'],
683 $this->slugger
->flatten($context, null, '.', '%', '%')
688 $message = (new TemplatedEmail())
690 ->from(new Address($this->config
['contact']['address'], $this->config
['contact']['name']))
692 //XXX: remove the debug set in vendor/symfony/mime/Address.php +46
693 ->to(new Address($context['recipient_mail'], $context['recipient_name']))
695 ->subject($context['subject'])
697 //Set path to twig templates
698 ->htmlTemplate($this->config
['register']['mail']['html'])
699 ->textTemplate($this->config
['register']['mail']['text'])
704 //Try saving in database
707 $this->manager
->flush();
709 //Add error message mail already exists
710 $this->addFlash('notice', $this->translator
->trans('Account created'));
712 //Try sending message
713 //XXX: mail delivery may silently fail
716 $this->mailer
->send($message);
718 //Redirect on the same route with sent=1 to cleanup form
719 return $this->redirectToRoute($request->get('_route'), ['sent' => 1]+
$request->get('_route_params'));
720 //Catch obvious transport exception
721 } catch(TransportExceptionInterface
$e) {
722 //Add error message mail unreachable
723 $form->get('mail')->addError(new FormError($this->translator
->trans('Unable to reach account')));
725 //Catch double subscription
726 } catch (UniqueConstraintViolationException
$e) {
727 //Add error message mail already exists
728 $this->addFlash('error', $this->translator
->trans('Account already exists'));
734 return $this->render(
736 $this->config
['register']['view']['name'],
738 ['register' => $form->createView(), 'sent' => $request->query
->get('sent', 0)]+
$this->config
['register']['view']['context']