Install a sms two factor authentication in Symfony2
Raphaël Dubigny6 min read
Abstract:
This article aims to help you build a two step authentication with sms for your Symfony2 application. It works like the google two step authentication. Here is the workflow of the achieved feature:
- the user fills in a first login form with his login and password
- he receives an SMS with a one time code
- he fills a second login form with the code
- he can check a “I’m on a trusted computer” box so the second step will be skipped the next time he logs
- he’s logged
We will also add some development tools:
- a parameter to fallback to mails (useful in dev or test environment)
- a parameter to add a master phone number (like the ‘delivery_address’ parameter of swiftmailer)
- a functional test
Requirements
- I use Nexmo as my sms sending service.
- a functional Symfony2 project with FOSuser installed (FOSuser is not compulsory but it helps a lot doing it right and through)
1. Install bundles
We need to install two dependencies. The first, two-factor-bundle, will manage the second authentication step. The second, nexmo-bundle, will help us send sms easily.
# composer.json
{
# ...
"require": {
# ...
"scheb/two-factor-bundle": "0.3.\*",
"javihernandezgil/nexmo-bundle": "v0.9.\*"
# ...
},
# ...
}
Register them in AppKernel :
// app/AppKernel.php
// ...
class AppKernel extends Kernel
{
public function registerBundles()
{
$bundles = array(
// ...
new Scheb\\TwoFactorBundle\\SchebTwoFactorBundle(),
new Jhg\\NexmoBundle\\JhgNexmoBundle(),
// ...
);
// ...
}
// ...
}
Then add some configuration:
# app/config/config.yml
# ...
jhg\_nexmo:
api\_key: %nexmo\_api\_key%
api\_secret: %nexmo\_api\_secret%
from\_name: %nexmo\_from\_name%
# ...
nexmo_api_key, nexmo_api_secret, nexmo_from_name are parameters defined in app/config/parameters.yml. More details on these parameters are available in the two-factor bundle documentation and in the nexmo bundle documentation.
We will use two additional parameters along with them:
- nexmo_delivery_phone_number: if set, all sms messages will be sent to this phone number instead of being sent to their actual recipients. This is often useful when developing.
- nexmo_disable_delivery: if true, no sms will be delivered, mail will be send instead.
Eventually, our parameter file will look more or less like this:
# app/config/parameters.yml
...
nexmo\_api\_key: "12345abc"
nexmo\_api\_secret: "67890def"
nexmo\_from\_name: MyCompany
nexmo\_delivery\_phone\_number: "+33123456789"
nexmo\_disable\_delivery: false
You can now run composer to process the install.
composer install
2. Extend FOSUserBundle
This is the optional part. All we need is a bundle which implements a user entity. Extending FOSUserBundle is a secure and clean way to do so.
If you use FOSUserBundle then create a new bundle (I called it “AcmeUserBundle”) which extends “FOSUserBundle” as explained in the Symfony2 documentation.
3. Link nexmo with two-factor
The idea is to build a custom AuthCodeMailer which sends SMS :
// src/Acme/AcmeUserBundle/Services/SmsMailer.php
<?php
namespace Acme\\AcmeUserBundle\\Services;
use Acme\\AcmeUserBundle\\Entity\\User;
use Jhg\\NexmoBundle\\Managers\\SmsManager;
use Scheb\\TwoFactorBundle\\Model\\Email\\TwoFactorInterface;
use Scheb\\TwoFactorBundle\\Mailer\\AuthCodeMailerInterface;
class SmsMailer implements AuthCodeMailerInterface
{
private $smsSender;
private $senderMail;
private $mailer;
private $isSmsDisabled;
private $deliveryPhoneNumber;
private $senderAddress;
public function \_\_construct(SmsManager $smsSender, \\Swift\_Mailer $mailer, $isSmsDisabled, $deliveryPhoneNumber, $senderAddress)
{
$this->smsSender = $smsSender;
$this->mailer = $mailer;
$this->isSmsDisabled = $isSmsDisabled;
$this->deliveryPhoneNumber = $deliveryPhoneNumber;
$this->senderAddress = $senderAddress;
}
public function sendAuthCode(TwoFactorInterface $user)
{
$msg = "Your validation code is " . $user->getEmailAuthCode();
$fromName = "SMSAuth";
$this->sendSMS($user, $msg, $fromName);
}
public function sendSMS(User $user, $msg, $fromName)
{
// Fallback to mail if isSmsDisabled
if ($this->isSmsDisabled) {
$this->sendMail($user->getEmail(), $msg, $fromName);
} else {
if ($this->deliveryPhoneNumber !== null) {
$number = $this->deliveryPhoneNumber;
} else {
$number = $user->getPhoneNumber();
}
$this->smsSender->sendText($number, $msg, $fromName);
}
}
public function sendMail($deliveryAddress, $msg, $fromName)
{
$message = \\Swift\_Message::newInstance()
->setSubject("\[SMS - ".$fromName."\]")
->setFrom($this->senderAddress)
->setTo($deliveryAddress);
$message->setBody($msg, 'text/html');
return $this->mailer->send($message);
}
}
Then declare this as a service:
# src/Acme/AcmeUserBundle/Ressources/config/service.yml
parameters:
acme\_user.sms\_manager.class: Acme\\AcmeUserBundle\\Services\\SmsMailer
services:
doctor\_dashboard.sms\_mailer:
class: %acme\_user.sms\_manager.class%
arguments:
- @jhg\_nexmo\_sms
- @mailer
- %nexmo\_disable\_delivery%
- %nexmo\_delivery\_phone\_number%
- %mailer\_sender%
Configure the two factor bundle so it uses our sms mailer:
# app/config/config.yml
scheb\_two\_factor:
email:
enabled: true
mailer: acme\_user.sms\_mailer
sender\_email: %mailer\_sender%
template: AcmeUserBundle:Security:login\_validation.html.twig
digits: 6
model\_manager\_name: ~
Also add the configuration for the trusted computer feature. This will allow users to check a “I’m on a trusted computer” box so they could skip the second step the next time they log.
# app/config/config.yml
scheb\_two\_factor:
# ...
trusted\_computer:
enabled: true
cookie\_name: two\_factor\_trusted\_computer
cookie\_lifetime: 5184000 # 60 days
If you want to customize the form integration:
{# src/Acme/AcmeUserBundle/Resources/views/Security/login\_validation.html.twig #}
{% extends "FOSUserBundle::layout.html.twig" %}
{% trans\_default\_domain 'FOSUserBundle' %}
{% block fos\_user\_content %}
{# the following is just the template proposed in the two-factor-bundle #}
<form class="form" action="" method="post">
{% for flashMessage in app.session.flashbag.get("two\_factor") %}
<p class="error">{{ flashMessage|trans }}</p>
{% endfor %}
<p class="label"><label for="\_auth\_code">{{ "scheb\_two\_factor.auth\_code"|trans }}</label></p>
<p class="widget"><input id="\_auth\_code" type="text" autocomplete="off" name="\_auth\_code" /></p>
{% if useTrustedOption %}<p class="widget"><label for="\_trusted"><input id="\_trusted" type="checkbox" name="\_trusted" /> {{ "scheb\_two\_factor.trusted"|trans }}</label></p>{% endif %}
<p class="submit"><input type="submit" value="{{ "scheb\_two\_factor.login"|trans }}" /></p>
{# The logout link gives the user a way out if they can't complete the second step #}
<p class="cancel"><a href="{{ path("\_security\_logout") }}">Cancel</a></p>
</form>
{% endblock fos\_user\_content %}
At last, implement a proper user for this to work:
// src/Acme/AcmeUserBundle/Entity/User.php
<?php
namespace Acme\\AcmeUserBundle\\Entity;
use FOS\\UserBundle\\Model\\User as BaseUser;
use Doctrine\\ORM\\Mapping as ORM;
use Scheb\\TwoFactorBundle\\Model\\Email\\TwoFactorInterface;
use Scheb\\TwoFactorBundle\\Model\\TrustedComputerInterface;
/\*\*
\* @ORM\\Table(name="acme\_user")
\* @ORM\\Entity()
\*/
abstract class User extends BaseUser implements TwoFactorInterface, TrustedComputerInterface
{
/\*\*
\* @var integer
\*
\* @ORM\\Column(name="id", type="integer")
\* @ORM\\Id
\* @ORM\\GeneratedValue(strategy="AUTO")
\*/
protected $id;
/\*\*
\* @var string
\*
\* @ORM\\Column(name="phone\_number", type="string", length=255)
\*/
protected $phoneNumber;
/\*\*
\* @ORM\\Column(name="auth\_code", type="integer", nullable=true)
\*/
private $authCode;
/\*\*
\* @ORM\\Column(name="trusted", type="json\_array", nullable=true)
\*/
private $trusted;
public function setPhoneNumber($phoneNumber)
{
$this->phoneNumber = $phoneNumber;
return $this;
}
public function getPhoneNumber()
{
return $this->phoneNumber;
}
/\*
\* Implement the TwoFactorInterface
\*/
public function isEmailAuthEnabled() {
return true; // This can also be a persisted field but it is enabled by default for now
}
public function getEmailAuthCode() {
return $this->authCode;
}
public function setEmailAuthCode($authCode) {
$this->authCode = $authCode;
}
/\*
\* Implement the TrustedComputerInterface
\*/
public function addTrustedComputer($token, \\DateTime $validUntil)
{
$this->trusted\[$token\] = $validUntil->format("r");
}
public function isTrustedComputer($token)
{
if (isset($this->trusted\[$token\])) {
$now = new \\DateTime();
$validUntil = new \\DateTime($this->trusted\[$token\]);
return $now < $validUntil;
}
return false;
}
}
4. Test your work
In a behat scenario we want to do things like this:
Scenario: Login through login form
Given I am on "/login"
When I fill in "username" with "admin"
And I fill in "password" with "admin"
And I press "\_submit"
Then I fill the form with the validation code
And I press "\_submit"
Then the url should match "/home"
Here is the custom behat step to do so:
// Features/Context/FeatureContext.php
/\*\*
\* @Then /^I fill the form with the validation code$/
\*/
public function iFillTheValidationCodeForm()
{
$profiler = $this->getContainer()->get('profiler');
$result = $profiler->find(null, null, 1, "POST", null, null);
$profile = $profiler->loadProfile($result\[0\]\['token'\]);
$collector = $profile->getCollector('swiftmailer');
$code = $collector->getMessages()\[0\]->getBody();
return array(
new Step\\When('I fill in "\_auth\_code" with "'.$code.'"')
);
}
Resources
Have a look at Christian Scheb Blog
Special thanks to scheb and javihernandezgil for their fantastic work and availability.