Buy Access to Course
44.

Activating 2FA

|

Share this awesome video!

|

Keep on Learning!

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:

268 lines | src/Entity/User.php
// ... 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:

32 lines | migrations/Version20211012201423.php
// ... 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":

50 lines | src/Controller/SecurityController.php
// ... 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:

71 lines | config/packages/security.yaml
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:

50 lines | src/Controller/SecurityController.php
// ... 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:

50 lines | src/Controller/SecurityController.php
// ... 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():

50 lines | src/Controller/SecurityController.php
// ... 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:

50 lines | src/Controller/SecurityController.php
// ... 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:

50 lines | src/Controller/SecurityController.php
// ... 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":

98 lines | templates/base.html.twig
// ... 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.