Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

2fa with TOTP (Time-Based One Time Password)

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

It may not feel like it yet, but the bundle is now set up... except for one big missing piece: how do we want our users to get the temporary token they'll enter into the form?

In the docs, there are 3 choices... well kind of only 2. These first two are where you use an authenticator app - like Google authenticator or Authy. The other option is to send the code via email.

We're going to use this "totp" authentication, which is basically the same as Google authenticator and stands for "time-based one-time password".

The logic for this actually lives in a separate library. Copy the Composer require line, find your terminal, and paste:

composer require "scheb/2fa-totp:^5.13"

This time there's no recipe or anything fancy: it just installs the library. Next, if you head back to the documentation, we need to enable this as an authentication method inside the config file. That's back in config/packages/scheb_2fa.yaml. Paste that at the bottom:

# See the configuration reference at https://github.com/scheb/2fa/blob/master/doc/configuration.md
scheb_two_factor:
... lines 3 - 8
totp:
enabled: true

Implementing TwoFactorInterface

The last step, if you look over at the docs, is to make our User implements a TwoFactorInterface. Open up our user class: src/Entity/User.php, add TwoFactorInterface:

... lines 1 - 9
use Scheb\TwoFactorBundle\Model\Totp\TwoFactorInterface;
... lines 11 - 19
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
... lines 22 - 253
}

Then head down to the bottom. Now go to the "Code"->"Generate" menu - or Command+N on a Mac - and choose implement methods to generate the 3 we need:

... lines 1 - 8
use Scheb\TwoFactorBundle\Model\Totp\TotpConfigurationInterface;
... lines 10 - 19
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
... lines 22 - 239
public function isTotpAuthenticationEnabled(): bool
{
// TODO: Implement isTotpAuthenticationEnabled() method.
}
public function getTotpAuthenticationUsername(): string
{
// TODO: Implement getTotpAuthenticationUsername() method.
}
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
{
// TODO: Implement getTotpAuthenticationConfiguration() method.
}
}

Beautiful. Here's how TOTP authentication works. Each user that decides to activate two-factor authentication for their account will have a TOTP secret - a random string - stored on a property. This will be used to validate the code and will be used to help the user set up their authenticator app when they first activate two-factor authentication.

The methods from the interface are fairly straightforward. isTotpAuthenticationEnabled() returns whether or not the user has activated two-factor auth... and we can just check to see if the property is set. The getTotpAuthenticationUsername() method is used to help generate some info on the QR code. The last method - getTotpAuthenticationConfiguration() - is the most interesting: it determines how the codes are generated, including the number of digits and how long each will last. Usually, authenticator apps generate a new code every 30 seconds.

Copy the $totpSecret property, scroll up to the properties in our class and paste:

... lines 1 - 19
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
... lines 22 - 63
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
private $totpSecret;
... lines 68 - 270
}

Then head back to the bottom and use the "Code"->"Generate" menu to generate a getter and setter for this. But we can make this nicer: give the argument a nullable string type, a self return type, and return $this... because the rest of our setters are "fluent" like this:

... lines 1 - 19
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
... lines 22 - 259
public function getTotpSecret(): ?string
{
return $this->totpSecret;
}
public function setTotpSecret(?string $totpSecret): self
{
$this->totpSecret = $totpSecret;
return $this;
}
}

For the getter... let's delete this entirely. We just won't need it... and it's kind of a sensitive value.

Let's fill in the three methods. I'll steal the code for the first... and paste:

... lines 1 - 20
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
... lines 23 - 245
public function isTotpAuthenticationEnabled(): bool
{
return $this->totpSecret ? true : false;
}
... lines 250 - 266
}

For the username, in our case, return $this->getUserIdentifier(), which is really just our email:

... lines 1 - 20
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
... lines 23 - 250
public function getTotpAuthenticationUsername(): string
{
return $this->getUserIdentifier();
}
... lines 255 - 266
}

For the last method, copy the config from the docs... and paste:

... lines 1 - 20
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
... lines 23 - 255
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
{
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
}
... lines 260 - 266
}

I'll re-type the end of TotpConfiguration and hit tab so that PhpStorm adds the use statement on top:

... lines 1 - 8
use Scheb\TwoFactorBundle\Model\Totp\TotpConfiguration;
... lines 10 - 20
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
... lines 23 - 255
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
{
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
}
... lines 260 - 266
}

But, be careful. Change the 20 to 30, and the 8 to 6:

... lines 1 - 20
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
... lines 23 - 255
public function getTotpAuthenticationConfiguration(): ?TotpConfigurationInterface
{
return new TotpConfiguration($this->totpSecret, TotpConfiguration::ALGORITHM_SHA1, 30, 6);
}
... lines 260 - 266
}

This says that each code should last for 30 seconds and contain 6 digits. The reason I'm using these exact values - including the algorithm - is to support the Google Authenticator app. Other apps, apparently, allow you to tweak these, but Google Authenticator doesn’t. So if you want to support Google Authenticator, stick with this config.

Okay, our user system is ready! In theory, if we set a totpSecret value for one of our users in the database, and then tried to log in as that user, we would be redirected to the "enter your code" form. But, we're missing a step.

Next: let's add a way for a user to activate two-factor authentication on their account. When they do that, we'll generate a totpSecret and - most importantly - use it to show a QR code the user can scan to set up their authenticator app.

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