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.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeOk: 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.
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.