Products
Use cases
Resources & Case studies
- Products
- Use cases
- Resources & Case studies
This tutorial will help you integrate Verify 2FA into an existing Symfony application in order to increase its security by providing a complementary identity verification mechanism (2FA).
In this tutorial we will create a new Symfony project, add basic user authentication to it, and then add Verify 2FA.
We will cover all the necessary steps to get this working on a local machine
To make the example as portable as possible we will be using Docker. For the purposes of this article we will be using an Ubuntu box, but the same should be replicable on any other platform.
There are a few preparatory steps that must be followed in order to end up with a working application:
Create an account on TypingDNA.
Once there, go to your Dashboard to find a screen similar to:
Take a note of your Client Id and Client Secret
Install docker on your development computer
Create an account at ngrok (We will use this later to create a secure public URL to localhost which is mandatory for Verify 2FA)
Download and install the ngrok binary
Thatâs it! Youâre ready to start building your app.
We will be using three different docker containers: php
, nginx
and database
. Since all of them will be physically located in the same development machine, we will use docker-compose
to easily interact with them.
Create a new directory to hold the project and inside of it create a docker-compose.yml
file that looks like this:
version: '3.8'
services:
database:
container_name: database
image: mysql:8.0
command: --default-authentication-plugin=mysql_native_password
environment:
MYSQL_ROOT_PASSWORD: secret
MYSQL_DATABASE: symfony
MYSQL_USER: symfony
MYSQL_PASSWORD: symfony
ports:
- '4306:3306'
volumes:
- ./mysql:/var/lib/mysql
php:
container_name: php
build:
context: ./php
ports:
- '9000:9000'
volumes:
- ./app:/var/www/symfony_docker
depends_on:
- database
nginx:
container_name: nginx
image: nginx:stable-alpine
ports:
- '8080:80'
volumes:
- ./app:/var/www/symfony_docker
- ./nginx/default.conf:/etc/nginx/conf.d/default.conf
depends_on:
- php
- database
Now we need to put a couple of auxiliary directories together:
php
to hold the Dockerfile
needed to build the php image
nginx
for the webserver configuration file (default.conf
)
The file php/Dockerfile
should look like this
FROM php:8-fpm
RUN apt update \
&& apt install -y zlib1g-dev g++ git libicu-dev zip libzip-dev zip \
&& docker-php-ext-install intl opcache pdo pdo_mysql \
&& pecl install apcu \
&& docker-php-ext-enable apcu \
&& docker-php-ext-configure zip \
&& docker-php-ext-install zip
WORKDIR /var/www/symfony_docker
RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
RUN curl -sS https://get.symfony.com/cli/installer | bash
RUN mv /root/.symfony/bin/symfony /usr/local/bin/symfony
RUN git config --global user.email "YOUR_EMAIL" && \
git config --global user.name "YOUR_NAME"
And the file nginx/default.conf
like this:
server {
listen 80;
index index.php;
server_name localhost;
root /var/www/symfony_docker/public;
error_log /var/log/nginx/project_error.log;
access_log /var/log/nginx/project_access.log;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\\.php(/|$) {
fastcgi_pass php:9000;
fastcgi_split_path_info ^(.+\\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_buffer_size 128k;
fastcgi_buffers 4 256k;
fastcgi_busy_buffers_size 256k;
internal;
}
location ~ \\.php$ {
return 404;
}
}
With all this in place youâre ready to start your containers and start building your application in a local LEMP environment.
Use this command to get things going:
docker-compose up -d
The symfony binary is installed inside the php container, so letâs use it to create a new project:
docker-compose exec php symfony new . --full
Now, if you take a look at the contents of the app directory youâll find the basic structure of your project:
If everything is in place you should be able to see this:
When pointing your browser to http://localhost:8080
To add user authentication we need to perform the following steps:
Run the command docker-compose exec php symfony console make:user
to have symfony create the User class.
Use the default answer for all the questions and you should end up with this screen:
Before you can execute any commands on the database, you need to configure the access information.
Start by copying the file app/.env
to app/.env.local
, then edit the latter to look like this:
# In all environments, the following files are loaded if they exist,
# the latter taking precedence over the former:
#
# * .env contains default values for the environment variables needed by the app
# * .env.local uncommitted file with local overrides
# * .env.$APP_ENV committed environment-specific defaults
# * .env.$APP_ENV.local uncommitted environment-specific overrides
#
# Real environment variables win over .env files.
#
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
#
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
###> symfony/framework-bundle ###
APP_ENV=dev
APP_SECRET=eff7b8b9f7a76270b8c35130179d1b5a
###< symfony/framework-bundle ###
###> symfony/mailer ###
# MAILER_DSN=smtp://localhost
###< symfony/mailer ###
###> doctrine/doctrine-bundle ###
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
#
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"
DATABASE_URL="mysql://symfony:symfony@database:3306/symfony?serverVersion=8.0"
# DATABASE_URL="postgresql://db_user:db_password@127.0.0.1:5432/db_name?serverVersion=13&charset=utf8"
###< doctrine/doctrine-bundle ###
This is a two step process. First weâll create the appropriate migration through the command docker-compose exec php symfony console make:migration
.
Then run the migration: docker-compose exec php symfony console doctrine:migrations:migrate -q
.
Use the command docker-compose exec php symfony console make:auth
to get this done efficiently.
Answer 1
when prompted What style of authentication do you want? [Empty authenticator]
Answer LoginAuthenticator
when prompted The class name of the authenticator to create (e.g. AppCustomAuthenticator)
Then keep the defaults until you see a screen that looks like this:
For this weâre going to use Doctrineâs Fixtures feature, which must be installed.
Use the command docker-compose exec php composer require orm-fixtures --dev
to have composer install and configure it for you.
Once done, issue the command docker-compose exec php symfony console make:fixtures UserFixtures
to have symfony create all the necessary code for you to start putting contents into the database.
Edit the file app/src/DataFixtures/UserFixtures.php
to make it look like this:
<?php
namespace App\DataFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
use App\Entity\User;
class UserFixtures extends Fixture
{
private $passwordHasher;
public function __construct(UserPasswordHasherInterface $passwordHasher)
{
$this->passwordHasher = $passwordHasher;
}
public function load(ObjectManager $manager)
{
$user = new User();
$user
->setPassword($this->passwordHasher->hashPassword($user, 'p4ssW0rd!'))
->setEmail('YOUR_EMAIL')
;
$manager->persist($user);
$manager->flush();
}
}
Load the fixtures into the database: docker-compose exec php symfony console doctrine:fixtures:load -q
Now point your browser to http://127.0.0.1:8080/login. You should see something like this:
Enter the credentials you defined in your fixture class and hit âSign Inâ. Youâll see something like this:
Donât worry about it, itâs totally normal đ.
The fact is, we didnât tell symfony where to send the user once authenticated. Letâs fix that...
In this section weâll build a very basic dashboard to be used as a protected area.
Start by creating a new Controller:
docker-compose exec php symfony console make:controller
Name it DashboardController
.
Edit the file app/src/Controller/DashboardController.php
to look like this:
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
class DashboardController extends AbstractController
{
#[Route('/dashboard', name: 'dashboard')]
public function index(): Response
{
return $this->render('dashboard/index.html.twig', [
'user' => $this->getUser(),
]);
}
}
This way, the template dashboard/index.html.twig
will have access to the User
object.
Finally, your template should look like this:
{% extends 'base.html.twig' %}
{% block title %}Hello!{% endblock %}
{% block body %}
<div class="form-container">
<div class="card">
<div class="card-header">
Dashboard
</div>
<div class="card-body">
<h5 class="card-title">{{ user.email }}</h5>
<a href="/logout" class="btn btn-primary">Logout</a>
</div>
</div>
</div>
{% endblock %}
Now you have the dashboard ready for user views.
Thereâs just one little detail missing: you need to connect the dashboard with the valid login. To do that youâll need to edit the file app/src/Security/LoginAuthenticator.php
and change the method onAuthenticationSuccess
to look like this:
public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
if ($targetPath = $this->getTargetPath($request->getSession(), $firewallName)) {
return new RedirectResponse($targetPath);
}
return new RedirectResponse($this->urlGenerator->generate('dashboard'));
}
Save the file, reload the page in the browser and you should see:
Great! Now you have a simple Symfony application that has a login and a protected area.
But, is it really protected? Look at what would happen if you logged out and tried to go straight to http://127.0.0.1:8080/dashboard
We have to fix that.
Fortunately, doing so using Symfony is really simple: just edit the file app/config/packages/security.yaml
and change the definitions under the key access_control
to:
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/logout, roles: ROLE_USER }
- { path: ^/dashboard, roles: ROLE_USER }
And there you go. If someone tries to go directly to your dashboard theyâll be immediately redirected to the login page.
This security can be enough in some cases, but if you really want to make sure people are who they say they are, you better take some extra measures, like using a Two Factor Authentication mechanism.
For that, Verify 2FA is a great option, both for the user and, as youâll see, also for you, as it is really simple to set up.
The first step in the integration process is to download the TypingDNA php client.
The way I did it (and I suggest you do it too) is to build a wrapper class around it so your application can be prepared for upcoming versions of the library (but you could use this one directly if you want).
Create a directory called TwoFactorAuthentication
inside your app/src
directory and place the file TypingDNAVerifyClient.php
that you just downloaded in there.
Open the file and add namespace App\TwoFactorAuthentication;
at the top.
Next, create a new file called TypingDNAWrapper.php
in the same directory.
This file should contain the following:
<?php
namespace App\TwoFactorAuthentication;
class TypingDNAWrapper
{
private TypingDNAVerifyClient $client;
public function __construct(string $clientID, string $applicationID, string $secret)
{
$this->client = new TypingDNAVerifyClient($clientID, $applicationID, $secret);
}
/**
* @param string $phoneNumber
* @return array
*/
public function getDataAttributes(string $phoneNumber) : array
{
return $this->getClient()->getDataAttributes(
[
'phoneNumber' => $phoneNumber,
'language' => 'en',
'mode' => 'standard',
]
);
}
/**
* @param string $phoneNumber
* @param string $otp
* @return bool
*/
public function isValidOTP(string $phoneNumber, string $otp) : bool
{
$response = $this->getClient()
->validateOTP([
'phoneNumber' => $phoneNumber,
], $otp);
return $response['success'];
}
private function getClient() : TypingDNAVerifyClient
{
return $this->client;
}
}
The idea of this wrapper is to simplify the interaction with the TypingDNA API by serving as a repository for the common information that must be exchanged in every call (The ClientId, ApplicationId and secret).
This information can be obtained from your Dashboard. Go to Verify 2FA Settings
Enter âSf tutorialâ in the field âIntegration nameâ:
For the Domain field youâll need a public domain name that points to your application.
Since you have an NginX server listening on port 8080 on your local machine (port 8080 being forwarded to port 80 on nginx docker container), you need a way to expose that to the Internet.
Hereâs where ngrok comes into play.
Simply open a separate terminal and run the following:
ngrok http 8080
Then take the information of the https forwarding:
And paste it into the domain field of the integration definition:
Click âCreate integrationâ.
This will result in an application id:
In order to have all this information injected into the service weâll leverage Symfonyâs configuration system.
Letâs start by adding a couple of entries to our .env.local
file:
###TypingDNA
TYPING_DNA_CLIENT_ID=YOUR_CLIENT_ID
TYPING_DNA_APP_ID=YOUR_APP_ID
TYPING_DNA_CLIENT_SECRET=YOUR_SECRET
The TypingDNAWrapper will be used as a new service. In order to have Symfony autowire it when we need it, we will have to create a special configuration for it inside the services.yaml
file, since the constructor has some parameters that need to be defined.
Simply open the file app/config/services.yaml
and add the following to the bottom:
App\TwoFactorAuthentication\TypingDNAWrapper:
class: App\TwoFactorAuthentication\TypingDNAWrapper
arguments:
$clientID: '%env(TYPING_DNA_CLIENT_ID)%'
$applicationID: '%env(TYPING_DNA_APP_ID)%'
$secret: '%env(TYPING_DNA_CLIENT_SECRET)%'
This will have Symfony grab all the configuration from the .env.local
file and use it to instantiate the service.
At this point you have everything you need to interact with TypingDNA! You just need to make a couple of adjustments to tie it all together and actually have the user confirm their identity.
We are going to use the userâs phone number as the root of trust. To do that we will need to store it somewhere.
The best way to go about this is to add a new property to the User class. Start with the command docker-compose exec php symfony console make:entity User
.
Since our User class is already defined, weâll only be adding a new field to it, name it phoneNumber
:
Then use the command docker-compose exec php symfony console make:migration
to produce the code to update the database and finally run the migration with docker-compose exec php symfony console doctrine:migrations:migrate -q
.
Now all you have to do is add the phone number to the user in the database.
In this case weâll do it by updating the UserFixtures
class.
The load method should look like this:
public function load(ObjectManager $manager)
{
$user = new User();
$user
->setPassword($this->passwordHasher->hashPassword($user, 'p4ssW0rd!'))
->setEmail('mauro.chojrin@leewayweb.com')
->setPhoneNumber('00YOUR_NUMBER')
;
$manager->persist($user);
$manager->flush();
}
After that just reload the fixtures to the database using this command: docker-compose exec php symfony console doctrine:fixtures:load -q
.
The next step is to add the 2FA to the login process. To do that youâll have to change the class SecurityController
, adding the following methods:
/**
* @Route("/verify/", name="app_verify_otp")
*/
public function verifyOTP(Request $request, TypingDNAWrapper $typingDNA) : Response
{
$error = null;
$user = $this->getUser();
$phoneNumber = $user->getPhoneNumber();
if ($otp = $request->get('otp')) {
if ($typingDNA->isValidOTP($phoneNumber, $otp)) {
$user
->setRoles(array_merge($user->getRoles(), ['TWO_FACTOR_PASSED']));
return $this->redirectToRoute('dashboard');
} else {
return $this->redirectToRoute('2fa-failed');
}
}
return $this->render(
'security/verify.html.twig',
[
'error' => $error,
'typingdna' => $typingDNA->getDataAttributes($phoneNumber)
]
);
}
/**
* @return Response
* @Route (path="/2fa_failed", name="2fa-failed")
*/
public function TwoFAFailed() : Response
{
return $this->render(
'security/2fa_failed.html.twig'
);
}
Donât forget to add the following at the top, otherwise the code wonât work.
use App\TwoFactorAuthentication\TypingDNAWrapper;
use Symfony\Component\HttpFoundation\Request;
With these changes to the code weâre setting the stage for the application to trigger TypingDNAâs verification.
Upon successful verification the user will be granted a new role ('TWO_FACTOR_PASSED')
which we will use as a means of confirmation that they are who they say they are.
We already have the backend ready for interacting with TypingDNAâs API, itâs time to get the frontend in sync.
Weâll start by creating two new templates inside the security
subdirectory. The first will be templates/security/verify.html.twig.
This template will serve as entrypoint to Verify 2FAâs workflow:
{% extends 'base.html.twig' %}
{% block title %}Verify OTP{% endblock %}
{% block body %}
<button
class="typingDNA-verify"
data-typingdna-client-id="{{ typingdna.clientId }}"
data-typingdna-application-id="{{ typingdna.applicationId }}"
data-typingdna-payload="{{ typingdna.payload }}"
data-typingdna-callback-fn= "callbackFn"
>Verify with Typingdna
</button>
{% endblock %}
{% block javascripts %}
<script src="https://cdn.typingdna.com/verify/typingdna-verify.js"></script>
<script>
function callbackFn(payload)
{
window.location.href = "{{ url('app_verify_otp') }}?otp=".concat(payload['otp']);
}
</script>
{% endblock %}
This template includes TypingDNAâs JavaScript code which will handle the interaction with the API, starting with the click of the button.
Itâs very important to respect the button class name as this is the marker used by the JS library to connect the event handler.
Before we go any further, letâs make a checkpoint.
Take your browser to https://NGROK_PROVIDED_URL/login and enter the details present in your database.
Next, change the URL to https://NGROK_PROVIDED_URL/verify. You should see a screen similar to:
If you click the button you should see a popup window similar to:
Follow the instructions in there and youâll finally be redirected to the user Dashboard.
Great! Integration with TypingDNA is working, all thatâs left is to close the circle by having the 2FA be mandatory in order to gain access to the protected area.
The first step here is to have the user redirected to the verification screen upon successful login. To do that, open the file app/src/Security/LoginAuthenticator.php
and change the method onAuthenticationSuccess
to return a redirection to app_verify_otp
instead of dashboard.
The final step is to update the firewall configuration to disallow access to the dashboard to users that donât have the role 'TWO_FACTOR_PASSED'
.
Open the file app/config/packages/security.yaml
and change the contents of the key access_control
to:
access_control:
- { path: ^/login, roles: IS_AUTHENTICATED_ANONYMOUSLY }
- { path: ^/logout, roles: ROLE_USER }
- { path: ^/dashboard, roles: TWO_FACTOR_PASSED }
Try it out. Log out and log in again to see what happens.
And youâre done! In this tutorial you learned how to integrate TypingDNAâs Verify to increase the security of a Symfony application.
A couple of tasks you might want to undertake in order to improve on this proof of concept application are:
Move the javascript inclusion to WebPack
Have the login method check the 2FA not only at /login
but also upon attempts to directly access the protected area.
If you want to take a closer look at the sample code you can check out this repository.
For the production environment, in order to be able to send SMS OTPs you will need to integrate with Twillio in the Verify 2FA dashboard. First log in to your Twillio account and then click on the following button from the dashboard:
For more support, contact us at support@typingdna.com.