Une bibliothèque pour gérer l'authentification avec Vue.js

Publié le 17 janvier 2019 par Tom Panier | front

Cet article est publié sous licence CC BY-NC-SA

Brrr ! En ces jours froids, quoi de mieux pour remettre un peu de chaleur dans nos cœurs qu’une étude de cas sur la mise en place d’une bibliothèque destinée à gérer l’authentification sur vos applications front-end ? Bon, OK, il y a des solutions plus efficaces, mais avant de ronchonner, sachez que je ne me moque pas de vous en vous mitonnant ça : l’article sera en deux parties, on ira de l’initialisation du projet à sa publication sur NPM, et on va même refaire du Vue.js — ne me mentez pas, ça vous a manqué.

Avant d’entrer dans le vif du sujet, sachez également que la bibliothèque que nous allons réaliser ensemble est directement inspirée d’un projet du monde réel, le vrai, celui avec des clients dedans, et qu’en l’occurrence ce projet est utilisé en production sur un nombre non négligeable d’applications, dans le cadre de la refonte d’un SI complet.

L’objectif de la fameuse bibliothèque est simple, en tout cas sur le papier : tant qu’on n’a pas un utilisateur authentifié, on n’effectue pas le rendu de l’application et on affiche un formulaire de connexion à la place. Voyons donc ce que ça donne en pratique !

Au commencement, Dieu lança vue create

Nous allons appeler notre bibliothèque auth-helper ; afin de faciliter le développement, nous allons carrément créer une application Vue.js et faire le nécessaire dedans, on fera le ménage plus tard même si on n’est pas dimanche !

$ vue create auth-helper

Choisissez ensuite « Manually select features » : on va se contenter du minimum, à savoir « Babel » et « Unit testing » — concernant ce dernier point, choisissez « Jest » comme test runner. Personnellement, j’aime placer la configuration des différents outils périphériques « In dedicated config files », mais c’est comme vous voulez !

Une fois le projet initialisé et les dépendances installées, placez-vous dans son dossier :

$ cd auth-helper

Il y a quelques détails dans la façon dont l’app a été initialisée sur lesquels j’aimerais revenir ; je vous les détaille ici, libre à vous de faire de même ou non, mais sachez que la suite de l’article partira du principe que c’est le cas ;)

  • Ajouter à package.json un script start servant d’alias à npm run serve ;
  • Toujours dans package.json, renommer le script test:unit en test ;
  • Placer le composant App.vue dans… le dossier des composants (en n’oubliant pas d’impacter les chemins idoines dans le fichier en question, ainsi que dans src/main.js) :
$ mv src/App.vue src/components/App.vue
  • Renommer le dossier tests en test, et son sous-dossier unit en specs (là aussi, en corrigeant le chemin dans la propriété testMatch de jest.config.js) :
$ mv tests test && mv test/unit test/specs

Vérifions que tout fonctionne bien :

$ npm test

Error: no test specified

$ npm start

 DONE  Compiled successfully in 3414ms

Vous noterez que malgré ma maniaquerie notoire, je n’ai pas sélectionné ESLint lors du choix des fonctionnalités annexes du projet. Je préfère en effet le mettre en place moi-même et ai mon propre fichier de règles, et ce n’est de toute façon pas le sujet de cet article. Ne vous étonnez donc pas si vous constatez de subtiles différences entre le code généré et celui de mes exemples, je vous garantis qu’ils sont strictement équivalents.

Implémentation naïve

Commençons par le commencement, à savoir l’implémentation de notre composant LoginForm :

<template>
  <form @submit.prevent="submit()">
    <input type="email" v-model="email" />
    <input type="password" v-model="password" />
    <button type="submit">Log in</button>
  </form>
</template>

<script>
export default {
  data() {
    return {
      email: "",
      password: ""
    };
  },

  methods: {
    submit() {
      this.$emit("submit", {
        email: this.email,
        password: this.password
      });
    }
  }
};
</script>

Rien de particulièrement révolutionnaire, vous en conviendrez ! Occupons-nous maintenant de notre composant central, celui qui choisira d’afficher, selon les cas, ce formulaire ou notre App. Nous le nommerons AuthWrapper :

<template>
  <div>
    <App v-if="user" />
    <LoginForm v-else @submit="login" />
  </div>
</template>

<script>
import App from "./App";
import LoginForm from "./LoginForm";

export default {
  data() {
    return {
      user: null
    };
  },

  methods: {
    login({ email, password }) {
      this.user = { email, password };
    }
  },

  components: { App, LoginForm }
};
</script>

Pour l’instant, pas de chichis, on range dans this.user les identifiants tels qu’on les a reçus !

Il ne nous reste plus qu’à modifier src/main.js pour l’utiliser :

import Vue from "vue";
import AuthWrapper from "./components/AuthWrapper";

new Vue({
  render: h => h(AuthWrapper)
}).$mount("#app");

Formulaire de connexion

Ce qui nous donne, une fois connecté :

Une fois connecté, l'application

Le comportement constaté est bien celui attendu : se connecter fait apparaître l’application en lieu et place du formulaire. Bien évidemment, cette première implémentation n’est pas très représentative de la réalité, puisqu’on n’y fait même pas usage d’authentification à proprement parler. Remédions-y !

Authentification et service dédié

Afin d’implémenter proprement un « vrai » processus d’authentification, nous allons créer un service dans un fichier dédié src/authentication.js : si nous allons, dans un premier temps, faire là encore simple en utilisant une collection d’utilisateurs « en dur », cela nous permettra de remplacer cette logique plus facilement plus tard, afin d’interagir avec un serveur HTTP, par exemple.

const users = [
  {
    email: "jim@example.com",
    password: "j1m"
  },
  {
    email: "bob@example.com",
    password: "b0b"
  }
];

/**
 * @param {String} email
 * @param {String} password
 *
 * @return {?Object}
 */
function login(email, password) {
  return users.find(user => user.email === email && user.password === password) || null;
}

export { login };

Notez également que nos objets utilisateurs pourraient contenir d’autres infos (autorisations, etc.) sans que cela nécessite de toucher au code.

Modifions ensuite src/components/AuthWrapper.vue afin de faire usage de ce service, et de gérer l’éventuel cas d’erreur :

<template>
  <div>
    <App v-if="user" />
    <LoginForm v-else :errorMessage="errorMessage" @submit="login" />
  </div>
</template>

<script>
import { login } from "../authentication";
import App from "./App";
import LoginForm from "./LoginForm";

export default {
  data() {
    return {
      user: null,
      errorMessage: ""
    };
  },

  methods: {
    login({ email, password }) {
      this.user = login(email, password);
      this.errorMessage = this.user ? "" : "Authentication failed, please try again";
    }
  },

  components: { App, LoginForm }
};
</script>

Nous devons également ajouter au composant LoginForm le support de sa nouvelle prop errorMessage :

<template>
  <form @submit.prevent="submit()">
    <p v-if="errorMessage" class="error"></p>
    <input type="email" v-model="email" />
    <input type="password" v-model="password" />
    <button type="submit">Log in</button>
  </form>
</template>

<script>
export default {
  props: {
    errorMessage: {
      type: String,
      default: ""
    }
  },

  /* ... */
};
</script>

<style scoped>
.error { color: tomato; }
</style>

Erreur d'authentification

Ça marche ! Nous allons donc pérenniser ce que nous avons écrit jusqu’ici en ajoutant quelques tests unitaires. Grâce au fait que la stack nécessaire a été installée via Vue CLI, nous allons pouvoir nous appuyer sur la présence de vue-test-utils qui, par opposition à la mise en place « manuelle » de Jest telle que je l’ai décrite dans mon article sur le sujet, va nous permettre d’utiliser une syntaxe plus concise dans la rédaction de nos tests.

Il est important, à ce stade, de prendre un peu de recul pour bien définir le périmètre de ce que nous voulons tester :

  • Nous n’écrirons pas de tests pour AuthWrapper ni LoginForm, car ceux-ci ne portent aucune logique complexe intrinsèque, et nous ne parviendrions à rien d’autre qu’à tester le framework lui-même ;
  • En revanche, nous allons tester notre service authentication : son implémentation est sujette à évolution, mais le service rendu doit rester le même, et l’intégralité du fonctionnement actuel de notre bibliothèque repose concrètement sur lui.

Supprimons donc le test d’exemple :

$ rm test/specs/example.spec.js

Puis créons test/specs/authentication.spec.js et lançons-nous :

import { login } from "../../src/authentication";

describe("authentication", () => {
  describe("login", () => {
    it("returns the logged-in user object upon success", () => {
      expect(login("jim@example.com", "j1m")).toEqual({
        email: "jim@example.com",
        password: "j1m"
      });
    });

    it("returns null upon failure", () => {
      expect(login("nobody@example.com", "nobody")).toBeNull();
    });
  });
});
$ npm test

 PASS  test/specs/authentication.spec.js
  authentication
    login
      ✓ returns the logged-in user object upon success (15ms)
      ✓ returns undefined upon failure (6ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        5.655s
Ran all test suites.

Parfait ! Maintenant que notre processus d’authentification fonctionne de manière basique, tâchons de le rendre un peu plus modulable et donc pérenne.

Refactoring de l’authentification

Je ne sais pas pour vous, mais en écrivant le test ci-dessus, j’ai trouvé étrange de voir le mot de passe faire partie de l’objet renvoyé par le service authentication. Il serait sûrement pertinent de faire la distinction entre l’utilisateur « canonique », stocké pour l’instant en dur dans le service susmentionné, et la représentation que ce dernier nous retourne en cas de connexion réussie. Nous allons confier cette mission à un nouveau service, j’ai nommé formatUser :

/**
 * @param {Object} user
 *
 * @return {String}
 */
function getDisplayName(user) {
  if (user.name) {
    return user.name;
  }

  if (user.firstName && user.lastName) {
    return user.firstName + " " + user.lastName;
  }

  return user.email;
}

/**
 * @param {Object} user
 *
 * @return {Object}
 */
export default function formatUser(user) {
  return {
    email: user.email,
    displayName: getDisplayName(user)
  };
}

Nous prenons d’ores et déjà en considération le fait que les utilisateurs stockés peuvent avoir différents formats et tâchons d’absorber cette différence potentielle, ce qui est un autre avantage non négligeable à l’utilisation d’un tel service.

En parlant d’utilisateurs, quitte à ce qu’ils soient faux, peut-être pourrions-nous les mettre ailleurs ? Utilisons pour cela le pattern factory : notre service d’authentification va devenir une fonction prenant cette collection en paramètre et nous retournant un objet configuré correspondant à ce que nous avions jusqu’ici, devenant par-là même totalement agnostique des données qu’il manipule ! Profitons-en également pour y faire usage de formatUser :

import formatUser from "./formatUser";

/**
 * @param {Object[]} users
 *
 * @return {Object}
 */
export default function getAuthentication(users) {
  return {
    /**
     * @param {String} email
     * @param {String} password
     *
     * @return {?Object}
     */
    login(email, password) {
      const user = users.find(user => user.email === email && user.password === password);

      return user ? formatUser(user) : null;
    }
  };
}

Nous pourrions dès lors nous contenter d’importer puis appeler getAuthentication dans AuthWrapper, mais allons plus loin : faisons de l’« instance » du service (le résultat de l’appel à la fonction) une prop de ce dernier, toujours dans l’optique de faciliter son remplacement ultérieur. Il nous faut donc d’abord modifier src/main.js en conséquence :

import Vue from "vue";
import getAuthentication from "./getAuthentication";
import AuthWrapper from "./components/AuthWrapper";

const auth = getAuthentication([
  {
    email: "jim@example.com",
    password: "j1m"
  },
  {
    email: "bob@example.com",
    password: "b0b"
  }
]);

new Vue({
  render: h => h(AuthWrapper, { props: { auth } })
}).$mount("#app");

Puis c’est au tour d’AuthWrapper :

import App from "./App";
import LoginForm from "./LoginForm";

export default {
  props: {
    auth: {
      type: Object,
      required: true,
      validator: auth => "login" in auth && typeof auth.login === "function"
    }
  },

  // ...

  methods: {
    login({ email, password }) {
      this.user = this.auth.login(email, password);
      this.errorMessage = this.user ? "" : "Authentication failed, please try again";
    }
  },

  components: { App, LoginForm }
};

On y déclare donc la nouvelle prop auth ; s’agissant d’un type Object et les possibilités de validation natives de Vue.js à ce niveau étant assez limitées, on met en place un validator afin de vérifier la présence d’une fonction dans la clé login.

Il nous faut maintenant adapter les tests, en particulier en renommant test/specs/authentication.spec.js en test/specs/getAuthentication.spec.js et en y apportant quelques modifications. Nous allons notamment mocker formatUser : il s’agit d’un service distinct et non d’un détail d’implémentation de celui-ci, et son évolution ne doit pas impacter ce test. Mettons donc en place une version simplifiée ne conservant que le champ email des objets utilisateurs traités :

import getAuthentication from "../../src/getAuthentication";

const auth = getAuthentication([{
  email: "jim@example.com",
  password: "j1m"
}]);

jest.mock("@/formatUser", () => jest.fn().mockImplementation(user => ({ email: user.email })));

describe("getAuthentication", () => {
  describe("login", () => {
    it("returns the logged-in user object upon success", () => {
      expect(auth.login("jim@example.com", "j1m")).toEqual({
        email: "jim@example.com"
      });
    });

    it("returns null upon failure", () => {
      expect(auth.login("nobody@example.com", "nobody")).toBeNull();
    });
  });
});

Un avantage non négligeable est immédiatement visible : les données utilisées pour jouer ce test sont maintenant déclarées localement, le rendant d’autant plus « unitaire » stricto sensu.

$ npm test

 PASS  test/specs/getAuthentication.spec.js
  getAuthentication
    login
      ✓ returns the logged-in user object upon success (20ms)
      ✓ returns undefined upon failure (1ms)

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        3.337s
Ran all test suites.

Ça fonctionne ! Écrivons maintenant le test de formatUser :

import formatUser from "../../src/formatUser";

describe("formatUser", () => {
  it("formats an user object correctly", () => {
    expect(formatUser({
      email: "jim@example.com",
      name: "Jimbo Kern"
    })).toEqual({
      email: "jim@example.com",
      displayName: "Jimbo Kern"
    });
  });

  it("builds a display name from first and last name if relevant", () => {
    expect(formatUser({
      email: "jim@example.com",
      firstName: "Jimbo",
      lastName: "Kern"
    })).toEqual({
      email: "jim@example.com",
      displayName: "Jimbo Kern"
    });
  });

  it("uses email for a display name as a last resort", () => {
    expect(formatUser({
      email: "jim@example.com"
    })).toEqual({
      email: "jim@example.com",
      displayName: "jim@example.com"
    });
  });
});
$ npm test

 PASS  test/specs/formatUser.spec.js
 PASS  test/specs/getAuthentication.spec.js

Test Suites: 2 passed, 2 total
Tests:       5 passed, 5 total
Snapshots:   0 total
Time:        3.542s
Ran all test suites.

Ajout de fonctionnalités

Notre dernière itération nous a permis de consolider les fondations de notre service d’authentification ; construisons maintenant par-dessus afin d’ajouter les fonctionnalités dont nous avons besoin, à savoir la possibilité de vérifier si l’utilisateur est déjà connecté au lancement de l’application, ainsi que celle de se déconnecter, tout simplement !

Nous appuyant toujours sur de faux utilisateurs, nous allons utiliser le local storage pour conserver l’éventuel utilisateur connecté. Procédons à cela en enrichissant getAuthentication :

import formatUser from "./formatUser";

/**
 * @param {Object[]} users
 * @param {String}   [lsKey]
 *
 * @return {Object}
 */
export default function getAuthentication(users, lsKey = "user") {
  return {
    /**
     * @return {?Object}
     */
    getConnectedUser() {
      const user = localStorage.getItem(lsKey);

      return user ? JSON.parse(user) : null;
    },

    /**
     * @param {String} email
     * @param {String} password
     *
     * @return {?Object}
     */
    login(email, password) {
      let user = users.find(user => user.email === email && user.password === password);

      if (user) {
        user = formatUser(user);
        localStorage.setItem(lsKey, JSON.stringify(user));
      }

      return user || null;
    },

    logout() {
      localStorage.removeItem(lsKey);
    }
  };
}
  • Au login, on range l’utilisateur récupéré dans le local storage — la clé est configurable à l’« instanciation » si nécessaire ;
  • On ajoute une méthode logout permettant de… se déconnecter, en effaçant ladite clé ;
  • On ajoute également une méthode getConnectedUser permettant de récupérer l’utilisateur courant, toujours sous cette même clé, s’il existe.

Impactons à nouveau AuthWrapper, en y vérifiant la présence de ces nouvelles fonctions dans la prop auth, et en ajoutant également l’affichage du nom de l’utilisateur courant :

<template>
  <div>
    <div v-if="user">
      <button type="button" @click="logout">Log out</button>
      <App />
    </div>
    <LoginForm v-else :errorMessage="errorMessage" @submit="login" />
  </div>
</template>

<script>
import App from "./App";
import LoginForm from "./LoginForm";

export default {
  props: {
    auth: {
      // ...
      validator: auth => ["getConnectedUser", "login", "logout"]
        .every(method => method in auth && typeof auth[method] === "function")
    }
  },

  // ...

  methods: {
    // ...

    logout() {
      this.auth.logout();
      this.user = null;
    }
  },

  created() {
    this.user = this.auth.getConnectedUser();
  },

  components: { App, LoginForm }
};
</script>

En faisant appel à getConnectedUser dans le hook created, notre petite application est désormais capable de garder l’utilisateur connecté en cas d’appui sur F5 !

Plugin > prop

Tout cela commence à prendre forme, mais nous risquons d’être vite limités dans le cas d’une application plus complexe : en effet, comment gérerions-nous par exemple le fait d’accéder à l’objet utilisateur dans d’autres composants qu’App ? Avec un plugin, pardi ! Créons donc un fichier src/plugin.js :

export default {
  install: Vue => {
    Vue.prototype.$auth = {
      user: null,

      init(user) {
        this.user = user;
      }
    };
  }
};

Faisons-en ensuite usage dans src/main.js :

import Vue from "vue";
import AuthPlugin from "./plugin";
import getAuthentication from "./getAuthentication";
import AuthWrapper from "./components/AuthWrapper";

Vue.use(AuthPlugin);

// ...

Puis modifions, une fois encore, AuthWrapper afin de tirer parti de la nouvelle propriété $auth rendue disponible dans tous nos composants :

<template>
  <div>
    <div v-if="$auth.user">
      <button type="button" @click="logout">Log out</button>
      <App />
    </div>
    <LoginForm v-else :errorMessage="errorMessage" @submit="login" />
  </div>
</template>

<script>
import App from "./App";
import LoginForm from "./LoginForm";

export default {
  // ...

  data() {
    return {
      errorMessage: ""
    };
  },

  methods: {
    login({ email, password }) {
      const user = this.auth.login(email, password);

      if (user) {
        this.$auth.init(user);
      } else {
        this.errorMessage = "Authentication failed, please try again";
      }
    },

    logout() {
      this.auth.logout();
    }
  },

  created() {
    const user = this.auth.getConnectedUser();

    if (user) {
      this.$auth.init(user);
    }
  },

  components: { App, LoginForm }
};
</script>

Là aussi, nous constatons immédiatement une amélioration : user a disparu des data de notre composant.

Heu… ça marche plus, par contre !

Effectivement ! La raison à cela est assez simple à comprendre : lorsque le plugin assigne une nouvelle valeur à sa propriété user via la méthode init, le framework n’a aucun moyen de détecter et prendre en compte ce changement, car cette propriété n’est pas réactive. Il existe un moyen simple d’y remédier, et c’est d’ailleurs celui employé par Vuex ainsi que d’autres plugins de référence de l’écosystème Vue : instancier Vue et l’utiliser pour déclarer toutes nos propriétés et méthodes, bénéficiant ainsi gratuitement de son système de réactivité interne !

export default {
  install: (Vue, { auth }) => {
    Vue.prototype.$auth = new Vue({
      data() {
        return {
          user: null
        };
      },

      methods: {
        init() {
          this.user = auth.getConnectedUser();
        },

        login(email, password) {
          this.user = auth.login(email, password);
        },

        logout() {
          auth.logout();
          this.user = null;
        }
      }
    });
  }
};

Comme vous pouvez le voir, nous faisons le choix de ranger directement dans le plugin la logique d’interaction avec le service getAuthentication, ce qui aura pour avantage d’amoindrir encore le code d’AuthWrapper, lequel n’aura plus qu’à appeler les méthodes du plugin selon le besoin (et perd sa dernière prop) :

<template>
  <div>
    <div v-if="$auth.user">
      <button type="button" @click="$auth.logout">Log out</button>
      <App />
    </div>
    <LoginForm v-else :errorMessage="errorMessage" @submit="login" />
  </div>
</template>

<script>
import App from "./App";
import LoginForm from "./LoginForm";

export default {
  // ...

  methods: {
    login({ email, password }) {
      this.$auth.login(email, password);

      if (!this.$auth.user) {
        this.errorMessage = "Authentication failed, please try again";
      }
    }
  },

  created() {
    this.$auth.init();
  },

  components: { App, LoginForm }
};
</script>

Cette modification implique que ce service soit fourni au plugin directement, puisqu’il ne l’est plus en tant que prop à notre composant — ça se passe, comme toujours, dans src/main.js :

Vue.use(AuthPlugin, {
  auth: getAuthentication([
    {
      email: "jim@example.com",
      password: "j1m"
    },
    {
      email: "bob@example.com",
      password: "b0b"
    }
  ])
});

new Vue({
  render: h => h(AuthWrapper)
}).$mount("#app");

Pour vérifier que tout ceci fonctionne comme souhaité, nous pouvons par exemple afficher le nom de l’utilisateur dans le composant HelloWorld en modifiant la prop msg qu’il reçoit d’App :

<template>
  <div id="app">
    <img alt="Vue logo" src="../assets/logo.png" />
    <HelloWorld :msg="'Welcome to Your Vue.js App, ' + $auth.user.displayName" />
  </div>
</template>

Application avec le nom de l'utilisateur

La suite au prochain épisode

Nous allons nous arrêter là pour aujourd’hui ! La deuxième et dernière partie de cet article sera tournée vers HTTP, afin de se connecter à un service distant plus vrai que nature, ou encore de fournir un moyen de faire des requêtes AJAX contextualisées à l’utilisateur courant. À très bientôt !


L’équipe Synbioz.
Libres d’être ensemble.