Sécurisation de l'API 1/2

Jusque-là, les actions disponibles dans notre API sont accessibles pour n’importe quel client. Nous ne disposons d’aucun moyen pour gérer l’identité de ces derniers.

Pour être bref, n’importe qui peut faire n’importe quoi avec notre API.

La sécurité n’est pas un sujet adressé par les concepts REST mais nous pouvons adapter les méthodes d’autorisation et d’authentification classiques aux principes REST.

Il existe beaucoup de techniques et d’outils comme OAuth ou JSON Web Tokens permettant de mettre en place un système d’authentification.

Cependant nous ne nous baserons sur aucun de ces outils et nous allons mettre en place un système d’authentification totalement personnalisé.

Connexion et déconnexion avec une API

Qui dit système d’authentification dit des opérations comme se connecter et se déconnecter.

Comment mettre en place un tel système en se basant sur des concepts REST ?

Pour bien adapter ses opérations, il faut d’abord bien les comprendre.

En général, lorsque nous nous connectons à un site web, nous fournissons un login et un mot de passe via un formulaire de connexion. Si les informations fournies sont valides, le serveur crée un cookie qui permettra d’assurer la gestion de la session. Une fois que nous avons fini de naviguer sur le site, il suffit de nous déconnecter pour que le cookie de session soit supprimé.

Cycle d’authentification

Nous avons donc 3 éléments essentiels pour un tel fonctionnement :

  • une méthode pour se connecter ;
  • une méthode pour se déconnecter ;
  • et une entité pour suivre l’utilisateur pendant sa navigation (le cookie).

En REST toutes nos opérations doivent se faire sur des ressources.

Pour rappel,

Du moment où vous devez interagir avec une entité de votre application, créer une entité, la modifier, la consulter ou que vous devez l’identifier de manière unique alors vous avez pour la plupart des cas une ressource.

Les opérations se font sur le cookie, nous pouvons donc dire qu’il représente notre ressource. Pour le cas d’un site web, l’utilisation d’un cookie est pratique vue que les navigateurs le gèrent nativement (envoie à chaque requête, limitation à un seul domaine pour la sécurité, durée de validité, etc.).

Pour le cas d’une API, il est certes possible d’utiliser un cookie mais il existe une solution équivalente mais plus simple et plus courante: les tokens.

Donc se connecter ou encore se déconnecter se traduisent respectivement par créer un token d’authentification et supprimer son token d’authentification.

Pour chaque requête, le token ainsi crée est rajouté en utilisant une entête HTTP comme pour les cookies.

Commençons d’abord par gérer la création des tokens.

Login et mot de passe pour les utilisateurs

Avant de créer un token, nous devons mettre à jour notre modèle de données. Un utilisateur doit maintenant avoir un mot de passe et son adresse mail sera son login. Pour la gestion de ce mot de passe, nous utiliserons les outils que nous propose Symfony.

Le nouveau modèle utilisateur :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# src/AppBundle/Entity/User.php
<?php
namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;

/**
* @ORM\Entity()
* @ORM\Table(name="users",
*      uniqueConstraints={@ORM\UniqueConstraint(name="users_email_unique",columns={"email"})}
* )
*/
class User
{
    //...

    /**
     * @ORM\Column(type="string")
     */
    protected $password;

    protected $plainPassword;

    // ... tous les getters et setters
}

`

L’attribut plainPassword ne sera pas sauvegardé en base. Il nous permettra de conserver le mot de passe de l’utilisateur en clair à sa création ou modification.

Comme toujours, n’oubliez pas de mettre à jour la base de données :

1
2
3
4
5
6
php bin/console doctrine:schema:update --dump-sql --force

ALTER TABLE users ADD password VARCHAR(255) NOT NULL;

Updating database schema...
Database schema updated successfully! "1" query was executed

La création d’un utilisateur nécessite maintenant un léger travail supplémentaire. À la création, il faudra fournir un mot de passe en claire que nous hasherons avant de le sauvegarder en base. Rajoutons donc les configurations de sécurité de Symfony :

1
2
3
4
5
6
7
8
9
# To get started with security, check out the documentation:
# http://symfony.com/doc/current/book/security.html
security:

    # Ajout d'un encoder pour notre entité USer
    encoders:
        AppBundle\Entity\User:
            algorithm: bcrypt
            cost: 12

Notre entité utilisateur doit implémenter l’interface UserInterface :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
# src/AppBundle/Entity/User.php
<?php
namespace AppBundle\Entity;

use Symfony\Component\Security\Core\User\UserInterface;
use Doctrine\ORM\Mapping as ORM;
use Doctrine\Common\Collections\ArrayCollection;

/**
* @ORM\Entity()
* @ORM\Table(name="users",
*      uniqueConstraints={@ORM\UniqueConstraint(name="users_email_unique",columns={"email"})}
* )
*/
class User implements UserInterface
{
    // ...

    public function getPassword()
    {
        return $this->password;
    }

    public function setPassword($password)
    {
        $this->password = $password;
    }


    public function getRoles()
    {
        return [];
    }

    public function getSalt()
    {
        return null;
    }

    public function getUsername()
    {
        return $this->email;
    }

    public function eraseCredentials()
    {
        // Suppression des données sensibles
        $this->plainPassword = null;
    }
}

Le formulaire de création d’utilisateur et l’action associée dans notre contrôleur vont être adaptés en conséquence :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# src/AppBundle/Form/Type/UserType.php
<?php
namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Form\Extension\Core\Type\EmailType;

class UserType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('firstname');
        $builder->add('lastname');
        $builder->add('plainPassword'); // Rajout du mot de passe 
        $builder->add('email', EmailType::class);
    }

    // ...
}

Pour le mot de passe, nous aurons juste quelques règles de validation basiques :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# src/AppBundle/Resources/config/validation.yml

# ...

AppBundle\Entity\User:
    constraints:
        - Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity: email
    properties:
        firstname:
            - NotBlank: ~
            - Type: string
        lastname:
            - NotBlank: ~
            - Type: string
        email:
            - NotBlank: ~
            - Email: ~
        plainPassword:
            - NotBlank: { groups: [New, FullUpdate] }
            - Type: string
            - Length:
                min: 4
                max: 50
# ...

Le champ plainPassword est un champ un peu spécial. Les groupes nous permettrons d’activer sa contrainte NotBlank lorsque le client voudra créer ou mettre à jour tous les champs de l’utilisateur. Mais lors d’une mise à jour partielle (PATCH), si le champ est nul, il sera tout simplement ignoré.

Le mot de passe ne doit en aucun cas être sérialisé. Il ne doit pas être associé à un groupe de sérialisation.

La création et la modification d’un utilisateur nécessite maintenant un hashage du mot de passe en clair, le service password_encoder de Symfony fait ce travail pour nous en utilisant toutes les configurations que nous venons de mettre en place.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
# src/AppBundle/Controller/UserController.php
<?php
namespace AppBundle\Controller;

use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Method;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use FOS\RestBundle\Controller\Annotations as Rest; // alias pour toutes les annotations
use AppBundle\Form\Type\UserType;
use AppBundle\Entity\User;

class UserController extends Controller
{
    /**
     * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"user"})
     * @Rest\Post("/users")
     */
    public function postUsersAction(Request $request)
    {
        $user = new User();
        $form = $this->createForm(UserType::class, $user, ['validation_groups'=>['Default', 'New']]);

        $form->submit($request->request->all());

        if ($form->isValid()) {
            $encoder = $this->get('security.password_encoder');
            // le mot de passe en claire est encodé avant la sauvegarde
            $encoded = $encoder->encodePassword($user, $user->getPlainPassword());
            $user->setPassword($encoded);

            $em = $this->get('doctrine.orm.entity_manager');
            $em->persist($user);
            $em->flush();
            return $user;
        } else {
            return $form;
        }
    }

     /**
     * @Rest\View(serializerGroups={"user"})
     * @Rest\Put("/users/{id}")
     */
    public function updateUserAction(Request $request)
    {
        return $this->updateUser($request, true);
    }

    /**
     * @Rest\View(serializerGroups={"user"})
     * @Rest\Patch("/users/{id}")
     */
    public function patchUserAction(Request $request)
    {
        return $this->updateUser($request, false);
    }

    private function updateUser(Request $request, $clearMissing)
    {
        $user = $this->get('doctrine.orm.entity_manager')
                ->getRepository('AppBundle:User')
                ->find($request->get('id')); // L'identifiant en tant que paramètre n'est plus nécessaire
        /* @var $user User */

        if (empty($user)) {
            return $this->userNotFound();
        }

        if ($clearMissing) { // Si une mise à jour complète, le mot de passe doit être validé
            $options = ['validation_groups'=>['Default', 'FullUpdate']];
        } else {
            $options = []; // Le groupe de validation par défaut de Symfony est Default
        }

        $form = $this->createForm(UserType::class, $user, $options);

        $form->submit($request->request->all(), $clearMissing);

        if ($form->isValid()) {
            // Si l'utilisateur veut changer son mot de passe
            if (!empty($user->getPlainPassword())) {
                $encoder = $this->get('security.password_encoder');
                $encoded = $encoder->encodePassword($user, $user->getPlainPassword());
                $user->setPassword($encoded);
            }
            $em = $this->get('doctrine.orm.entity_manager');
            $em->merge($user);
            $em->flush();
            return $user;
        } else {
            return $form;
        }
    }

    private function userNotFound()
    {
        return \FOS\RestBundle\View\View::create(['message' => 'User not found'], Response::HTTP_NOT_FOUND);
    }
}

`

Le groupe de validation Default regroupe toutes les contraintes de validation qui ne sont dans aucun groupe. Il est créé automatiquement par Symfony. N’hésitez surtout pas à consulter la documentation pour des informations plus détaillées avant de continuer.

Nous pouvons maintenant tester la création d’un utilisateur en fournissant un mot de passe.

Requête de création d’un utilisateur avec mot de passe

L’utilisateur est créé et la réponse ne contient aucun mot de passe :

1
2
3
4
5
6
7
{
  "id": 5,
  "firstname": "test",
  "lastname": "Pass",
  "email": "test@pass.fr",
  // ...
}

Toutes les modifications effectuées ici sont propres à Symfony. Si vous avez du mal à suivre, il est vivement (grandement) conseillé de consulter la documentation officielle du framework.

Création d'un token

Revenons maintenant à notre système d’authentification avec des tokens. Un token aura les caractéristiques suivantes :

  • une valeur : une suite de chaînes de caractères générées aléatoirement et unique ;
  • une date de création : la date à la quelle le token a été créé. Cette date nous permettra plus tard de vérifier l’âge du token et donc sa validité du token ;
  • un utilisateur : une référence vers l’utilisateur qui a demandé la création de ce token. Comme pour toute ressource, nous avons besoin d’une URL pour l’identifier. Nous utiliserons rest-api.local/auth-tokens, auth-tokens étant juste le dimunitif de authentication tokens i.e les tokens d’authentification.

Contrairement aux autres ressources, la requête de création d’un token est légérement différente. Le payload contiendra le login et le mot de passe de l’utilisateur et les informations qui décrivent le token seront générées par le serveur.

La réponse contiendra donc les informations ainsi créées.

Cinématique de création de token

L’implémentation va donc ressembler à tout ce que nous avons déjà fait.

Commençons par l’entité AuthToken :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
# src/AppBundle/Entity/AuthToken.php
<?php
namespace AppBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
 * @ORM\Entity()
 * @ORM\Table(name="auth_tokens",
 *      uniqueConstraints={@ORM\UniqueConstraint(name="auth_tokens_value_unique", columns={"value"})}
 * )
 */
class AuthToken
{
    /**
     * @ORM\Id
     * @ORM\Column(type="integer")
     * @ORM\GeneratedValue
     */
    protected $id;

    /**
     * @ORM\Column(type="string")
     */
    protected $value;

    /**
     * @ORM\Column(type="datetime")
     * @var \DateTime
     */
    protected $createdAt;

    /**
     * @ORM\ManyToOne(targetEntity="User")
     * @var User
     */
    protected $user;


    public function getId()
    {
        return $this->id;
    }

    public function setId($id)
    {
        $this->id = $id;
    }

    public function getValue()
    {
        return $this->value;
    }

    public function setValue($value)
    {
        $this->value = $value;
    }

    public function getCreatedAt()
    {
        return $this->createdAt;
    }

    public function setCreatedAt(\DateTime $createdAt)
    {
        $this->createdAt = $createdAt;
    }

    public function getUser()
    {
        return $this->user;
    }

    public function setUser(User $user)
    {
        $this->user = $user;
    }
}

La mise à jour de la base de données avec Doctrine :

1
2
3
4
5
6
php bin/console doctrine:schema:update --dump-sql --force
#> CREATE TABLE auth_tokens (id INT AUTO_INCREMENT NOT NULL, user_id INT DEFAULT NULL, value VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_8AF9B66CA76ED395 (user_id), UNIQUE INDEX auth_tokens_value_unique (value), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE = InnoDB;
#> ALTER TABLE auth_tokens ADD CONSTRAINT FK_8AF9B66CA76ED395 FOREIGN KEY (user_id) REFERENCES users (id);

#> Updating database schema...
#> Database schema updated successfully! "2" queries were executed

Pour la gestion du login et du mot de passe de l’utilisateur, nous allons créer :

  • une entité nommée Credentials avec deux attributs : login et password. Cette entité n’aura aucune annotation Doctrine, elle pemettra juste de transporter ces informations ;
  • un formulaire nommé CredentialsType pour valider que les champs de l’entité Credentials ne sont pas vides (Not-Blank).

L’entité ressemble donc à :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# src/AppBundle/Entity/Credentials.php
<?php
namespace AppBundle\Entity;

class Credentials
{
    protected $login;

    protected $password;

    public function getLogin()
    {
        return $this->login;
    }

    public function setLogin($login)
    {
        $this->login = $login;
    }

    public function getPassword()
    {
        return $this->password;
    }

    public function setPassword($password)
    {
        $this->password = $password;
    }
}

Le formulaire et les règles de validation associées :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# src/AppBundle/Form/Type/CredentialsType.php
<?php
namespace AppBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;

class CredentialsType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('login');
        $builder->add('password');
    }

    public function configureOptions(OptionsResolver $resolver)
    {
        $resolver->setDefaults([
            'data_class' => 'AppBundle\Entity\Credentials',
            'csrf_protection' => false
        ]);
    }
}

`
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# src/AppBundle/Resources/config/validation.yml

# ...

AppBundle\Entity\Credentials:
    properties:
        login:
            - NotBlank: ~
            - Type: string
        password:
            - NotBlank: ~
            - Type: string

Il ne faut pas oublier de configurer le sérialiseur pour afficher le token en utilisant un groupe prédéfini.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# src/AppBundle/Resources/config/serialization.yml
# ...

AppBundle\Entity\User:
    attributes:
        id:
            groups: ['user', 'preference', 'auth-token']
        firstname:
            groups: ['user', 'preference', 'auth-token']
        lastname:
            groups: ['user', 'preference', 'auth-token']
        email:
            groups: ['user', 'preference', 'auth-token']
        preferences:
            groups: ['user']

AppBundle\Entity\AuthToken:
    attributes:
        id:
            groups: ['auth-token']
        value:
            groups: ['auth-token']
        createdAt:
            groups: ['auth-token']
        user:
            groups: ['auth-token']

`

Maintenant, il ne reste plus qu’à créer le contrôleur qui assure la gestion des tokens d’authentification.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
# src/AppBundle/Controller/AuthTokenController.php
<?php
namespace AppBundle\Controller;

use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Request;
use FOS\RestBundle\Controller\Annotations as Rest; // alias pour toutes les annotations
use AppBundle\Form\Type\CredentialsType;
use AppBundle\Entity\AuthToken;
use AppBundle\Entity\Credentials;

class AuthTokenController extends Controller
{
    /**
     * @Rest\View(statusCode=Response::HTTP_CREATED, serializerGroups={"auth-token"})
     * @Rest\Post("/auth-tokens")
     */
    public function postAuthTokensAction(Request $request)
    {
        $credentials = new Credentials();
        $form = $this->createForm(CredentialsType::class, $credentials);

        $form->submit($request->request->all());

        if (!$form->isValid()) {
            return $form;
        }

        $em = $this->get('doctrine.orm.entity_manager');

        $user = $em->getRepository('AppBundle:User')
            ->findOneByEmail($credentials->getLogin());

        if (!$user) { // L'utilisateur n'existe pas
            return $this->invalidCredentials();
        }

        $encoder = $this->get('security.password_encoder');
        $isPasswordValid = $encoder->isPasswordValid($user, $credentials->getPassword());

        if (!$isPasswordValid) { // Le mot de passe n'est pas correct
            return $this->invalidCredentials();
        }

        $authToken = new AuthToken();
        $authToken->setValue(base64_encode(random_bytes(50)));
        $authToken->setCreatedAt(new \DateTime('now'));
        $authToken->setUser($user);

        $em->persist($authToken);
        $em->flush();

        return $authToken;
    }

    private function invalidCredentials()
    {
        return \FOS\RestBundle\View\View::create(['message' => 'Invalid credentials'], Response::HTTP_BAD_REQUEST);
    }
}

`

N’oublions pas de déclarer le nouveau contrôleur.

1
2
3
4
5
6
7
8
# app/config/routing.yml
# ...

auth-tokens:
    type:     rest
    resource: AppBundle\Controller\AuthTokenController

`

Pour des raisons de sécurité, nous évitons de donner des détails sur les comptes existants, un même message - Invalid Credentials - est renvoyé lorsque le login n’existe pas ou lorsque le mot de passe n’est pas correct.

Nous pouvons maintenant créer un token en utilisant le compte test@pass.fr créé plus tôt.

1
2
3
4
{
    "login": "test@pass.fr",
    "password": "test"
}
Requête de création d’un token d’authentification avec Postman

La réponse contient un token que nous pourrons exploiter plus tard pour décliner notre identité.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "id": 3,
  "value": "MVgq3dT8QyWv3t+s7DLyvsquVbu+mOSPMdYX7VUQOEQcJGwaGD8ETa+zi9ReHPWYFKI=",
  "createdAt": "2016-04-08T17:49:00+00:00",
  "user": {
    "id": 5,
    "firstname": "test",
    "lastname": "Pass",
    "email": "test@pass.fr"
  }
}

Nous disposons maintenant d’un système fonctionnel pour générer des tokens pour les utilisateurs. Ces tokens nous permettrons par la suite de vérifier l’identité des clients de l’API afin de la sécuriser.