<?php
namespace App\Controller;
use App\Form\EventListener\ReCaptchaValidatorListener;
use App\Service\Cypher\Cypher;
use App\Service\UserManager;
use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\FormType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TelType;
use Symfony\Component\Form\FormError;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Validator\Constraints\Email;
use Symfony\Component\Validator\Constraints\Length;
use Symfony\Component\Validator\Constraints\NotBlank;
use Symfony\Component\Validator\Constraints\Type;
/**
* @Route("/restablecer", name="password_restore_",
* requirements={"_locale":"%app.supported_locales%"},
* defaults={"_locale" = "es", "_title"="Pasarela restablecer clave de la Universidad de Murcia"})
*/
class PasswordRestoreController extends AbstractController
{
const PANEL_STEPS = 4;
const PASSWORD_MIN_LEN = 12;
const PHONE_MIN_LEN = 7;
const PHONE_MAX_LEN = 20;
const MAIL_MIN_LEN = 5;
const MAIL_MAX_LEN = 64;
const DEFAULT_ERRORS = [
500 => 'error.default.500', // Se ha producido un error inesperado, vuelva a intentarlo más tarde.
];
/**
* @Route("/{_locale}", name="index", requirements={"_locale"="[a-zA-Z]+"})
*/
public function index(Request $request, UserManager $userManager, Cypher $cypher, LoggerInterface $logger): Response
{
// Creación de formularios de usuario y validación
$userForm = $this->createUserForm();
$userForm->handleRequest($request);
$phoneValidationForm = $this->createPhoneValidationForm();
$phoneValidationForm->handleRequest($request);
$emailValidationForm = $this->createEmailValidationForm();
$emailValidationForm->handleRequest($request);
$validationForm = null;
// Función anónima que renderiza el paso 1
$render1 = function () use ($logger, $userForm) {
return $this->renderForm('password_restore_step1.html.twig', [
'panel_steps' => self::PANEL_STEPS,
'panel_current_step' => 1,
'user_form' => $userForm
]);
};
// Función anónima que renderizar el paso 2 con todos sus formularios
$render2 = function (string $username, array $contacts, $formWithErrors = null) use ($logger, $cypher, $phoneValidationForm, $emailValidationForm) {
// Preparar los datos de los formularios a renderizar
$defaultMethod = null;
foreach ($contacts as &$contact) {
$form = &${$contact['method'].'ValidationForm'};
// Se recibió un formulario con errores que se deberá renderizar por defecto
if (isset($formWithErrors) && $form === $formWithErrors) {
$defaultMethod = $contact['method'];
}
// Preparar datos de otros formularios
else {
$form->get('username')->setData($cypher->encrypt([$username]));
$form->get('method')->setData($contact['method']);
$form->get('hint')->setData($contact['hint']);
if ('phone' == $contact['method'])
$defaultMethod = $contact['method'];
}
$contact['tabid'] = 'recovery_method_'.$contact['method'];
$contact['form'] = $form->createView();
}
unset($contact);
$logger->info(sprintf('[restablecer] inicio de solicitud de cambio [username=%s, methods=%s]', $username, implode(',', array_column($contacts, 'method'))));
return $this->renderForm("password_restore_step2.html.twig", [
'panel_steps' => self::PANEL_STEPS,
'panel_current_step' => 2,
'contacts' => $contacts,
'default_method' => $defaultMethod
]);
};
// Función anónima que renderizar el paso 3, de envío de código al método elegido
$render3 = function (string $username, string $method, bool $extern = false) use ($logger) {
$logger->info(sprintf('[restablecer] envío de solicitud de cambio [username=%s, method=%s, extern=%s]', $username, $method, $extern ? 'true' : 'false'));
return $this->render('password_restore_step3.html.twig', [
'panel_steps' => self::PANEL_STEPS,
'panel_current_step' => 3,
'recovery_method' => $method,
]);
};
// Obtener datos de los formularios ya enviados y setear variables
$method = null;
$data = [];
if ($userForm->isSubmitted() && $userForm->isValid()) {
$data = $userForm->getData();
}
elseif ($phoneValidationForm->isSubmitted()) {
$method = UserManager::RECOVERY_METHOD_PHONE;
$validationForm = &$phoneValidationForm;
$data = $phoneValidationForm->getData();
$data['username'] = $cypher->decrypt($data['username'])[0] ?? '';
if ($phoneValidationForm->isValid()) {
$code = $data['country_code'];
$contact = strlen($code) == 0 || $code == 34 ? $data['contact'] : '+'.$code.$data['contact'];
$contactError = 'El teléfono móvil introducido no coincide con el registrado.';
}
}
elseif ($emailValidationForm->isSubmitted()) {
$method = UserManager::RECOVERY_METHOD_MAIL;
$validationForm = &$emailValidationForm;
$data = $emailValidationForm->getData();
$data['username'] = $cypher->decrypt($data['username'])[0] ?? '';
if ($emailValidationForm->isValid()) {
$contact = $data['contact'];
$contactError = 'El correo introducido no coincide con el registrado.';
}
}
// Comprobar proactivamente que el usuario es válido, no está bloqueado y obtener información actualizada
if (! empty($data)) {
try {
$userManager->infoAccount($data['username'], true);
$userInfo = $userManager->validateUserV2($data['username']);
} catch (\Throwable $th) {
// 404 debería volver a rederizar $userForm seteando error en username
if (404 == $th->getCode()) {
$userForm->get('username')->addError(new FormError($th->getMessage()));
return $render1();
}
// Error inesperado renderizar pantalla de error
return $this->render('error.html.twig', [
'error_code' => $th->getCode(),
'error_body' => $th->getMessage(),
]);
}
}
// Validar paso 1, ir a paso 2 o 3
if ($userForm->isSubmitted()) {
if ($userForm->isValid()) {
// Ir a paso 3 cuando es un usuario externo
if ($userInfo['externo']) {
return $render3($data['username'], UserManager::RECOVERY_METHOD_MAIL, true);
}
// Ir al paso 2, con todos los métodos disponibles
return $render2($data['username'], $userInfo['contacto']);
}
}
// Validar paso 2, ir a paso 3
elseif (isset($validationForm) && $validationForm->isSubmitted()) {
if ($validationForm->isValid()) {
try {
$result = $userManager->validateRecoveryMethod($data['username'], $userInfo['dni'], $contact, $method);
} catch (\Throwable $th) {
// 404 debería volver a renderizar $phoneValidationForm seteando error en phone
if ($th->getCode() == 404) {
// Sobrescribir el mensaje del UserManager por teléfono erróneo, por descarte y puesto que el usuario ya está validado
//$validationForm->get('contact')->addError(new FormError($th->getMessage()));
$validationForm->get('contact')->addError(new FormError($contactError));
return $render2($data['username'], $userInfo['contacto'], $validationForm);
// Error inesperado renderizar pantalla de error
} else {
return $this->render('error.html.twig', [
'error_code' => $th->getCode(),
'error_body' => $th->getMessage(),
]);
}
}
// Paso 3 tras comprobar todo ok
return $render3($data['username'], $method);
}
// El formulario se envío pero es erróneo, volver a mostrar paso 2
return $render2($data['username'], $userInfo['contacto'], $validationForm);
}
// Paso 1 por defecto
return $render1();
}
/**
* Redirección cunado no hay locale establecida.
* @Route("/{recovery_code}", name="passwd_redirect", defaults={"recovery_code" = "0"}, requirements={"recovery_code"="\d+"})
*/
public function passwdNoLocale(Request $request, string $recovery_code = 'empty')
{
return $this->redirectToRoute('password_restore_passwd', ['_locale' => $request->getLocale(), 'recovery_code' => $recovery_code]);
}
/**
* @Route("/{_locale}/{recovery_code}", name="passwd", defaults={"recovery_code" = "0"}, requirements={"recovery_code"="\d+"})
*/
public function passwd(string $recovery_code, Request $request, ReCaptchaValidatorListener $recaptchaValidator, UserManager $userManager, LoggerInterface $logger): Response
{
// Formulario nueva contraseña
$passwdForm = $this->createPasswdForm($recaptchaValidator);
$passwdForm->handleRequest($request);
if ($passwdForm->isSubmitted()) {
if ($passwdForm->isValid()) {
// Realizar el cambio de contraseña
$data = $passwdForm->getData();
try {
$result = $userManager->changePasswordWithToken($data['token'], $data['password']);
} catch (\Throwable $th) {
// Contraseña no válida
if (400 == $th->getCode() || 406 == $th->getCode()) {
//$passwdForm->addError(new FormError($th->getMessage()));
$passwdForm->get('password')['first']->addError(new FormError($th->getMessage()));
return $this->renderForm('password_restore_step4.html.twig', [
'panel_steps' => self::PANEL_STEPS,
'panel_current_step' => 4,
'passwd_form' => $passwdForm
]);
// Token no válido
} else if (403 == $th->getCode()) {
return $this->render('error.html.twig', [
'error_code' => $th->getCode(),
'error_body' => $th->getMessage(),
'back_btn_href' => $this->generateUrl('password_restore_index', ['_locale' => $request->getLocale() ]),
]);
// Cualquier otro error
} else {
return $this->render('error.html.twig', [
'error_code' => $th->getCode(),
'error_body' => $th->getMessage(),
]);
}
}
$logger->info('[restablecer] cambio de clave '.$data['token']);
return $this->render('success.html.twig', [ 'show_chpwd_help' => true ]);
}
}
// Establecer token del formulario si no se envió
else
$passwdForm->get('token')->setData($recovery_code);
// Renderizar
return $this->renderForm('password_restore_step4.html.twig', [
'panel_steps' => self::PANEL_STEPS,
'panel_current_step' => 4,
'passwd_form' => $passwdForm
]);
}
/**
* Crea un formulario de validación de usuario.
*/
private function createUserForm(string $name = 'userForm', string $formid = 'step1') {
//return $this->get('form.factory')->createNamedBuilder($name, FormType::class, [ 'formid' => $formid ])
return $this->createFormBuilder()->getFormFactory()->createNamedBuilder($name, FormType::class, [ 'formid' => $formid ])
->add('username', EmailType::class, [
'constraints' => [
new NotBlank(),
new Email(),
],
'label' => 'Correo electrónico',
'attr' => [ 'autofocus' => 'autofocus' ],
'row_attr' => [ 'class' => 'form-floating'],
])->add('next', SubmitType::class, [
'label' => 'Siguiente',
])->getForm();
}
/**
* Crea un formulario de validación de teléfono.
* Los campos username, uid y phonehint son ocultos y se deben proporcionar.
*/
private function createPhoneValidationForm(string $name = 'phoneForm', string $formid = 'step2') {
//return $this->get('form.factory')->createNamedBuilder($name, FormType::class, [ 'formid' => $formid ])
return $this->createFormBuilder()->getFormFactory()->createNamedBuilder($name, FormType::class, [ 'formid' => $formid ])
->add('method', HiddenType::class, [
'constraints' => [
new NotBlank(),
],
'attr' => [
'readonly' => true,
],
])->add('username', HiddenType::class, [
'constraints' => [
new NotBlank(),
//new Email(), Va encriptado
],
'attr' => [
'readonly' => true,
],
])->add('hint', HiddenType::class, [
'constraints' => [
new NotBlank(),
],
'attr' => [
'readonly' => true,
],
])->add('country_code', TelType::class, [
'constraints' => [
new Type('numeric'),
new Length([ 'max' => 4]),
],
'label' => false,
'required' => false,
'attr' => [
'placeholder' => 34,
'maxlength' => 4,
'autocomplete' => 'off',
'title' => 'Código de país',
'class' => 'input-numeric-code',
],
])->add('contact', TelType::class, [
'constraints' => [
new NotBlank(),
new Type('numeric'),
new Length([ 'min' => self::PHONE_MIN_LEN, 'max' => self::PHONE_MAX_LEN]),
],
'label' => false,
'attr' => [
'autofocus' => 'autofocus',
'minlength' => self::PHONE_MIN_LEN,
'maxlength' => self::PHONE_MAX_LEN,
'autocomplete' => 'off',
'title' => 'Teléfono',
'class' => 'input-numeric-code',
'aria-describedby' => 'tab_recovery_method_phone_desc',
],
])->add('next', SubmitType::class, [
'label' => 'Siguiente',
])->getForm();
}
/**
* Crea un formulario de validación de teléfono.
* Los campos username, uid y phoneint son ocultos y se deben proporcionar.
*/
private function createEmailValidationForm(string $name = 'mailForm', string $formid = 'step2') {
//return $this->get('form.factory')->createNamedBuilder($name, FormType::class, [ 'formid' => $formid ])
return $this->createFormBuilder()->getFormFactory()->createNamedBuilder($name, FormType::class, [ 'formid' => $formid ])
->add('method', HiddenType::class, [
'constraints' => [
new NotBlank(),
],
'attr' => [
'readonly' => true,
],
])->add('username', HiddenType::class, [
'constraints' => [
new NotBlank(),
//new Email(), Va encriptado
],
'attr' => [
'readonly' => true,
],
])->add('hint', HiddenType::class, [
'constraints' => [
new NotBlank(),
],
'attr' => [
'readonly' => true,
],
])->add('contact', EmailType::class, [
'constraints' => [
new NotBlank(),
new Email(),
new Length([ 'min' => self::MAIL_MIN_LEN, 'max' => self::MAIL_MAX_LEN]),
],
'label' => false,
'attr' => [
'autofocus' => 'autofocus',
'minlength' => self::MAIL_MIN_LEN,
'maxlength' => self::MAIL_MAX_LEN,
'autocomplete' => 'off',
'title' => 'Correo electrónico',
'aria-describedby' => 'tab_recovery_method_email_desc',
],
])->add('next', SubmitType::class, [
'label' => 'Siguiente',
])->getForm();
}
/**
* Crea un formulario de restauración de clave.
*/
private function createPasswdForm(ReCaptchaValidatorListener $recaptchaValidator, string $name = 'passwdForm', string $formid = 'step4') {
//return $this->get('form.factory')->createNamedBuilder($name, FormType::class, [ 'formid' => $formid ])
return $this->createFormBuilder()->getFormFactory()->createNamedBuilder($name, FormType::class, [ 'formid' => $formid ])
->add('token', HiddenType::class, [
'constraints' => [
new NotBlank(),
new Type('numeric'),
],
])->add('password', RepeatedType::class, [
'type' => PasswordType::class,
'constraints' => [
new NotBlank(),
new Length([ 'min' => self::PASSWORD_MIN_LEN ]),
],
'options' => [
'attr' => [ 'minglength' => 12, 'autofocus' => 'autofocus' ],
'row_attr' => [ 'class' => 'form-floating form-caps-lock'],
],
'required' => true,
'first_options' => [ 'label' => 'Nueva clave' ],
'second_options' => [ 'label' => 'Repita la clave' ],
'invalid_message' => 'Las claves deben coincidir.',
])->add('next', SubmitType::class, [
'label' => 'Restaurar clave',
])->addEventSubscriber($recaptchaValidator)
->getForm();
}
}
?>