Configure WSSE on Symfony with FOSRestBundle

Share Button

Lire la version française

As REST needs to be stateless :

The client–server communication is further constrained by no client context being stored on the server between requests. Each request from any client contains all of the information necessary to service the request, and any session state is held in the client.

Wikipedia Rest Article

We needed to add a new authentication provider in symfony2 in accordance with this constraint. We chose WSSE.

This article describes how to configure WSSE, how to mix it with FOSRestBundle and how to test it with Google Chrome.

Some words about salt.

First of all, we want to explain to you how we handle passwords in this case.

In FOSUserBundle, we use a salt to improve security. As you will see in wsse configuration, the password used for digest verification is the password of the user as stored in database, encoded with salt. 

In order to work, our client side code needs to generate a passwordDigest where password is also encoded with salt. In other words :

Client side needs to know user salt in order to keep in his database an encrypted password : the salt has to be a public member on getUser call in your API.

First we were very surprised, then we read a lot of things about this subject.

Actually, salt is used for Rainbow tables attacks on encrypted password. That is to say : “If a bad hacker get your whole user table, he can not decode all passwords using auto-generated rainbow tables.”

If someone get your database, he will also get salts. As there is no other use for the salt… Salt is use in case of database export and theft. We can share salt without fear.

Informations

Tools

Bundles exist for wsse authentication (thanks Google), but as it is a security part, and because we are curious, we decided to configure it from scratch in order to deeply understand how wsse works.

We invited you to read this article about WSSE.

How to install WSSE in Symfony2

This part is described by the Symfony2 cookbook chapter about WSSE

You just have to follow this article. Be attentive to the problems of firewall if you use multiple providers.

Here is how we done it, and inserted as the first firewall: 

#app/config/security.yml
  # ...
  security:
    # ...
    firewalls:  
       wsse_secured:
          pattern:   ^/api/.*
          stateless:    true
          wsse: true
          anonymous : true
       # ...

The pattern was chosen in accordance with our route pattern in Rest configuration :

#app/routing_yml
#REST 
rest : 
  type : rest 
  resource : "routing_rest.yml"
  prefix : /api

That’s it, your wsse authentication is ready.

In our case, user is provided by FOSUserBundle, but authenticated by wsse.

We also rewrite a part of AuthenticationProvider this way (added some AuthentificationException(s))

//Obtao\UserBundle\Security\Authentication\Provider 

  // ...

  public function authenticate(TokenInterface $token){
    $user = $this->userProvider->loadUserByUsername($token->getUsername());
    if(!$user){
      throw new AuthenticationException("Bad credentials... Did you forgot your username ?");
    }
    if ($user && 
    $this->validateDigest($token->digest, $token->nonce, $token->created, $user->getPassword())) {
      // ...
    }
  }

  protected function validateDigest($digest, $nonce, $created, $secret){
        
    // Check created time is not in the future
    if (strtotime($created) > time()) {
      throw new AuthenticationException("Back to the future...");
    }

    // Expire timestamp after 5 minutes
    if (time() - strtotime($created) > 300) {
      throw new AuthenticationException("Too late for this timestamp... Watch your watch.");
    }

    // Validate nonce is unique within 5 minutes
    if (file_exists($this->cacheDir.'/'.$nonce) && file_get_contents($this->cacheDir.'/'.$nonce) + 300 > time()) {
      throw new NonceExpiredException('Previously used nonce detected');
    }

    // If cache directory does not exist we create it
    if (!is_dir($this->cacheDir)) {
      mkdir($this->cacheDir, 0777, true);
    }

    file_put_contents($this->cacheDir.'/'.$nonce, time());

    // Validate Secret
    $expected = base64_encode(sha1(base64_decode($nonce).$created.$secret, true));

    if($digest !== $expected){
      throw new AuthenticationException("Bad credentials ! Digest is not as expected.");
    }

    return true;
  }

Log errors and return a 403 error message.

Here are the changes needed to save logs about wsse login in separate files.

In your bundle services.yml file, you have to add the logger service injection, and choose a channel (here “wsse“) :

#Obtao\UserBundle\Resources\config\services.yml
services : 
  wsse.security.authentication.listener:
    class:  Obtao\UserBundle\Security\Firewall\WsseListener
    arguments: ["@security.context", "@security.authentication.manager","@logger"]
    tags:
      - { name: monolog.logger, channel: wsse }

In your Wsse listener, add the injection in the constructor and add a Log in case of AuthenticationException. We choose not to redirect our user, but to display an explicit error message (on 403).

namespace Obtao\UserBundle\Security\Firewall;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Http\Firewall\ListenerInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\SecurityContextInterface;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
use Obtao\UserBundle\Security\Authentication\Token\WsseUserToken;
use Symfony\Component\HttpKernel\Log\LoggerInterface;

class WsseListener implements ListenerInterface
{
    protected $securityContext;
    protected $authenticationManager;
    protected $logger;

    public function __construct(SecurityContextInterface $securityContext,
                               AuthenticationManagerInterface $authenticationManager,
                               LoggerInterface $logger
    ){
        $this->securityContext = $securityContext;
        $this->authenticationManager = $authenticationManager;
        $this->logger = $logger;
    }

    public function handle(GetResponseEvent $event)
    {
        $request = $event->getRequest();

        $wsseRegex = '/UsernameToken Username="([^"]+)", PasswordDigest="([^"]+)", Nonce="([^"]+)", Created="([^"]+)"/';
        if (!$request->headers->has('x-wsse')
         || 1 !== preg_match($wsseRegex, $request->headers->get('x-wsse'), $matches)
        ) {
            return;
        }

        $token = new WsseUserToken();
        $token->setUser($matches[1]);

        $token->digest   = $matches[2];
        $token->nonce    = $matches[3];
        $token->created  = $matches[4];

        try {
            $authToken = $this->authenticationManager->authenticate($token);
            $this->securityContext->setToken($authToken);
            return;
        } catch (AuthenticationException $failed) {
            $failedMessage = 'WSSE Login failed for '.$token->getUsername().'. Why ? '.$failed->getMessage();
            $this->logger->err($failedMessage);
            
            // Deny authentication with a '403 Forbidden' HTTP response
            $response = new Response();
            $response->setStatusCode(403);
            $response->setContent($failedMessage);
            $event->setResponse($response);
            return; 
            
        }
        // By default deny authorization
        $response = new Response();
        $response->setStatusCode(403);
        $event->setResponse($response);
    }
}

In config_prod.yml, declare your channel and the ouptut file. (We did the same in config_dev.yml)

//app/config/config_prod.yml
monolog:
    handlers:
        main:
            type          : fingers_crossed
            action_level  : error
            handler       : nested
            channels      : [!wsse]
            
        wsse:
            type: stream
            path: %kernel.logs_dir%/%kernel.environment%.wsse.log
            level: error
            channels: [wsse]
            
        nested:
            type:  stream
            path:  %kernel.logs_dir%/%kernel.environment%.log
            level: debug

For further information you can read our article about how to handle channel in monolog

Test your wsse access to your API (GET).

WSSE communication over HTTP is header based.

In order to test our API, we have to :

- Generate WSSE Header (With nonce and date)
- Send it to the server

For WSSE Header generation, use http://www.teria.com/~koseki/tools/wssegen/ . Simple, efficient. (auto nonce, auto date. No before wsse. User = username in database, password = encypted salt+password = password as in database)

Click on generate, copy the value part of the result.

UsernameToken Username="francois",PasswordDigest="LlsDqVDaw5nMs1iasbladXWvs5c=",Nonce="YzdlMzQ3NWQ4MTc1YTI3OA==", Created="2013-05-30T07:53:54Z"

Install and open Rest Console for chrome. Fields to use :

  • Request API : Your server address, with an API service (http://obtao.localhost/app_dev.php/api/me for us)
  • Custom Header (+) :
    • Parameter = “x-wsse“,
    • Value
      UsernameToken Username=”francois”,PasswordDigest=”LlsDqVDaw5nMs1iasbladXWvs5c=”,Nonce=”YzdlMzQ3NWQ4MTc1YTI3OA==”, Created=”2013-05-30T07:53:54Z”[Generated value by wssegen, see above]
  • Authorization Hearder :  Authorization profile=”UsernameToken”

Push the GET button ! :)

If a username header is given to the wsse (server side), Symfony will try to authenticate the user. If no username is given, Symfony will allow anonymous connexion (as described in security.yml under wsse) 

You can now easily test your API by WSSE access. And no link (cookie) has to be stored… neither server side nor client side.  You can check and get the authenticated user in your controllers.

For more informations you can read our article : “How to use WSSE in Android App”

Share Button

7 thoughts on “Configure WSSE on Symfony with FOSRestBundle

  1. One additional thing that is useful to have is an ability to dispatch events similiar to the ones that are being dispatched when logging in using regular authentication listener for FOSUserBundle because this will help you develop consistent authentication and context setup flow (e.g. setting locale based on user preferences).

    • Indeed, thanks for the link !

      In fact, this exercise was for us more a way to deeply understand how it works than a desire to reinvent the wheel.

  2. thx a lot for the great article, it helped me solved a problem with my firewall.

    Btw, there is a great bundle in order to test the api with wsse auth using the javascript wssegen : NelmioApiDocBundle. It works great. You might want to disable the firewall wsse when calling from the doc sandbox but I use wsse auth to identificate the user, so I use your javascript generator (THX A LOT) and it works fine.

  3. Should I create new nonce on every client’s request?
    I see many client implementations which persist the created header and use it for every request, but it doesn’t make sense with the part of the code that checks if the nonce wasn’t used in the past 5 minutes. (its means every request should have new nonce)
    Did I get it right?
    THanks!

    • Indeed, in this implementation you have to regenerate nonce for every request.
      This avoid replay attacks. If someone intercept the nonce, he can not use it for identity theft.

      François