Création d'une API REST dans une application Symfony 2.8 avec authentification WSSE

Création d'une API REST dans une application Symfony 2.8 avec authentification WSSE

Publié le 22/06/2016

1 - Création d'un socle applicatif SF2

On va créer une application Symfony avec un Bundle MainBundle contenant deux entités User et Group qui étendent les entités du même nom du Bundle FOSUserBundle (Cf le tutorial sur FOSUserBundle).

Les différentes étapes qui suivent vont nous amener à passer une requête GET via l'API REST de notre application pour obtenir les informations de notre utilisateur "admin". Notre API sera sécurisée et nécessitera une authentification WSSE.

2 - Installation des bundles FOSRestBundle JMSSerializerBundle

Ajouter les deux bundles dans le fichier composer.json :

nano composer.json

// ...
"friendsofsymfony/rest-bundle": "dev-master",
"jms/serializer-bundle": "dev-master"

Mettre à jour les dépendances :

php composer.phar update

Enregistrer les bundle dans le fichier AppKernel.php :

nano app/AppKernel.php

public function registerBundles()
    {
        $bundles = array(
            [...],
            new FOS\RestBundle\FOSRestBundle(),
            new JMS\SerializerBundle\JMSSerializerBundle(),

3 - Configuration de FOSRestBundle

nano app/config/config.yml

fos_rest:
    param_fetcher_listener: true
    body_listener: true
    format_listener: true
    view:
        view_response_listener: 'force'
        formats:
            xml: true
            json : true
        templating_formats:
            html: true
        force_redirects:
            html: true
        failed_validation: HTTP_BAD_REQUEST
        default_engine: twig
    routing_loader:
        default_format: json

4 - Création des routes

nano app/config/routing.yml

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

nano app/config/routing_rest.yml

Rest_User :
  type : rest
  resource: "@ApiWsseMainBundle/Resources/config/routing_rest.yml"

nano src/ApiWsse/MainBundle/Resources/config/routing_rest.yml

users :
  type: rest
  resource:     "@ApiWsseMainBundle/Controller/UserRestController.php"
  name_prefix:  api_

5 - Création du controller UserRestController

nano src/ApiWsse/MainBundle/Controller/UserRestController

<?php

namespace ApiWsse\MainBundle\Controller;

use FOS\RestBundle\Controller\Annotations\View;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;

class UserRestController extends Controller
{
	public function getUserAction($username)
	{
		$user = $this->getDoctrine()->getRepository('ApiWsseMainBundle:User')->findOneByUsername($username);
		
		if(!is_object($user)){
			throw $this->createNotFoundException();
		}
		return $user;
	}
}

6 - Configuration de la serialisation dans l'entité User

nano src/ApiWsse/MainBundle/Entity/User.php

<?php

namespace ApiWsse\MainBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;
use FOS\UserBundle\Entity\User as BaseUser;
use JMS\Serializer\Annotation\ExclusionPolicy;
use JMS\Serializer\Annotation\Expose;
use JMS\Serializer\Annotation\Groups;
use JMS\Serializer\Annotation\VirtualProperty;

/**
 * Class User
 * @package ApiWsse\MainBundle\Entity
 * @ORM\Table(name="fos_user")
 * @ORM\Entity
 *
 * @ExclusionPolicy("all")
 */
class User extends BaseUser
{
	// ...

	/**
	 * @var
	 *
	 * @ORM\Column(name="firstname", type="string", length=255, nullable=true)
	 * @Expose
	 */
	protected $firstname;

	/**
	 * @var
	 *
	 * @ORM\Column(name="lastname", type="string", length=255, nullable=true)
	 * @Expose
	 */
	protected $lastname;

	// ...	

	/**
	 * Get the formatted name to display (NAME Firstname or username)
	 *
	 * @param $separator: the separator between name and firstname (default: ' ')
	 * @return String
	 * @VirtualProperty
	 */
	public function getUsedName($separator = ' '){
		if($this->getLastname() != null && $this->getFirstName() != null){
			return ucfirst(strtolower($this->getFirstName())).$separator.strtoupper($this->getLastName());
		}
		else{
			return $this->getUsername();
		}
	}
}

7 - Création du Token

nano src/ApiWsse/MainBundle/Security/Authentication/Token/WsseUserToken.php

<?php

namespace ApiWsse\MainBundle\Security\Authentication\Token;

use Symfony\Component\Security\Core\Authentication\Token\AbstractToken;

class WsseUserToken extends AbstractToken
{
	public $created;
	public $digest;
	public $nonce;

	public function __construct(array $roles = array())
	{
		parent::__construct($roles);

		// If the user has roles, consider it authenticated
		$this->setAuthenticated(count($roles) > 0);
	}

	public function getCredentials()
	{
		return '';
	}
}

8 - Création du Listener

nano src/ApiWsse/MainBundle/Security/Firewall/WsseListener.php

<?php

namespace ApiWsse\MainBundle\Security\Firewall;

use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Event\GetResponseEvent;
use Symfony\Component\Security\Core\Authentication\AuthenticationManagerInterface;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Firewall\ListenerInterface;
use ApiWsse\MainBundle\Security\Authentication\Token\WsseUserToken;

class WsseListener implements ListenerInterface
{
	protected $tokenStorage;
	protected $authenticationManager;

	public function __construct(TokenStorageInterface $tokenStorage, AuthenticationManagerInterface $authenticationManager)
	{
		$this->tokenStorage = $tokenStorage;
		$this->authenticationManager = $authenticationManager;
	}

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

		$wsseRegex = '/UsernameToken Username="([^"]+)", PasswordDigest="([^"]+)", Nonce="([a-zA-Z0-9+\/]+={0,2})", 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->tokenStorage->setToken($authToken);

			return;
		} catch (AuthenticationException $failed) {
			// ... you might log something here

			// To deny the authentication clear the token. This will redirect to the login page.
			// Make sure to only clear your token, not those of other authentication listeners.
			// $token = $this->tokenStorage->getToken();
			// if ($token instanceof WsseUserToken && $this->providerKey === $token->getProviderKey()) {
			//     $this->tokenStorage->setToken(null);
			// }
			// return;
		}

		// By default deny authorization
		$response = new Response();
		$response->setStatusCode(Response::HTTP_FORBIDDEN);
		$event->setResponse($response);
	}
}

9 - Création de l'authentication provider

nano src/ApiWsse/MainBundle/Security/Authentication/Provider/WsseProvider.php

<?php

namespace ApiWsse\MainBundle\Security\Authentication\Provider;

use Symfony\Component\Security\Core\Authentication\Provider\AuthenticationProviderInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\NonceExpiredException;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use ApiWsse\MainBundle\Security\Authentication\Token\WsseUserToken;

class WsseProvider implements AuthenticationProviderInterface
{
	private $userProvider;
	private $cacheDir;

	public function __construct(UserProviderInterface $userProvider, $cacheDir)
	{
		$this->userProvider = $userProvider;
		$this->cacheDir     = $cacheDir;
	}

	public function authenticate(TokenInterface $token)
	{
		$user = $this->userProvider->loadUserByUsername($token->getUsername());

		if ($user && $this->validateDigest($token->digest, $token->nonce, $token->created, $user->getPassword())) {
			$authenticatedToken = new WsseUserToken($user->getRoles());
			$authenticatedToken->setUser($user);

			return $authenticatedToken;
		}

		throw new AuthenticationException('The WSSE authentication failed.');
	}

	/**
	 * This function is specific to Wsse authentication and is only used to help this example
	 *
	 * For more information specific to the logic here, see
	 * https://github.com/symfony/symfony-docs/pull/3134#issuecomment-27699129
	 */
	protected function validateDigest($digest, $nonce, $created, $secret)
	{
		// Check created time is not in the future
		if (strtotime($created) > time()) {
			return false;
		}

		// Expire timestamp after 5 minutes
		if (time() - strtotime($created) > 300) {
			return false;
		}

		// Validate that the nonce is *not* used in the last 5 minutes
		// if it has, this could be a replay attack
		if (
			file_exists($this->cacheDir.'/'.md5($nonce))
			&& file_get_contents($this->cacheDir.'/'.md5($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.'/'.md5($nonce), time());

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

		return hash_equals($expected, $digest);
	}

	public function supports(TokenInterface $token)
	{
		return $token instanceof WsseUserToken;
	}
}

10 - Création de la Factory

nano src/ApiWsse/MainBundle/DependencyInjection/Security/Factory/WsseFactory.php

<?php

namespace ApiWsse\MainBundle\DependencyInjection\Security\Factory;

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\DefinitionDecorator;
use Symfony\Component\Config\Definition\Builder\NodeDefinition;
use Symfony\Bundle\SecurityBundle\DependencyInjection\Security\Factory\SecurityFactoryInterface;

class WsseFactory implements SecurityFactoryInterface
{
	public function create(ContainerBuilder $container, $id, $config, $userProvider, $defaultEntryPoint)
	{
		$providerId = 'security.authentication.provider.wsse.'.$id;
		$container
			->setDefinition($providerId, new DefinitionDecorator('wsse.security.authentication.provider'))
			->replaceArgument(0, new Reference($userProvider))
		;

		$listenerId = 'security.authentication.listener.wsse.'.$id;
		$listener = $container->setDefinition($listenerId, new DefinitionDecorator('wsse.security.authentication.listener'));

		return array($providerId, $listenerId, $defaultEntryPoint);
	}

	public function getPosition()
	{
		return 'pre_auth';
	}

	public function getKey()
	{
		return 'wsse';
	}

	public function addConfiguration(NodeDefinition $node)
	{
	}
}

11 - Configuration de l'AuthenticationProvider

nano app/config/services.yml

# Learn more about services, parameters and containers at
# http://symfony.com/doc/current/book/service_container.html
parameters:
#    parameter_name: value

services:
    wsse.security.authentication.provider:
        class: ApiWsse\MainBundle\Security\Authentication\Provider\WsseProvider
        arguments:
            - '' # User Provider
            - '%kernel.cache_dir%/security/nonces'
        public: false

    wsse.security.authentication.listener:
        class: ApiWsse\MainBundle\Security\Firewall\WsseListener
        arguments: ['@security.token_storage', '@security.authentication.manager']
        public: false

12 - Intégration du contexte de sécurité

nano src/ApiWsse/MainBundle/ApiWsseMainBundle.php

<?php

namespace ApiWsse\MainBundle;

use Symfony\Component\HttpKernel\Bundle\Bundle;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use ApiWsse\MainBundle\DependencyInjection\Security\Factory\WsseFactory;

class ApiWsseMainBundle extends Bundle
{
	public function build( ContainerBuilder $container )
	{
		parent::build( $container );

		$extension = $container->getExtension( 'security' );
		$extension->addSecurityListenerFactory(new WsseFactory());
	}
}

13 - Ajout du firewall wsse dans security.yml

nano app/config/security.yml

security:
    # ...

    firewalls:
        wsse_secured:
            pattern:   ^/api/
            stateless: true
            wsse:      true

14 - Test de l'api

Génération de l'entête http à l'adresse suivante : http://www.teria.com/~koseki/tools/wssegen/

Il va être nécessaire de copier le mot de passe en l'état depuis le champs password de la base de doonnées vers le formulaire du générateur.

Cocher "auto" pour les champs "nonce" et "created".

Cliquer sur "generate" et récupérer la chaine de caractères après "X-WSSE: ".

Tester la requête vers l'api avec la commande curl dans le terminal :

curl -i -H "Accept: application/json" -H 'Authorization: WSSE profile="UsernameToken"' -H 'X-WSSE: UsernameToken Username="admin", PasswordDigest="TwVcnx3P/SiDq1+rd3hjsHY7S1k=", Nonce="OTZ2ZmEzNjk1MtY2e2VRMg==", Created="2016-06-22T13:15:30Z"' http://api-wsse.local/api/users/admin.json

Cela doit nous retourner une instance du User admin.

Sources :