Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Activating 2FA

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

Ok: here's the flow. When we submit a valid email and password, the two-factor bundle will intercept that and redirect us to an "enter the code" form. To validate the code, it will read the totpSecret that's stored for that User:

... lines 1 - 20
class User implements UserInterface, PasswordAuthenticatedUserInterface, TwoFactorInterface
{
... lines 23 - 64
/**
* @ORM\Column(type="string", length=255, nullable=true)
*/
private $totpSecret;
... lines 69 - 266
}

But in order to know what code to type, the user first needs to activate two-factor authentication on their account and scan a QR code we provide with their authenticator app.

Let's build that side of things now: the activation and QR code.

Oh, but before I forget again, we added a new property to our User in the last chapter... and I forgot to make a migration for it. At your terminal, run:

symfony console make:migration

Let's go check out that file:

... lines 1 - 4
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20211012201423 extends AbstractMigration
{
public function getDescription(): string
{
return '';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user ADD totp_secret VARCHAR(255) DEFAULT NULL');
}
public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE user DROP totp_secret');
}
}

And... good. No surprises, it adds one column to our table. Run that:

symfony console doctrine:migrations:migrate

Adding a way to Activate 2fa

Here's the plan. A user will not have two-factor authentication enabled by default. Instead, they'll activate it by clicking a link. When they do that, we'll generate a totpSecret, set it on the user, save it to the database and show the user a QR code to scan.

Head over to src/Controller/SecurityController.php. Let's create the endpoint that activates two-factor authentication: public function enable2fa(). Give this a route: how about /authenticate/2fa/enable - and name="app_2fa_enable":

... lines 1 - 12
class SecurityController extends BaseController
{
... lines 15 - 33
/**
* @Route("/authentication/2fa/enable", name="app_2fa_enable")
... line 36
*/
public function enable2fa(TotpAuthenticatorInterface $totpAuthenticator, EntityManagerInterface $entityManager)
{
... lines 40 - 47
}
}

Just be careful not to start the URL with /2fa... that's kind of reserved for the two-factor authentication process:

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

Inside of the method, we need two services. The first is an autowireable service from the bundle - TotpAuthenticatorInterface $totpAuthenticator. That will help us generate the secret. The second is EntityManagerInterface $entityManager:

... lines 1 - 4
use Doctrine\ORM\EntityManagerInterface;
use Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
... lines 7 - 12
class SecurityController extends BaseController
{
... lines 15 - 37
public function enable2fa(TotpAuthenticatorInterface $totpAuthenticator, EntityManagerInterface $entityManager)
{
... lines 40 - 47
}
}

Oh, and, of course, you can only use this route if you're authenticated. Add @IsGranted("ROLE_USER"). Let me re-type that and hit tab to get the use statement on top:

... lines 1 - 6
use Sensio\Bundle\FrameworkExtraBundle\Configuration\IsGranted;
... lines 8 - 12
class SecurityController extends BaseController
{
... lines 15 - 33
/**
... line 35
* @IsGranted("ROLE_USER")
*/
public function enable2fa(TotpAuthenticatorInterface $totpAuthenticator, EntityManagerInterface $entityManager)
{
... lines 40 - 47
}
}

Tip

This next paragraph is... wrong! Using ROLE_USER will not force a user to re-enter their password if they're only authenticated via a "remember me" cookie. To do that, you should use IS_AUTHENTICATED_FULLY. And that's what I should have used here.

For the most part, I've been using IS_AUTHENTICATED_REMEMBERED for security... so that you just need to be logged in... even if it’s via a "remember me" cookie. But I'm using ROLE_USER here, which is effectively identical to IS_AUTHENTICATED_FULLY. That’s on purpose. The result is that if the user were authenticated... but only thanks to a "remember me" cookie, Symfony will force them to re-type their password before getting here. A little extra security before we enable two-factor authentication.

Anyways, say $user = this->getUser()... and then if not $user->isTotpAuthenticationEnabled():

... lines 1 - 12
class SecurityController extends BaseController
{
... lines 15 - 37
public function enable2fa(TotpAuthenticatorInterface $totpAuthenticator, EntityManagerInterface $entityManager)
{
$user = $this->getUser();
if (!$user->isTotpAuthenticationEnabled()) {
... lines 42 - 44
}
... lines 46 - 47
}
}

Hmm, I want to see if totp authentication is not already enabled... but I'm not getting auto-completion for this.

We know why: the getUser() method only knows that it returns a UserInterface. We fixed this earlier by making our own base controller. Let's extend that:

... lines 1 - 12
class SecurityController extends BaseController
{
... lines 15 - 48
}

Back down here, if not $user->isTotpAuthenticationEnabled() - so if the user does not already have a totpSecret - let's set one: $user->setTotpSecret() passing $totpAuthentiator->generateSecret(). Then, save with $entityManager->flush().

At the bottom, for now, just dd($user) so we can make sure this is working:

... lines 1 - 12
class SecurityController extends BaseController
{
... lines 15 - 37
public function enable2fa(TotpAuthenticatorInterface $totpAuthenticator, EntityManagerInterface $entityManager)
{
$user = $this->getUser();
if (!$user->isTotpAuthenticationEnabled()) {
$user->setTotpSecret($totpAuthenticator->generateSecret());
$entityManager->flush();
}
dd($user);
}
}

Linking to the Route

Cool! Let's link to this! Copy the route name... then open templates/base.html.twig. Search for "Log Out". There we go. I'll paste that route name, duplicate the entire li, clean things up, paste the new route name, remove my temporary code and say "Enable 2FA":

... line 1
<html>
... lines 3 - 14
<body
... lines 16 - 21
<nav
class="navbar navbar-expand-lg navbar-light bg-light px-1"
{{ is_granted('ROLE_PREVIOUS_ADMIN') ? 'style="background-color: red !important"' }}
>
<div class="container-fluid">
... lines 27 - 35
<div class="collapse navbar-collapse" id="navbar-collapsable">
... lines 37 - 47
{% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}
<div class="dropdown">
... lines 50 - 60
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="user-dropdown">
... lines 62 - 68
<li>
<a class="dropdown-item" href="{{ path('app_2fa_enable') }}">Enble 2fa</a>
</li>
... lines 72 - 74
</ul>
</div>
{% else %}
... lines 78 - 79
{% endif %}
</div>
</div>
</nav>
... lines 84 - 95
</body>
</html>

Testing time! Oh, but first, at your terminal, reload your fixtures:

symfony console doctrine:fixtures:load

That will make sure all of the users have verified emails so that we can actually log in. When that finishes, log in with abraca_admin@example.com, password tada. Beautiful. Then hit "Enable 2FA" and... got it! It hits our user dump! And most importantly, we have a totpSecret set!

That's great! But the final step is to show the user a QR code that they can scan to get their authenticator app set up. Let's do that next.

Leave a comment!

8
Login or Register to join the conversation
Tomasz-I Avatar
Tomasz-I Avatar Tomasz-I | posted 9 months ago

Hello, any idea on how to generate secret but in a different situation? Imagine there is a system where SUPER ADMINS have to use 2fa. So it is not a matter of enabling it for someone after he logged in, but it is required. So I should somehow hook in so that when user is authenticated with username and password, generate Secret for him, store in in User entity, so that when scheb/2fa takes its hands on the user it redirects him to show the QR Code. If I do not do this, I will get TwoFactorProviderLogicException that the secret is missing.

1 Reply

Hey Tomasz-I!

Hmm. Here's how I would implement that:

A) Create a listener on LoginSuccessEvent.
B) If the user MUST have 2fa enabled and they do NOT, then generate the totp secret on the User object and save it. THEN, redirect to a "show QR code" screen where you show the totp. (Make it very clear to the user that they MUST set up their authenticator app or else they will not be able to log in).

And... that's it. The big difference, I think, is that I'm using aa LoginSuccessEvent listener to generate the top secret instead of trying to hook into scheb/2fa and making *it* redirect to the "show QR code" page.

Let me know if that makes sense, or if I'm misunderstood the situation :).

Cheers!

2 Reply
Tomasz-I Avatar
Tomasz-I Avatar Tomasz-I | weaverryan | posted 9 months ago | edited

weaverryan

Thank you. I already tried it but the problem is that scheb 2fa (ver 6) is faster than that and throws

User has to provide a secret code for Google Authenticator authentication.

That is why I asked here cause I do not have any other options. It is weird because LoginSuccessEvent should be first. See https://ibb.co/dJX2pfH

1 Reply

Hmm. In that case, until the secret is set, can you return false from User.isTotpAuthenticationEnabled()? I believe that if this is false (and it would be false JUST the first time that they log in), then 2fa should not initiate the process and then your redirect will take over.

Sorry for the slow reply, btw - it's been a crazy week :).

Cheers!

Reply
Tomasz-I Avatar

Oh yes, that did the job, genius. Thank you very much!!!!!

"slow reply" - no worries :) Been swamped too, helping Ukrainian refugees

Reply

❤️❤️❤️

Reply
Oliver W. Avatar
Oliver W. Avatar Oliver W. | posted 4 months ago

Hi Ryan,
what the hec is that:
Symfony\Component\HttpKernel\Exception\
ControllerDoesNotReturnResponseException
in C:\xampp\htdocs\lernen5sec\src\Controller\SecurityController.php (line 49)

$entityManager->flush();
dd($user);
}
}
}

5 minutes ago this one worked. Then I changed to dd($totpAuthenticator->getQRContent($user)); and got this error for the first time. Now switching back to dd($user) does not work either!?

What went wrong?

Thx
Oliver

Reply
Oliver W. Avatar

aaaaarg, the } has to be in front of dd($user). Sorry for bothering you ;-)

Apparently I accidently pressed Shift+Caps+Up.

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