Guillaume Rivière 2019 – 2024

Le logo de la CCI Bayonne Pays Basque

Développer des applications full web : devenir développeur full-stack

TP3 : Dialogue front-end/back-end avec une API REST

BUTS PÉDAGOGIQUES

  • Jonction front-end et back-end avec un webservice d'API REST
  • Authentification des utilisateurs par formulaire
  • Restriction des accès à l'API par authentification
  • Mise en page responsive pour une application web
  • Déploiement par conteneur

Dans ce sujet de TP, nous finalisons notre application web et la préparons pour son déploiement.

Exercice 1 • Consommer l'API depuis le front-end Vue.js

Modifiez l'URL d'API dans le front-end Vue.js pour recevoir la liste des oiseaux. Vérifiez que cela fonctionne : adaptez le code si nécessaire.

Faîtes en sorte que les compteurs mettent à jour le champ nbr dans la BDD.

Créer un formulaire VueJS pour ajouter de nouveaux oiseaux dans la BDD.

Exercice 2 • Authentification des utilisateurs

Nous voulons maintenant que les utilisateurs de l'application soient identifiés. Ils doivent pouvoir créer un compte, se connecter et se déconnecter. Dès lors, le formulaire de comptage des oiseaux sera uniquement accessible aux utilisateurs connectés. De plus, l'accès à l'API sera également restreint par authentification.

2.1 Back-end

Tout d'abord, ajoutez de nouveaux composants dans le projet :

2.1.1 Une entité pour les informations des utilisateurs

  1. Créons une nouvelle entité User :
    • php bin/console make:user
      • À chaque question, appuyez sur la touche [Entrée] pour prendre le choix par défaut.
  2. Ajoutons deux autres champs à cette nouvelle entité User :
    • php bin/console make:entity
      • Première question : indiquez User comme nom de classe.
      • Questions suivantes : créez les deux champs firstname et lastname de type string.
  3. Faîte la migration vers la BDD pour que la table soit créée en accord avec le schéma décrit par l'entité :
    • php bin/console make:migration
    • php bin/console doctrine:migrations:migrate

2.1.2 Un nouveau controlleur pour gérer les authentifications

  1. Générez un nouveau contrôleur appelé AuthController :
    • php bin/console make:controller AuthController
  2. Dans le fichier de configuration config/packages/security.yaml, changez l'algorithme de crytpage de auto à bcrypt :
    security:
        # ...
        password_hashers:
            # ...
            App\Entity\User:
                algorithm: bcrypt
  3. Dans le fichier du nouveau controlleur src/Controller/AuthController.php :
    1. Ajoutez les importations suivantes :
      use Symfony\Component\HttpFoundation\Request;
      use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
      use Firebase\JWT\JWT;
      use App\Repository\UserRepository;
      use App\Entity\User;
    2. Effacez la méthode entièrement index() : y compris son code et y compris le commentaire qui la précède.
    3. Ajoutez la nouvelle méthode qui permettra d'inscrire de nouveaux utilisateurs dans la base de données de l'application :
          /**
           * @Route("/auth/register", name="register", methods={"POST"})
           */
          public function register(Request $request, UserPasswordEncoderInterface $encoder)
          {
              $password = $request->get('password');
              $email = $request->get('email');
              $firstname = $request->get('firstname');
              $lastname = $request->get('lastname');
              $user = new User();
              $user->setPassword($encoder->encodePassword($user, $password));
              $user->setEmail($email);
              $user->setFirstname($firstname);
              $user->setLastname($lastname);
              $em = $this->getDoctrine()->getManager();
              $em->persist($user);
              $em->flush();
              return $this->json([
                  'user' => $user->getEmail()
              ]);
          }
    4. Ajoutez la nouvelle méthode qui permettra de vérifier si le mot de passe d'un utilisateur est correct lors d'une tentative de connexion  :
          /**
           * @Route("/auth/login", name="login", methods={"POST"})
           */
          public function login(Request $request, UserRepository $userRepository, UserPasswordEncoderInterface $encoder)
          {
              $user = $userRepository->findOneBy([
                      'email'=>$request->get('email'),
              ]);
              if (!$user || !$encoder->isPasswordValid($user, $request->get('password'))) {
                      return $this->json([
                          'auth' => '0',
                      ]);
              }
             $payload = [
                 "user" => $user->getUsername(),
                 "exp"  => (new \DateTime())->modify("+60 minutes")->getTimestamp(),
             ];
       
              $jwt = JWT::encode($payload, $this->getParameter('jwt_secret'), 'HS256');
              return $this->json([
                  'auth' => '1',
                  'user' => [
                    'username' => $user->getUsername(),
                    'firstname' => $user->getFirstName(),
                    'lastname' => $user->getLastName(),
                  ],
                  'token' => sprintf('Bearer %s', $jwt),
              ]);
          }
  4. Dans le fichier de configuration config/services.yaml, ajoutez la phrase secrète utilisée dans la fonction ci-dessus pour l'algorithme de crytpage :
    parameters:
        jwt_secret: ESTIA_SECRET_YOU_WONT_GUESS

2.1.3 Test du nouveau service d'authentification

Pour tester que le service d'authentification fonctionne correctement, nous allons envoyer des requêtes post grâce à la commande curl.

  1. Tout d'abord, créons un nouvel utilisateur :
    • curl -L -X POST 'http://127.0.0.1:8000/auth/register' -F 'email=jesuis.moi@net.estia.fr' -F 'password=1234' -F 'firstname=Jesuis' -F 'lastname=Moi'
  2. Vérifiez via phpMyAdmin que l'utilisateur a été correctement inséré dans la base de données.
  3. Testez maintenant la réponse du contolleur d'authentification pour une tentative de connexion correcte :
    • curl -L -X POST 'http://127.0.0.1:8000/auth/login' -F 'email=jesuis.moi@net.estia.fr' -F 'password=1234'
  4. Puis pour une tentative de connexion incorrecte :
    • curl -L -X POST 'http://127.0.0.1:8000/auth/login' -F 'email=jesuis.moi@net.estia.fr' -F 'password=5678'

2.1.4 Vérification d'authentification par jeton

Une fois l'utilisateur de l'application connecté, il faudra pouvoir vérifier l'autenticité de toute nouvelle demande venant de l'application : une authentification par jeton est alors utilisée.

  1. Créer le fichier src/Security/JwtAuthenticator.php contenant la déclaration de classe suivante :
    <?php
     
    namespace App\Security;
     
    use Doctrine\ORM\EntityManagerInterface;
    use Symfony\Component\DependencyInjection\ParameterBag\ContainerBagInterface;
    use Symfony\Component\HttpFoundation\Request;
    use Symfony\Component\HttpFoundation\Response;
    use Symfony\Component\HttpFoundation\JsonResponse;
    use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
    use Symfony\Component\Security\Core\Exception\AuthenticationException;
    use Symfony\Component\Security\Core\User\UserInterface;
    use Symfony\Component\Security\Core\User\UserProviderInterface;
    use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;
    use Firebase\JWT\JWT;
    use Firebase\JWT\Key;
    use App\Entity\User;
     
     
    class JwtAuthenticator extends AbstractGuardAuthenticator
    {
        private $em;
        private $params;
     
        public function __construct(EntityManagerInterface $em, ContainerBagInterface $params)
        {
            $this->em = $em;
            $this->params = $params;
        }
     
        public function start(Request $request, AuthenticationException $authException = null)
        {
            $data = [ 
                'message' => 'Authentication Required' 
            ];
            return new JsonResponse($data, Response::HTTP_UNAUTHORIZED);
        }
     
        public function supports(Request $request)
        {
            return $request->headers->has('Authorization');
        }
     
        public function getCredentials(Request $request)
        {
            return $request->headers->get('Authorization');
        }
     
        public function getUser($credentials, UserProviderInterface $userProvider)
        {
            try {
                $credentials = str_replace('Bearer ', '', $credentials);
                $jwt = (array) JWT::decode(
                    $credentials,
                    new Key($this->params->get('jwt_secret'), 'HS256')
                );
                return $this->em->getRepository(User::class)
                                ->findOneBy([
                                    'email' => $jwt['user'],
                                ]);
            } catch (\Exception $exception) {
                throw new AuthenticationException($exception->getMessage());
            }
        }
     
        public function checkCredentials($credentials, UserInterface $user)
        {
            return true;
        }
     
        public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
        {
            return new JsonResponse([
                'auth' => '0',
                'message' => $exception->getMessage()
            ], Response::HTTP_UNAUTHORIZED); 
        }
     
        public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $providerKey)
        {
            return;
        }
     
        public function supportsRememberMe()
        {
            return false; 
        }
    }
  2. Complétez les sections main et access_control du fichier config/packages/security.html en ajoutant la déclaration suivante :
    security:
        # ...
        firewalls:
            dev:
                # ...
            api:
                pattern: ^/api
                guard:
                    authenticators:
                        - App\Security\JwtAuthenticator
            main:
                # ...
        # ...
        access_control:
            - { path: ^/api, roles: ROLE_USER }
  3. Créez un contrôleur de test :
    • php bin/console make:controller ApiController
  4. Ajoutez-lui la méthode suivante :
        /**
         * @Route("/api/test", name="testapi")
         */
        public function test()
        {
              return $this->json([
                      'message' => 'Happy Controller!',
               ]);
        }
  5. Testez un accès direct avec curl, puis un accès en remplaçant par un jeton obtenu lors d'un login récent :
    • curl -L -X GET 'http://127.0.0.1:8000/api/test'
    • curl -L -X GET 'http://127.0.0.1:8000/api/test' -H 'Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoidGVzdEBleGFtcGxlLmNvbSIsImV4cCI6MTY0ODI5MzAyM30.FOrNyl1MS91ZcYrtSts1CmJZQJFh8XwUcNFsU3HI40U'

2.2 Front-end

Nous allons maintenant modifier l'application pour lui ajouter un formulaire d'enregistrement, un formulaire de connexion, une vérification de l'accès au formulaire de comptage, et un bouton de déconnexion.

Tout d'abord, nous allons avoir besoin de stocker des informations :

  1. Créez un nouveau fichier index.js dans le répertoire « src/store/ » et copiez-y le code suivant :
    import Vue from 'vue';
    import Vuex from 'vuex';
     
    Vue.use(Vuex);
     
    export default new Vuex.Store({
      state: {
        user: null,
        token: null,
      },
      mutations: {
        setUser(state, user) {
          state.user = user;
        },
        setToken(state, token) {
          state.token = token;
        },
      },
      actions: {
        logout(state) {
          state.user = null;
          state.token = null;
        }
      },
      getters: {
        isLoggedIn(state) {
          return !!state.token;
        },
        getUser(state) {
          return state.user;
        }
      },
    });
  2. Dans le fichier main.js :
    1. Importez le store :
      import store from '@/store/';
    2. Puis, ajoutez-le dans la création de la vue avant la ligne render: ... :
      store,

2.2.1 Connexion via un formulaire

  1. Créez un nouveau fichier AppLogin.vue dans le répertoire « src/components/ » et copiez-y le formulaire et le script suivant :
    <template>
      <div>
        <h1>LOGIN</h1>
        <form @submit.prevent="login">
          <p><input v-model="email" placeholder="email" /></p>
          <p><input v-model="password" placeholder="password" type="password" /></p>
          <p><button type="submit">Login</button></p>
        </form>
      </div>
    </template>
     
    <script>
    import { mapMutations } from 'vuex';
    export default {
      data: () => {
        return {
          email: "",
          password: "",
        };
      },
      methods: {
        ...mapMutations(['setUser', 'setToken']),
        async login(e) {
          e.preventDefault();
          console.log('Login...');
        },
      },
    };
    </script>
  2. Ajoutez une route vers cette nouvelle page :
      {
        path: "/login",
        name: "AppLogin",
        component: require('@/components/AppLogin.vue').default
      },
  3. Testez le formulaire via l'adresse http://127.0.0.1:8080/login en vérifiant le message produit dans la console lors de la validation.
  4. Complétez maintenant le code de la fonction login() de sorte à :
    • Interroger le back-end en lui envoyant les identifiants de connexion via une requête post de type multipart/form-data et traiter le résultat en fonction que l'utlisateur a été authentifié ou non.
    • Dans le cas où l'authentification réussi, mémorisez l'utilisateur puis redirigez vers la page principale :
      this.setUser(user);
      this.setToken(token);
      this.$router.push('/');
    • Dans le cas où l'authentification échoue, affichez un message d'information dans un paragraphe que vous ajouterez en dessous du formulaire.

2.2.2 Restriction de l'accès aux pages

  1. Importez le store dans le fichier src/router/index.js :
    import store from '@/store/';
  2. Puis définissez les routes nécessistant d'être authentifié en ajoutant :
        meta: { requiresAuth: true},
  3. Enfin, ajoutez le code suivant après la création du router :
    router.beforeEach((to, from, next) => {
      if (to.matched.some(record => record.meta.requiresAuth)) {
        if (store.getters['isLoggedIn'] !== false) {
          return next()
        } else {
          return next({
            path: '/login',
            query: { redirect: to.fullPath }
          })
        }
      } else {
        return next()
      }
    })
  4. Vérifiez que l'accès aux pages nécessite désormais d'être authentifié, et que sinon une redirection vers le formulaire de connexion est bien en place.

2.2.3 Utilisation des autorisations

Ajouter le token en autorisation des entêtes de vos requêtes post pour pouvoir continuer d'accéder aux ressouces du back-end (par exemple, pour la liste des oiseaux de la page 4).

2.2.4 Déconnexion via un bouton

Aggrémentez vos pages avec des boutons de connexion et de déconnexion, par exemple :

<template>
  <div>
    <button v-if="isLoggedIn" v-on:click="logout()">Logout</button>
    <router-link v-if="!isLoggedIn" to="/login"><button>Login</button></router-link>
  </div>
</template>
 
<script>
import { mapGetters } from 'vuex';
export default {
  computed: {
    ...mapGetters(['isLoggedIn'])
  },
  methods: {
    logout: function () {
      this.$store._actions['logout'];
      this.$router.push('/login');
    }
  },
};
</script>

2.2.5 Enregistrement via un formulaire

En prenant le formulaire de connexion en exemple, créer un formulaire d'enregistrement pour de nouveaux utilisateurs de l'application.

Exercice 3 • Application web responsive

Rendez votre application VueJS responsive pour de petites tailles d'écran :

  1. Dans le fichier main.js, ajoutez les deux importations suivantes :
    import 'materialize-css/dist/css/materialize.min.css'
    import 'material-design-icons/iconfont/material-icons.css'
  2. Dans le fichier App.vue, ajoutez la section de script suivante  :
    <script>
    import M from 'materialize-css'
    export default {
      mounted () {
        M.AutoInit()
      }
    }
    </script>
  3. Diminuez la taille de votre navigateur web et consultez à nouveau les pages de votre application.

Exercice 4 • Emballer dans des conteneurs Docker

Nous allons maintenant exécuter les deux projets depuis des conteneurs Docker.

Tout d'abord, commencez par stopper les serveurs de « hello-vue » et de « hello-symfony » qui sont en cours d'exécution.

4.1 Back-end

  1. Créez un nouveau fichier Dockerfile dans le répertoire du projet et copiez-y les appels suivants :
    FROM php:8.0-fpm
     
    RUN apt update \
        && apt install -y zlib1g-dev g++ git libicu-dev zip libzip-dev zip \
        && docker-php-ext-install intl opcache pdo pdo_mysql \
        && pecl install apcu \
        && docker-php-ext-enable apcu \
        && docker-php-ext-configure zip \
        && docker-php-ext-install zip
     
    WORKDIR /var/www/html/
     
    RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
     
    RUN curl -sS https://get.symfony.com/cli/installer | bash
    RUN mv /root/.symfony*/bin/symfony /usr/local/bin/symfony
     
    EXPOSE 8000
     
    CMD php bin/console server:run 0.0.0.0:8000
  2. Créez un nouveau fichier docker-compose.yml dans le répertoire du projet et copiez-y la structure suivante :
    version: "3"
     
    services:
      database:
        container_name: db_docker_symfony
        image: mysql:8.0
        command: --default-authentication-plugin=mysql_native_password
        environment:
          MYSQL_ROOT_PASSWORD: estia
          MYSQL_DATABASE: db_poitou_birds
          MYSQL_USER: estia
          MYSQL_PASSWORD: estia
        ports:
          - '4306:3306'
        volumes:
          - ../mysql:/var/lib/mysql
      php:
        container_name: php_docker_symfony
        working_dir: /var/www/html
        build:
          context: .
        ports:
          - '8000:8000'
        volumes:
          - /home/estia/devel/hello-symfony/:/var/www/html
        depends_on:
          - database
  3. Dans le fichier .env, modifiez la variable de connexion à la base de données :
    DATABASE_URL="mysql://root:estia@database:3306/db_poitou_birds?serverVersion=8.0"
  4. Préparez les images dockers puis démarrez le service :
    • docker-compose build
      • Patientez... le chargement des images peut prendre environ 10 minutes.
    • docker-compose up
  5. Vérifiez que le service est en marche :
    • docker ps
    • Accédez au serveur Symfony s'exécutant depuis Docker : http://127.0.0.1:8000

4.2 Front-end

  1. Créez un nouveau fichier Dockerfile dans le répertoire du projet et copiez-y les appels suivants :
    FROM node:lts-alpine
    # install simple http server for serving static content
    RUN npm install -g http-server
    # make the 'app' folder the current working directory
    WORKDIR /app
    # copy 'package.json' to install dependencies
    COPY package*.json ./
    # install dependencies
    RUN npm install
    # copy files and folders to the current working directory (i.e. 'app' folder)
    COPY . .
    # build app for production with minification
    RUN npm run build
    EXPOSE 8080
    CMD [ 'http-server', 'dist' ]
  2. Créez un nouveau fichier docker-compose.yml dans le répertoire du projet et copiez-y la structure suivante :
    version: "3"
     
    services:
      vue-app:
        working_dir: /app
        build:
          context: .
        container_name: app_docker_vue
        restart: always
        ports:
          - '8080:8080'
        volumes:
          - /home/estia/devel/hello-vue:/app
        networks:
          - vue-network
        command: >
          sh -c 'npm run serve'
    networks:
      vue-network:
        driver: bridge
  3. Préparez les images dockers puis démarrez le service :
    • docker-compose build
      • Patientez... car le chargement des images peut prendre une dizaine de minutes.
    • docker-compose up
  4. Vérifiez que le service est en marche :
    • docker ps
    • Accédez à l'application VueJS s'exécutant depuis Docker : http://127.0.0.1:8080