Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

2 Factor Authentication & Authentication Tokens

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

For our last trick in this tutorial, we're going to do something fun: add two-factor authentication. This can take a few forms, but the basic flow looks like this, you're probably familiar. First, the user submits a valid email and password to the login form. But then, instead of that logging them in, they're redirected to a form where they need to enter a temporary code.

This code could be something that we email them or text to their phone... or it could be a code from an authenticator app like Google authenticator or Authy. Once the user fills in the code and submits, then they are finally logged in.

Installing The scheb/2fa-bundle

In the Symfony world, we're super lucky to have a fantastic library to help with two-factor auth. Search for Symfony 2fa to find the scheb/2fa library. Scroll down... and click into the documentation, which lives on Symfony.com. Then head to the Installation page.

Cool! Let's get this thing installed! At your terminal, run:

composer require "2fa:^5.13"

Where 2fa is a Flex alias to the actual bundle name.

Once this finishes... I'll run:

git status

to see what the bundle's recipe did. Cool: it added a new configuration file... and also a new routes file.

That routes file, which lives at config/routes/scheb_2fa.yaml, adds two routes to our app:

2fa_login:
path: /2fa
defaults:
_controller: "scheb_two_factor.form_controller:form"
2fa_login_check:
path: /2fa_check

The first will render the "enter the code” form that we see after submitting our email and password. The second route is the URL that this form will submit to.

Bundle Configuration / Setup

Back at the docs, let's walk through this. Step 2 - enable the bundle - was done by Flex automatically… and step 3 - define the routes - was handled thanks to the recipe. Nice!

Step 4 is to configure the firewall. This part we do need to do.

Start by copying the two_factor stuff. Then open up config/packages/security.yaml. This new config can live anywhere under our main firewall. I'll paste it after form_login... and we can remove this comment: it highlighted that 2fa_login should match the route name in our routes file, which it does:

security:
... lines 2 - 20
firewalls:
... lines 22 - 24
main:
... lines 26 - 49
two_factor:
auth_form_path: 2fa_login
check_path: 2fa_login_check
... lines 53 - 71

Oh, and remember how the job of most keys under our firewall is to activate another authenticator? Whelp, the two_factor key is no exception: this activates a new authenticator that handles the "enter your code" form submit that we'll see in a few minutes.

The README also recommends a couple of access controls, which are a good idea. Copy those... and paste them at the top of our access_control:

security:
... lines 2 - 61
access_control:
# This makes the logout route accessible during two-factor authentication. Allows the user to
# cancel two-factor authentication, if they need to.
- { path: ^/logout, role: PUBLIC_ACCESS }
# This ensures that the form can only be accessed when two-factor authentication is in progress.
- { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
- { path: ^/admin/login, roles: PUBLIC_ACCESS }
- { path: ^/admin, roles: ROLE_ADMIN }
# - { path: ^/profile, roles: ROLE_USER }

This second one makes sure that you can't go to /2fa - that's the URL that renders the "enter your code" form - unless you have already submitted your valid email and password. When you're in that, sort of, “in-between-login” state, the 2fa bundle makes sure that you have this IS_AUTHENTICATED_2FA_IN_PROGRESS attribute:

security:
... lines 2 - 61
access_control:
... lines 63 - 65
# This ensures that the form can only be accessed when two-factor authentication is in progress.
- { path: ^/2fa, role: IS_AUTHENTICATED_2FA_IN_PROGRESS }
... lines 68 - 71

The first entry - for /logout - makes sure that if you are in that “in-between” state, you can still cancel the login by going to /logout. Oh, but change this to PUBLIC_ACCESS:

security:
... lines 2 - 61
access_control:
# This makes the logout route accessible during two-factor authentication. Allows the user to
# cancel two-factor authentication, if they need to.
- { path: ^/logout, role: PUBLIC_ACCESS }
... lines 66 - 71

Configuring the security_tokens

The last step in the README is to configure this security_tokens config.

Let me explain. When we submit a valid email and password into the login form, the two-factor authentication system - via a listener - is going to decide whether or not it should interrupt authentication and start the two-factor authentication process... where it redirects the user to the "enter the code" form.

If we think about it, we definitely do want this to happen when a user logs in via the login form. But... we probably wouldn't want this to happen if, for example, a user was authenticating via an API token. The bundle needs a way to figure out whether or not we want 2fa based on how the user just authenticated.

We haven't talked about it much, but whenever you log in, you're authenticated with a certain type of token object. This token object is... sort of a wrapper around the User object... and you almost never care about it.

But, different authentication systems - like form_login or remember_me - use different token classes... which means that you can figure out how the user originally logged in, by looking at the currently-authenticated token.

For example, this top token class is actually the token that you get if you log in via the form_login authenticator. I'll prove it. Hit Shift+Shift and search for FormLoginAuthenticator. Inside... it has a createAuthenticatedToken() method, a method that every authenticator has. It returns a new UsernamePasswordToken.

Here's the point. If we login via this authenticator... and the matching token class is listed under our scheb_two_factor config, the two-factor authentication process will take over and redirect the user to the "enter the code" form.

Let's go see what our file looks like: config/packages/scheb_2fa.yaml:

# See the configuration reference at https://github.com/scheb/2fa/blob/master/doc/configuration.md
scheb_two_factor:
security_tokens:
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
# If you're using guard-based authentication, you have to use this one:
# - Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken
# If you're using authenticator-based security (introduced in Symfony 5.1), you have to use this one:
# - Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken

By default, the only uncommented class is UsernamePasswordToken, which is perfect for us.

But notice the last comment. If you're authenticating via a custom authenticator - like we were doing earlier - then you should use this class.

Let see exactly why that’s the case. Open our custom LoginFormAuthenticator. We're not using this anymore, but pretend we are. This extends AbstractLoginFormAuthenticator:

... lines 1 - 15
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
... lines 17 - 26
class LoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
... lines 29 - 81
}

Hold Cmd or Ctrl to open that... then open its base class AbstractAuthenticator. Scroll down a bit and... hello createAuthenticatedToken()! This returns a new PostAuthenticatedToken. And so, by default, this is the token class you get with a custom authenticator.

These token classes aren't super important... they basically all extend the same AbstractToken... and mostly just help to identify how the user logged in.

By leveraging this knowledge, along with the scheb configuration, you can tell the two-factor bundle which authenticators require two-factor authentication and which don't.

Oh, and if you're using two custom authenticators... and only one of them needs two-factor authentication, you'll need to create a custom token class and override the createAuthenticatedToken() method in your authenticator to return that. Then you can target just the custom class here.

Phew! It may not feel like we've done much yet... other than listen to me talk about tokens... but the bundle is now... basically set up. But next, we need to choose how our users will get the tokens. Will we email them? Or will they use an authenticator app with a QR code? We're going to do the second.

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