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!

16
Login or Register to join the conversation
Fernando A. Avatar
Fernando A. Avatar Fernando A. | posted hace 3 meses

Not related tho this video, but related to 2FA,

How can I test this? I have this set up in an API and we are supposed to make 2FA mandatory, but I cannot really enforce that on the backend because of the tests... if I do it then I cannot login because I have no clue how to generate the code from the known seed so that I could cover the entire Auth flow on the tests...

Thanks

Reply
Fernando A. Avatar

Never mind I found what I needed...

if someone else is in need of generating TOTP codes on their tests, do it like this:

```
use OTPHP\TOTP;

....

$otp = TOTP::create($totpSecret, 30, 'sha1', 6);
$key = $otp->at(time());

```

$key will be your 6 digit code...
play around have fun

Thanks for listening to my ted talk

Reply

Best TED talk ever, short and right to the point. Thanks for sharing your solution with others Fernando A.

Reply
Juan B. Avatar

Hello,

Thank you for another excellent tutorial! One thing that I was looking for in a security tutorial that was not covered was how to use LDAP/Active Directory for authentication. I have read through the documentation, but as a new Symfony user, it seems a bit fuzzy how to enable this along with a User entity. Perhaps this topic can be added in a future tutorial. It would be a huge help.

Cheers!

Nitrox

Reply

Hey Nitrox,

Yeah, this tutorial is missing that LDAP, but we already complicated it a lot with 2FA authentication, so no LDAP this time as well, sorry! I'll add this topic to our ideas pool, but fairly speaking I'm not sure we will cover this topic in the nearest future unfortunately - a lot of other good stuff is coming though.

Meanwhile, you can take a look at this Disqus thread: https://symfonycasts.com/sc... that I hope might help you at least partially - that LDAP topic is kinda popular and we get more and more requests about it. Maybe in the future we will cover it. Also, feel free to search for LDAP in comments on SymfonyCasts - you may find more related information about it: https://symfonycasts.com/se...

I hope this helps!

Cheers!

1 Reply
Juan B. Avatar

Thank you for your response. It was definitely helpful to see the approach to configure and debug LDAP. Keep up the amazing work you're doing and I look forward to the next tutorial.

2 Reply

Hey Nitrox,

You're welcome! I really happy to hear it was useful for you :)

Cheers!

Reply

Hi there!

Which next tutorial? And when it will happen?
Thanks for you job :)

Reply

Hey dzianisr our next course will be EasyAdminBundle and it will start releasing this week! :)

Reply
Trafficmanagertech Avatar
Trafficmanagertech Avatar Trafficmanagertech | posted hace 10 meses

Any tip on how to set the user language in the /2fa page?
I see two possible ways, but can't find how to implement any of them :(

1. Instead of redirecting to /2fa, redirect to /{_locale}/2fa where {_locale} is taken from $user->getLang()
2. Keep the "/2fa" route, but show the localized strings in the form, and apply the correct language to the redirect after the successfull 2fa verification

I tried with
`$this->session->set('_locale', $user->getLang());`
in the LoginFormAuthenticator::onAuthenticationSuccess(), but it's not working

To localize the twig template I can read app.user.lang and manually pass that to the trans filter, but the issue is that after the 2fa submission, they are not redirected to the homepage with their language...

Reply

Hey The_nuts,

Tricky question. Ideally, go with the 1st option you mentioned. If you could localize that route, i.e. add {_locale} there - then Symfony will do the rest itself I think. If your website localized - then all routes that are generating in your system would be automatically generated in the current user locale thanks to the _locale in your routes. But for this, you need to localize all the routes in the system, including /2fa one and the other routes where the form is submitted on the /2fa page (I don't remember if it's a different route or the same /2fa).

With the 2nd option you will just have more problems IMO. First of all, search engine robots may index your website in one locale only which is not great for SEO and you will have problems generating links as you will need to pass the locale explicitly to all the links that are generated on non-localized page. Or yes, you can probably do it with listeners, but you need to choose the correct event. I suppose you need to inject the proper locale *before* the Symfony core event, otherwise if you set it later in the code - it may not have any effect.

So, I'd suggest you to go with the first option as I think it should be the best, probably you can easily achieve this for all 2fa bundle routes with route prefix, see https://symfony.com/doc/cur...

I hope this helps!

Cheers!

1 Reply
Trafficmanagertech Avatar
Trafficmanagertech Avatar Trafficmanagertech | victor | posted hace 10 meses

Thank you very much, yes all my routes are already localized, it was quite simple!

Reply

Hey The_nuts,

Awesome! Thanks for confirming it worked for you, happy to hear it was achieved that easy :)

Cheers!

Reply
Trafficmanagertech Avatar

I found this event (subscribed to TwoFactorAuthenticationEvents::FORM),
I can read the user lang and set it to the request, but it doesn't work :(


public function onTwoFactorForm(TwoFactorAuthenticationEvent $event)
{
$event->getRequest()->setLocale($event->getToken()->getUser()->getLang());
}
Reply
Fabrice Avatar

Hello, for the activation of 2fa with the qr-code, I set up a form for the user to scan the qr-code then validate immediately afterwards with a code, in order to formalize the fact that it will use 2fa.

When he submits the verification code after scanning the QR-code, if he is wrong, then the page is reloaded and the error that the code is incorrect is displayed, but the qr-code is also changed.

So he will have to delete the previously created account and scan a qr-code again.

In the following method:


#[Route(path: '/authentification/2fa/qr-code', name: 'app_qr_code')]
public function displayGoogleAuthenticatorQrCode(): Response
{
$qrCodeContent = $this->totpAuthenticator->getQRContent($this->getUser());
$result = Builder::create()
->data($qrCodeContent)
->build();

return new Response($result->getString(), 200, ['Content-Type' => 'image/png']);
}

Can I set up a user-specific cache so that the qr-code doesn't change if they make a mistake confirming the code?

A cache of 15 min for example.
And I also cache 15 minutes when generating a totpCode that I assign to the user.

Does this pose security issues? Will the QR-code still be valid?

Thanks !

Reply

Hey Kiuega,

You probably should not render that QR code on the same page where you prompt user for input the code. Take a look at other websites that have similar behaviour, the easiest would be to render the QR on one page, and then ask user to go to the next page where they will be able to validate it, i.e. those should be different pages.

But in short, you just should not re-render the QR code. You can achieve this in a different way: use different pages, or use a boolean flag on User field that will help you to decide if you should render the code or no, etc.

I hope this helps!

Cheers!

1 Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

¡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
    }
}