Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Customize The 2-Factor Auth Form

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

We just successfully logged in using two-factor authentication. Woo! But, the form where we entered the code was ugly. Time to fix that! Log out... then log back in… with our usual email... and password tada. Here's our ugly form.

How can we customize this? Well, the wonderful documentation, of course, could tell us. But let's be tricky and see if we can figure it out for ourselves. Find your terminal and load the current configuration for this bundle: symfony console debug:config... and then, find the config file, copy the root key - scheb_two_factor - and paste.

symfony console debug:config scheb_two_factor

Awesome! We see security_tokens with UsernamePasswordToken... that's no surprise because that's what we have here. But this also shows us some default values that we have not specifically configured. The one that's interesting to us is template. This is the template that's currently rendered to show the two-factor "enter the code" page.

Overriding the Template

Let's go check it out. Copy most of the file name, hit Shift+Shift, paste and... here it is! It's not too complex: we have an authenticationError variable that renders a message if we type an invalid code.

Then… we basically have a form with an action set to the correct submit path, an input and a button.

To customize this, go down into the templates/security/ directory and create a new file called, how about, 2fa_form.html.twig. I'll paste in a structure to get us started:

{% 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 %}

This extends base.html.twig... but there's nothing dynamic yet: the form is a big TODO.

So obviously, this isn't done... but, let's try to use it anyways! Back in config/packages/scheb_2fa.yaml, under totp, add template set to security/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

Back at the browser, refresh and... yes! That's our template!

Oh, and now that this renders a full HTML page, we have our web debug toolbar again. Hover over the security icon to see one interesting thing. We're, sort of, authenticated, but with this special TwoFactorToken. And if you look closer, we don't have any roles. So, we are kind of logged in, but without any roles.

And also, the two-factor bundle executes a listener at the start of each request that guarantees the user can't try to navigate the site in this half-logged-in state: it stops all requests and redirects them back to this URL. And if you scroll down, even on this page, all security checks return ACCESS DENIED. The two-factor bundle hooks into the security system to cause this.

Anyways, let's fill in the form TODO part. For this, copy all of the core template, and paste it over our 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 %}

Now... it's just a matter of customizing this. Change the error p to a div with class="alert alert-error". That should be alert-danger... I'll fix it in a minute. Below, I'm going to remove the links to authenticate in a different way because we're only supporting totp. For the input we need class="form-control". Then all the way down here, I'll leave these displayTrusted and isCsrfProtectionEnabled sections... though I'm not using them. You can activate these in the config. Finally, remove the p around the button, change it to a button - I just like those better - put the text inside the tag... then add a few classes to it.

Oh, and I'm also going to move the "Log Out" link up a bit... clean it up a little... and add some extra classes:

... 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 %}

Phew! With any luck, that should make it look fairly good. Refresh and... sweet! Bah, except for a little extra quotation on my "Login". I always do that. There we go, that looks better:

... 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 %}

If we type an invalid code... error! Oh, but it's not red... the class should be alert-danger. That's why we test things! And now... that's better:

... 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 %}

If we type a valid code from my Authy app, we've got it! Mission accomplished!

Also, even though we won't talk about them, the two-factor bundle also supports "backup codes" and "trusted devices" where a user can choose to skip future two-factor authentication on a specific device. Check out their docs for the details.

And... we made it! Congrats on your incredibly hard work! Security is supposed to be a dry, boring topic, but I absolutely love this stuff. I hope you enjoyed the journey as much as I did. If there's something we didn't cover or you still have some questions, we're here for you down in the comments section.

All right friends, see ya next time!

Leave a comment!

16
Login or Register to join the conversation
Fernando A. Avatar
Fernando A. Avatar Fernando A. | posted 1 month ago

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 Andrade

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 8 months ago

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 8 months ago

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!

This tutorial also works great for 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
    }
}