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 SubscribeBien: este es el flujo. Cuando enviemos un correo electrónico y una contraseña válidos, el paquete de dos factores lo interceptará y nos redirigirá a un formulario de "introducir el código". Para validar el código, leerá el totpSecret
que está almacenado para ese 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 | |
} |
Pero para saber qué código debe escribir, el usuario tiene que activar primero la autenticación de dos factores en su cuenta y escanear un código QR que le proporcionamos con su aplicación de autenticación.
Construyamos ahora ese lado de las cosas: la activación y el código QR.
Ah, pero antes de que se me olvide otra vez, en el último capítulo añadimos una nueva propiedad a nuestro User
... y se me olvidó hacer una migración para ella. En tu terminal, ejecuta:
symfony console make:migration
Vamos a comprobar ese archivo:
... 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'); | |
} | |
} |
Y... bien. Sin sorpresas, añade una columna a nuestra tabla. Ejecuta eso:
symfony console doctrine:migrations:migrate
Este es el plan. Un usuario no tendrá activada la autenticación de dos factores por defecto, sino que la activará haciendo clic en un enlace. Cuando lo hagan, generaremos untotpSecret
, se lo pondremos al usuario, lo guardaremos en la base de datos y le mostraremos un código QR para que lo escanee.
Dirígete a src/Controller/SecurityController.php
. Vamos a crear la ruta que activa la autenticación de dos factores: public function
enable2fa(). Dale una ruta: ¿qué tal /authenticate/2fa/enable
- y 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 | |
} | |
} |
Sólo ten cuidado de no empezar la URL con /2fa
... eso está reservado para el proceso de autenticación de dos factores:
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 |
Dentro del método, necesitamos dos servicios. El primero es un servicio autoconvocable del paquete - TotpAuthenticatorInterface $totpAuthenticator
. Que nos ayudará a generar el secreto. El segundo es 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 | |
} | |
} |
Y, por supuesto, sólo puedes utilizar esta ruta si estás autentificado. Añade@IsGranted("ROLE_USER")
. Permíteme volver a escribir eso y pulsar el tabulador para que aparezca la declaración use
en la parte superior:
... 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
Este párrafo siguiente es... ¡equivocado! Utilizar ROLE_USER
no obligará a un usuario a volver a introducir su contraseña si sólo está autenticado a través de una cookie "recuérdame". Para ello, debes utilizar IS_AUTHENTICATED_FULLY
. Y eso es lo que debería haber utilizado aquí.
En su mayor parte, he utilizado IS_AUTHENTICATED_REMEMBERED
por seguridad... para que sólo tengas que iniciar sesión... aunque sea a través de una cookie "recuérdame". Pero aquí estoy utilizando ROLE_USER
, que es efectivamente idéntico aIS_AUTHENTICATED_FULLY
. Eso es a propósito. El resultado es que si el usuario se autentificó... pero sólo gracias a una cookie "recuérdame", Symfony le obligará a volver a escribir su contraseña antes de llegar aquí. Un poco de seguridad extra antes de habilitar la autenticación de dos factores.
De todos modos, digamos $user = this->getUser()
... y luego si no$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, quiero ver si la autenticación totp no está ya habilitada... pero no me aparece el autocompletado para esto.
Ya sabemos por qué: el método getUser()
sólo sabe que devuelve un UserInterface
. Lo hemos arreglado antes haciendo nuestro propio controlador base. Vamos a ampliarlo:
... lines 1 - 12 | |
class SecurityController extends BaseController | |
{ | |
... lines 15 - 48 | |
} |
Aquí abajo, si no es $user->isTotpAuthenticationEnabled()
-por lo que si el usuario no tiene ya un totpSecret
- vamos a establecer uno:$user->setTotpSecret()
pasando por $totpAuthentiator->generateSecret()
. Luego, guarda con $entityManager->flush()
.
En la parte inferior, por ahora, sólo dd($user)
para que podamos asegurarnos de que esto funciona:
... 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); | |
} | |
} |
¡Genial! ¡Vamos a enlazar con esto! Copia el nombre de la ruta... y abretemplates/base.html.twig
. Busca "Cerrar sesión". Ya está. Pegaré ese nombre de ruta, duplicaré todo li
, limpiaré las cosas, pegaré el nuevo nombre de ruta, eliminaré mi código temporal y diré "Activar 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> |
¡Hora de probar! Ah, pero antes, en tu terminal, recarga tus instalaciones:
symfony console doctrine:fixtures:load
Eso hará que todos los usuarios tengan correos electrónicos verificados para que podamos iniciar la sesión. Cuando esto termine, inicia la sesión con abraca_admin@example.com
, contraseña tada
. Precioso. A continuación, pulsa "Habilitar 2FA" y... ¡ya está! Se accede a nuestro volcado de usuarios! Y lo más importante, ¡tenemos un conjunto de totpSecret
!
¡Eso es genial! Pero el último paso es mostrar al usuario un código QR que pueda escanear para configurar su aplicación de autenticación. Hagamos eso a continuación.
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!
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
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!
Oh yes, that did the job, genius. Thank you very much!!!!!
"slow reply" - no worries :) Been swamped too, helping Ukrainian refugees
Trying to figure out why I am getting Cannot autowire service error: I have done everything that I should be as directed above but still to no avail.
Cannot autowire argument $totpAuthenticator of "App\Controller\SecurityController::enable2fa()": it references class "Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticator" but no such service exists.
My bundles.php file in /config/bundles.php
SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle::class => ['all' => true],
Scheb\TwoFactorBundle\SchebTwoFactorBundle::class => ['all' => true],
My autoload_static after running a composer dump-autoload -o
` 'Scheb\TwoFactorBundle\' =>
array (
0 => __DIR__ . '/..' . '/scheb/2fa-bundle',
1 => __DIR__ . '/..' . '/scheb/2fa-totp',
),`
My Security.yaml
` main:
lazy: true
provider: app_user_provider
custom_authenticator: App\Security\LoginFormAuthenticator
entry_point: App\Security\LoginFormAuthenticator
logout:
path: app_logout
switch_user: true
login_throttling: true
two_factor:
auth_form_path: 2fa_login
check_path: 2fa_login_check
<br />My packages/scheb_2fa.yaml file<br />
# See the configuration reference at https://symfony.com/bundles/SchebTwoFactorBundle/5.x/configuration.html
scheb_two_factor:
security_tokens:
- Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken
# If you're using guard-based authentication, you have to use this one:
# - Symfony\Component\Security\Guard\Token\PostAuthenticationGuardToken
# If you're using authenticator-based security (introduced in Symfony 5.1), you have to use this one:
- Symfony\Component\Security\Http\Authenticator\Token\PostAuthenticationToken`
My Routes scheb_2fa.yaml file
`2fa_login:
path: /2fa
defaults:
_controller: "scheb_two_factor.form_controller::form"
2fa_login_check:
path: /2fa_check
`
Cleared my cache no issues, ran composer dump-autoload -o and still getting this issue. I am not running docker this is being run on symfony 5.3 using the symfony cli command symfony serve -d php version is 8.1.13
I am stuck any help is much appreciated.
Greg
Hey @captsulu32
Have you tried type-hinting the interface instead? Scheb\TwoFactorBundle\Security\TwoFactor\Provider\Totp\TotpAuthenticatorInterface;
Cheers!
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
aaaaarg, the } has to be in front of dd($user). Sorry for bothering you ;-)
Apparently I accidently pressed Shift+Caps+Up.
// composer.json
{
"require": {
"php": ">=8.1",
"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
}
}
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.