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 !
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 ;)
package.json
un script start
servant d’alias à
npm run serve
;package.json
, renommer le script test:unit
en test
;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
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.
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");
Ce qui nous donne, une fois connecté :
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 !
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>
Ç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 :
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 ;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.
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’import
er 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.
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);
}
};
}
login
, on range l’utilisateur récupéré dans le local storage — la
clé est configurable à l’« instanciation » si nécessaire ;logout
permettant de… se déconnecter, en effaçant
ladite clé ;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 !
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>
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.
Nos conseils et ressources pour vos développements produit.