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!

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