Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Personalizar el formulario de autenticación de dos factores

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

Acabamos de iniciar la sesión con éxito utilizando la autenticación de dos factores. ¡Guau! Pero el formulario en el que hemos introducido el código era feo. ¡Es hora de arreglarlo! Cierra la sesión... y vuelve a entrar... con nuestro correo electrónico habitual... y la contraseña tada. Este es nuestro feo formulario.

¿Cómo podemos personalizarlo? Bueno, la maravillosa documentación, por supuesto, podría decírnoslo. Pero vamos a ser intrigantes y ver si podemos descubrirlo por nosotros mismos. Busca tu terminal y carga la configuración actual de este paquete:symfony console debug:config... y luego, busca el archivo de configuración, copia la clave raíz - scheb_two_factor - y pégala.

symfony console debug:config scheb_two_factor

¡Genial! Vemos security_tokens con UsernamePasswordToken... eso no es ninguna sorpresa porque es lo que tenemos aquí. Pero esto también nos muestra algunos valores por defecto que no hemos configurado específicamente. El que nos interesa es template. Esta es la plantilla que se renderiza actualmente para mostrar la página de dos factores "introduce el código".

Anulando la plantilla

Vamos a comprobarlo. Copia la mayor parte del nombre del archivo, pulsa Shift+Shift, pega y... ¡aquí está! No es demasiado complejo: tenemos una variable authenticationError que muestra un mensaje si escribimos un código no válido.

Entonces... básicamente tenemos un formulario con una acción establecida en la ruta de envío correcta, una entrada y un botón.

Para personalizar esto, baja al directorio templates/security/ y crea un nuevo archivo llamado, qué tal, 2fa_form.html.twig. Pondré una estructura para empezar:

{% extends 'base.html.twig' %}
{% block title %}Two Factor Auth{% endblock %}
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
<h1 class="h3 mb-3 font-weight-normal">Two Factor Authentication</h1>
<p>
Open your Authenticator app and type in the number.
</p>
FORM TODO
</div>
</div>
</div>
{% endblock %}

Esto extiende base.html.twig... pero todavía no hay nada dinámico: el formulario es un gran TODO.

Así que, obviamente, esto no está hecho... pero, ¡intentemos usarlo de todos modos! De nuevo enconfig/packages/scheb_2fa.yaml, bajo totp, añade template ajustado asecurity/2fa_form.html.twig:

# See the configuration reference at https://github.com/scheb/2fa/blob/master/doc/configuration.md
scheb_two_factor:
... lines 3 - 8
totp:
... lines 10 - 11
template: security/2fa_form.html.twig

De vuelta al navegador, actualiza y... ¡sí! ¡Esa es nuestra plantilla!

Ah, y ahora que esto renderiza una página HTML completa, tenemos de nuevo nuestra barra de herramientas de depuración web. Pasa el ratón por encima del icono de seguridad para ver una cosa interesante. Estamos, más o menos, autentificados, pero con este TwoFactorToken especial. Y si te fijas, no tenemos ningún rol. Por lo tanto, estamos como conectados, pero sin ningún rol.

Y además, el paquete de dos factores ejecuta un escuchador al inicio de cada petición que garantiza que el usuario no puede intentar navegar por el sitio en este estado de media sesión: detiene todas las peticiones y las redirige a esta URL. Y si se desplaza hacia abajo, incluso en esta página, todas las comprobaciones de seguridad devuelven el ACCESO DENEGADO. El paquete de dos factores se engancha al sistema de seguridad para provocar esto.

De todos modos, vamos a rellenar la parte del formulario TODO. Para ello, copia toda la plantilla del núcleo, y pégala sobre nuestro TODO:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
<h1 class="h3 mb-3 font-weight-normal">Two Factor Authentication</h1>
<p>
Open your Authenticator app and type in the number.
</p>
{% if authenticationError %}
<p>{{ authenticationError|trans(authenticationErrorData, 'SchebTwoFactorBundle') }}</p>
{% endif %}
{# Let the user select the authentication method #}
<p>{{ "choose_provider"|trans({}, 'SchebTwoFactorBundle') }}:
{% for provider in availableTwoFactorProviders %}
<a href="{{ path("2fa_login", {"preferProvider": provider}) }}">{{ provider }}</a>
{% endfor %}
</p>
{# Display current two-factor provider #}
<p class="label"><label for="_auth_code">{{ "auth_code"|trans({}, 'SchebTwoFactorBundle') }} {{ twoFactorProvider }}:</label></p>
<form class="form" action="{{ checkPathUrl ? checkPathUrl: path(checkPathRoute) }}" method="post">
<p class="widget">
<input
id="_auth_code"
type="text"
name="{{ authCodeParameterName }}"
autocomplete="one-time-code"
autofocus
{#
https://www.twilio.com/blog/html-attributes-two-factor-authentication-autocomplete
If your 2fa methods are using numeric codes only, add these attributes for better user experience:
inputmode="numeric"
pattern="[0-9]*"
#}
/>
</p>
{% if displayTrustedOption %}
<p class="widget"><label for="_trusted"><input id="_trusted" type="checkbox" name="{{ trustedParameterName }}" /> {{ "trusted"|trans({}, 'SchebTwoFactorBundle') }}</label></p>
{% endif %}
{% if isCsrfProtectionEnabled %}
<input type="hidden" name="{{ csrfParameterName }}" value="{{ csrf_token(csrfTokenId) }}">
{% endif %}
<p class="submit"><input type="submit" value="{{ "login"|trans({}, 'SchebTwoFactorBundle') }}" /></p>
</form>
{# The logout link gives the user a way out if they can't complete two-factor authentication #}
<p class="cancel"><a href="{{ logoutPath }}">{{ "cancel"|trans({}, 'SchebTwoFactorBundle') }}</a></p>
</div>
</div>
</div>
{% endblock %}

Ahora... es cuestión de personalizar esto. Cambia el error p por un divcon class="alert alert-error". Eso debería ser alert-danger... Lo arreglaré en un momento. A continuación, voy a eliminar los enlaces para autenticar de forma diferente porque sólo soportamos totp. Para el input necesitamosclass="form-control". Luego, aquí abajo, dejaré estas secciones displayTrustedy isCsrfProtectionEnabled... aunque no las estoy usando. Puedes activarlas en la configuración. Por último, quita el p alrededor del botón, cámbialo por unbutton -me gustan más-, pon el texto dentro de la etiqueta... y luego añádele unas cuantas clases.

Ah, y también voy a mover el enlace "Cerrar sesión" un poco hacia arriba... limpiarlo un poco... y añadir algunas clases adicionales:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
... lines 9 - 14
{% if authenticationError %}
<div class="alert alert-danger">{{ authenticationError|trans(authenticationErrorData, 'SchebTwoFactorBundle') }}</div>
{% endif %}
<form class="form" action="{{ checkPathUrl ? checkPathUrl: path(checkPathRoute) }}" method="post">
<p class="widget">
<input
... lines 22 - 25
class="form-control"
... lines 27 - 33
/>
</p>
{% if displayTrustedOption %}
<p class="widget"><label for="_trusted"><input id="_trusted" type="checkbox" name="{{ trustedParameterName }}" /> {{ "trusted"|trans({}, 'SchebTwoFactorBundle') }}</label></p>
{% endif %}
{% if isCsrfProtectionEnabled %}
<input type="hidden" name="{{ csrfParameterName }}" value="{{ csrf_token(csrfTokenId) }}">
{% endif %}
<a class="btn btn-link" href="{{ logoutPath }}">{{ "cancel"|trans({}, 'SchebTwoFactorBundle') }}</a>
<button type="submit" class="btn btn-primary">{{ "login"|trans({}, 'SchebTwoFactorBundle') }}</button>
</form>
</div>
</div>
</div>
{% endblock %}

¡Uf! Con un poco de suerte, eso debería hacer que se vea bastante bien. Refresca y... ¡qué bien! Bah, excepto por una pequeña cita extra en mi "Inicio de sesión". Siempre hago eso. Ya está, se ve mejor:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
... lines 9 - 18
<form class="form" action="{{ checkPathUrl ? checkPathUrl: path(checkPathRoute) }}" method="post">
... lines 20 - 43
<button type="submit" class="btn btn-primary">{{ "login"|trans({}, 'SchebTwoFactorBundle') }}</button>
</form>
</div>
</div>
</div>
{% endblock %}

Si escribimos un código no válido... ¡error! Ah, pero no es rojo... la clase debería ser alert-danger. ¡Por eso probamos las cosas! Y ahora... esto es mejor:

... lines 1 - 4
{% block body %}
<div class="container">
<div class="row">
<div class="login-form bg-light mt-4 p-4">
... lines 9 - 14
{% if authenticationError %}
<div class="alert alert-danger">{{ authenticationError|trans(authenticationErrorData, 'SchebTwoFactorBundle') }}</div>
{% endif %}
... lines 18 - 46
</div>
</div>
</div>
{% endblock %}

Si escribimos un código válido desde mi aplicación Authy, ¡lo tenemos! ¡Misión cumplida!

Además, aunque no hablemos de ellos, el paquete de dos factores también admite "códigos de respaldo" y "dispositivos de confianza", en los que un usuario puede elegir omitir la futura autenticación de dos factores en un dispositivo específico. Consulta la documentación para conocer los detalles.

Y... ¡lo hemos conseguido! ¡Enhorabuena por tu increíble trabajo! Se supone que la seguridad es un tema árido y aburrido, pero a mí me encanta este tema. Espero que hayas disfrutado del viaje tanto como yo. Si hay algo que no hayamos cubierto o todavía tienes algunas preguntas, estamos aquí para ti en la sección de comentarios.

Muy bien amigos, ¡hasta la próxima!

Leave a comment!

¡Este tutorial también funciona muy bien para Symfony 6!

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.4.1 || ^8.0.0",
        "ext-ctype": "*",
        "ext-iconv": "*",
        "babdev/pagerfanta-bundle": "^3.3", // v3.3.0
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "doctrine/annotations": "^1.0", // 1.13.2
        "doctrine/doctrine-bundle": "^2.1", // 2.6.3
        "doctrine/doctrine-migrations-bundle": "^3.0", // 3.1.1
        "doctrine/orm": "^2.7", // 2.10.1
        "knplabs/knp-markdown-bundle": "^1.8", // 1.9.0
        "knplabs/knp-time-bundle": "^1.11", // v1.16.1
        "pagerfanta/doctrine-orm-adapter": "^3.3", // v3.3.0
        "pagerfanta/twig": "^3.3", // v3.3.0
        "phpdocumentor/reflection-docblock": "^5.2", // 5.2.2
        "scheb/2fa-bundle": "^5.12", // v5.12.1
        "scheb/2fa-qr-code": "^5.12", // v5.12.1
        "scheb/2fa-totp": "^5.12", // v5.12.1
        "sensio/framework-extra-bundle": "^6.0", // v6.2.0
        "stof/doctrine-extensions-bundle": "^1.4", // v1.6.0
        "symfony/asset": "5.3.*", // v5.3.4
        "symfony/console": "5.3.*", // v5.3.7
        "symfony/dotenv": "5.3.*", // v5.3.8
        "symfony/flex": "^1.3.1", // v1.17.5
        "symfony/form": "5.3.*", // v5.3.8
        "symfony/framework-bundle": "5.3.*", // v5.3.8
        "symfony/monolog-bundle": "^3.0", // v3.7.0
        "symfony/property-access": "5.3.*", // v5.3.8
        "symfony/property-info": "5.3.*", // v5.3.8
        "symfony/rate-limiter": "5.3.*", // v5.3.4
        "symfony/runtime": "5.3.*", // v5.3.4
        "symfony/security-bundle": "5.3.*", // v5.3.8
        "symfony/serializer": "5.3.*", // v5.3.8
        "symfony/stopwatch": "5.3.*", // v5.3.4
        "symfony/twig-bundle": "5.3.*", // v5.3.4
        "symfony/ux-chartjs": "^1.3", // v1.3.0
        "symfony/validator": "5.3.*", // v5.3.8
        "symfony/webpack-encore-bundle": "^1.7", // v1.12.0
        "symfony/yaml": "5.3.*", // v5.3.6
        "symfonycasts/verify-email-bundle": "^1.5", // v1.5.0
        "twig/extra-bundle": "^2.12|^3.0", // v3.3.3
        "twig/string-extra": "^3.3", // v3.3.3
        "twig/twig": "^2.12|^3.0" // v3.3.3
    },
    "require-dev": {
        "doctrine/doctrine-fixtures-bundle": "^3.3", // 3.4.0
        "symfony/debug-bundle": "5.3.*", // v5.3.4
        "symfony/maker-bundle": "^1.15", // v1.34.0
        "symfony/var-dumper": "5.3.*", // v5.3.8
        "symfony/web-profiler-bundle": "5.3.*", // v5.3.8
        "zenstruck/foundry": "^1.1" // v1.13.3
    }
}