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
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); | |
} | |
} |
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.
// 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
}
}