Install a sms two factor authentication in Symfony2

May 23, 2014Raphaël Dubigny7 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.

Raphaël Dubigny

Raphaël Dubigny

Web Developer at Theodo