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 :