c) Sécurisation d'un frontend

Où sauvegarder de l'info dans un browser ?

Nous avons vu qu'une des exigences associées à la création d'une application selon l'architecture REST, c'est qu'elle soit stateless : l'API ne peut pas garder l'état du client, sa session, côté serveur.

C'est donc au client de sauvegarder ses données de session.

Mais où pouvons nous sauvegarder des données de manière persistante côté client ?

Il existe deux façons principales de sauvegarder de l'info dans un browser :

  • le web storage ;
  • les cookies.

Dans le cadre de ce cours, nous allons principalement voir comment sauvegarder de l'info à l'aide du web storage. Dans la partie du cours sur la gestion de l'authentification et l'autorisation d'utilisateurs, vous pourrez optionnellement voir comment les cookies peuvent être utilisés pour sauvegarder des données de session côté client.

NB : le browser met à disposition d'autres API un peu moins connues pour sauvegarder des infos. Nous ne les verrons pas dans le cadre de ce cours, mais il reste néanmoins intéressant de lire très rapidement de quoi il s'agit :

  • IndexedDB API : permet de sauvegarder côté client de grandes quantités d'infos structurées, incluant des fichiers ; c'est une base de données orientée objets en JS qui permet les transactions.
  • Cache API : permet d'enregistrer et retrouver des requêtes et leur réponses. Bien qu'à la base créé pour pouvoir fournir des réponses plus rapides à certaines requêtes, cette API peut aussi être utilisée comme mécanisme général de stockage.

Persistance de données de session via le web storage

Introduction

Le Web Storage API fournit un mécanisme permettant aux browser d'enregistrer des paires clé / valeur d'une manière plus intuitive que l'utilisation de cookies.

Il existe deux mécanismes au sein du web storage :

  • sessionStorage :
    • offre un espace de stockage séparé pour chaque origine pour la durée de la session d'une page, tant que le browser est ouvert.
    • les clés / valeurs y sont enregistrées sous forme de string uniquement ;
    • met à disposition un espace de stockage plus grand qu'un cookie, ~5MB maximum par origine ;
  • localStorage :
    • offre aussi un espace de stockage séparé pour chaque origine, mais les données persistent quand le browser est fermé et rouvert ;
    • est un espace de stockage plus grand qu'un cookie, limité à ~10MB en cas de crash/restart du browser.

Les principales méthodes offertes par sessionStorage et localStorage sont les mêmes. Voici quelques exemples de codes par méthode.

setItem()

Cette méthode permet d'enregistrer, pour une clé donnée, la valeur associée :

ts
1
const storeName = 'user';
2
3
const setUserSessionData = (user) => {
4
  const storageValue = JSON.stringify(user);
5
  localStorage.setItem(storeName, storageValue);
6
};

Pour enregistrer un objet JS sous forme de string, il suffit de le sérialiser à l'aide de la méthode JSON.stringify().

getItem()

Cette méthode permet d'obtenir la valeur associée à la clé donnée en argument :

ts
1
const storeName = 'user';
2
3
const getUserSessionData = () => {
4
  const retrievedUser = localStorage.getItem(storeName);
5
  if (!retrievedUser) return;
6
  return JSON.parse(retrievedUser);
7
};

Pour cet exemple, comme la valeur a été sérialisée, nous pouvons récupérer l'objet grâce à la méthode JSON.parse().

removeITem()

Cette méthode permet d'effacer une clé / valeur :

ts
1
const storeName = 'user';
2
3
const removeSessionData = () => {
4
  localStorage.removeItem(storeName);
5
};

clear()

Cette méthode permet d'effacer tout l'espace de stockage pour une origine donnée.

Cette méthode est très utile lorsque l'on souhaite effacer toute la session d'un utilisateur, notamment lors du logout d'un utilisateur.

Exercice 3.3 : Persistence d'un thème

Veuillez partir d'une copie de l'exercice (/exercises/2.15b) pour créer un nouveau projet nommé exercises/3.3-4 afin de continuer l'application myMovies.

Vous devez ajouter un moyen de switcher d'un thème "light" ou "dark" au sein de votre application.

Par exemple, vous pouvez le faire via un bouton dans le header ou le footer permettant de basculer d'un thème à l'autre.

L'effet du thème doit être visible seulement sur le footer et le header... Il n'est pas nécessaire de faire un thème "dark" complet sur la page. Veuillez donc changer les couleurs des backgrounds du header & du footer et de certains de leurs textes en fonction du thème.

Vous devez sauvegarder le thème sélectionné par l'utilisateur comme donnée de session persistante. Ainsi, vous allez sauvegarder l'information du thème dans le localStorage.

Au redémarrage du browser, ou lors du refresh du frontend, l'application doit toujours afficher ses écrans selon le dernier thème sélectionné.

Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex3.3.

Authentification d'un utilisateur via une IHM & JWT

Pour authentifier un utilisateur via une IHM, il suffit de faire une requête à une RESTful API.

Généralement, l'utilisateur devra d'abord créer son compte. Il utilisera un formulaire demandant au minimum un identifiant (username, adresse e-mail ou autres) et un password.
Dans le cadre d'une SPA, l'IHM fera appel à une opération de type register lorsque l'utilisateur soumet le formulaire.

Par la suite, lorsque le compte de l'utilisateur existe, l'IHM fera appel à une opération de type login lorsque l'utilisateur tentera de se connecter à l'aide d'un formulaire.

Dans les deux cas, register ou login, le développeur devra connaître les opérations mises à disposition par l'API.

Dans le cadre du site de la pizzeria, nous savons que l'API met à disposition ces deux opérations :

URIMéthode HTTPOpération
auths/loginPOSTVérifier les credentials d'une ressource de type "users" et renvoyer le username et un token JWT si les credentials sont OK
auths/registerPOSTCréer une ressource de type "users" et renvoyer le username et un token JWT

Pour ce tutoriel, veuillez créer une copie du tutoriel fetch-proxy, si nécessaire voici le code du tutoriel fetch-proxy, et l'appeler session-jwt. Changez le nom du projet dans package.json.

Veuillez démarrer la version auths de l'API de la pizzeria.

Pour notre tutoriel, nous avons besoin de créer deux nouvelles pages :

  • RegisterPage qui offrira un formulaire pour créer un compte utilisateur ; nous considérons qu'un utilisateur qui vient d'être créé est connecté (il ne doit donc pas se loguer).
  • LoginPage qui offrira un formulaire pour se connecter à son compte utilisateur.

Nous allons commencer par RegisterPage.

💭 Très souvent, nous allons devoir penser si c'est à RegisterPage de faire le fetch des données, ou si ce fetch doit être fait ailleurs.

S'il n'y a pas d'état associé à une opération de "register", alors tout serait OK de faire le fetch dans RegisterPage. Néanmoins, si un utilisateur est connecté, il va falloir mettre jour la Navbar... et qui dit mise à jour de la UI, dit gestion de l'état...
Ainsi, nous nous rendons compte qu'il est important de définir une variable d'état qui correspondra à l'utilisateur connecté, et qui nous permettra notamment d'afficher le nom de l'utilisateur connecté, d'afficher dynamiquement la Navbar (quand on est connecté, les éléments de la barre permettant de créer un compte ou de se connecter doivent disparaître...).

Notons que nous souhaitons appliquer le même format à tous les formulaires de chacune des pages. Dès lors, le plus direct est de renommer AddPizzaPage.css en index.css. Nous allons utiliser index.css tant dans AddPizzaPage que RegisterPage (et plus tard LoginPage).

Ainsi, nous allons définir le fetch du register au sein de App en y ajoutant la nouvelle fonction registerUser, en mettant à jour le contexte, et en créant une nouvelle variable d'état authenticatedUser (associé à un nouveau type MaybeAuthenticatedUser construit sur AuthenticatedUser) :

tsx
1
// code existant
2
const [authenticatedUser, setAuthenticatedUser] =
3
useState<MaybeAuthenticatedUser>(undefined);
4
// ...
5
const registerUser = async (newUser: User) => {
6
try {
7
const options = {
8
method: "POST",
9
body: JSON.stringify(newUser),
10
headers: {
11
"Content-Type": "application/json",
12
},
13
};
14
15
const response = await fetch("/api/auths/register", options);
16
17
if (!response.ok)
18
throw new Error(
19
`fetch error : ${response.status} : ${response.statusText}`
20
);
21
22
const createdUser: AuthenticatedUser = await response.json();
23
24
setAuthenticatedUser(createdUser);
25
console.log("createdUser: ", createdUser);
26
} catch (err) {
27
console.error("registerUser::error: ", err);
28
throw err;
29
}
30
}
31
// reste du code
32
33
const fullPizzaContext: PizzeriaContext = {
34
addPizza,
35
pizzas,
36
setPizzas,
37
actionToBePerformed,
38
setActionToBePerformed,
39
clearActionToBePerformed,
40
drinks,
41
registerUser,
42
};

Comme le contexte a été mis à jour, nous devons mettre à jour le type associé PizzeriaContext, ainsi que créer :

  • un nouveau type User
  • un nouveau type AuthenticatedUser
  • un nouveau type MaybeAuthenticatedUser (qui est un AuthenticatedUser ou `undefined).

Pour cela, veuillez mettre à jour /src/types.ts :

ts
1
interface PizzeriaContext {
2
pizzas: Pizza[];
3
setPizzas: (pizzas: Pizza[]) => void;
4
actionToBePerformed: boolean;
5
setActionToBePerformed: (actionToBePerformed: boolean) => void;
6
clearActionToBePerformed: () => void;
7
drinks: Drink[];
8
addPizza: (newPizza: NewPizza) => Promise<void>;
9
registerUser: (newUser: User) => Promise<void>;
10
}
11
12
interface User {
13
username: string;
14
password: string;
15
}
16
17
interface AuthenticatedUser {
18
username: string;
19
token: string;
20
}
21
22
type MaybeAuthenticatedUser = AuthenticatedUser | undefined;
23
24
export type {
25
Pizza,
26
NewPizza,
27
Drink,
28
PizzeriaContext,
29
User,
30
AuthenticatedUser,
31
MaybeAuthenticatedUser,
32
};

Et voici le code de la nouvelle RegisterPage (veuillez la créer) qui fait appel au contexte pour récupérer la fonction registerUser :

tsx
1
import { useState, SyntheticEvent } from "react";
2
import "./index.css";
3
import { useNavigate, useOutletContext } from "react-router-dom";
4
import { PizzeriaContext } from "../../types";
5
6
const RegisterPage = () => {
7
const { registerUser }: PizzeriaContext = useOutletContext();
8
9
const navigate = useNavigate();
10
const [username, setUsername] = useState("");
11
const [password, setPassword] = useState("");
12
13
const handleSubmit = async (e: SyntheticEvent) => {
14
e.preventDefault();
15
try {
16
await registerUser({ username, password });
17
navigate("/");
18
} catch (err) {
19
console.error("RegisterPage::error: ", err);
20
}
21
};
22
23
const handleUsernameInputChange = (e: SyntheticEvent) => {
24
const input = e.target as HTMLInputElement;
25
setUsername(input.value);
26
};
27
28
const handlePasswordChange = (e: SyntheticEvent) => {
29
const input = e.target as HTMLInputElement;
30
setPassword(input.value);
31
};
32
33
return (
34
<div>
35
<h1>Ajoutez un utilisateur</h1>
36
<form onSubmit={handleSubmit}>
37
<label htmlFor="username">Username</label>
38
<input
39
value={username}
40
type="text"
41
id="username"
42
name="username"
43
onChange={handleUsernameInputChange}
44
required
45
/>
46
<label htmlFor="password">Password</label>
47
<input
48
value={password}
49
type="text"
50
id="password"
51
name="password"
52
onChange={handlePasswordChange}
53
required
54
/>
55
<button type="submit">Créer le compte</button>
56
</form>
57
</div>
58
);
59
};
60
61
export default RegisterPage;

Maintenant nous devons mettre à jour la configuration de notre router pour offrir la RegisterPage : pour ce faire, veuillez mettre à jour /src/main.tsx :

tsx
1
const router = createBrowserRouter([
2
{
3
path: "/",
4
element: <App />,
5
children: [
6
{
7
path: "",
8
element: <HomePage />,
9
},
10
{
11
path: "add-pizza",
12
element: <AddPizzaPage />,
13
},
14
{
15
path: "register",
16
element: <RegisterPage />,
17
},
18
],
19
},
20
]);

Il faut aussi mettre à jour la Navbar pour offrir un lien vers la RegisterPage et avoir un affichage dynamique des éléments de navigation en fonction que les utilisateurs sont connectés ou non :

tsx
1
interface NavBarProps {
2
authenticatedUser: MaybeAuthenticatedUser;
3
}
4
5
const NavBar = ({authenticatedUser} : NavBarProps) => {
6
const navigate = useNavigate();
7
8
if(authenticatedUser) {
9
return (
10
<nav>
11
<button onClick={() => navigate("/")}>Home</button>
12
<button onClick={() => navigate("/add-pizza")}>Ajouter une pizza</button>
13
</nav>
14
);
15
}
16
17
return (
18
<nav>
19
<button onClick={() => navigate("/")}>Home</button>
20
<button onClick={() => navigate("/register")}>Créer un utilisateur</button>
21
<button onClick={() => navigate("/login")}>Se connecter</button>
22
</nav>
23
);
24
};

Ici nous avons ajouté un paramètre qui contiendra, l'éventuel authenticatedUser si l'utilisateur vient de créer son compte (ou s'il s'est logué, mais nous verrons ça plus tard).

Nous mettons donc à jour le return de App afin de passer cette variable authenticatedUser à la Navbar :

tsx
1
return (
2
<div className="page">
3
<Header
4
title="We love Pizza"
5
version={0 + 1}
6
handleHeaderClick={handleHeaderClick}
7
/>
8
<main>
9
<NavBar authenticatedUser={authenticatedUser} />
10
<Outlet context={fullPizzaContext} />
11
</main>
12
<Footer />
13
</div>
14
);

Veuillez exécuter le frontend (ainsi que l'API auths) et vous assurer que l'utilisateur que vous tentez de créer est bien créé par votre API.

Veuillez utiliser le formulaire de création de compte et vérifier que cela fonctionne. Une fois un nouveau compte créé, votre Navbar ne devrait plus afficher Créer un utilisateur.

Nous allons maintenant apporter des modifications quasi identiques pour gérer la LoginPage.

Commençons par créer la fonction loginUser dans App :

tsx
1
const loginUser = async (user: User) => {
2
try {
3
const options = {
4
method: "POST",
5
body: JSON.stringify(user),
6
headers: {
7
"Content-Type": "application/json",
8
},
9
};
10
11
const response = await fetch("/api/auths/login", options);
12
13
if (!response.ok)
14
throw new Error(
15
`fetch error : ${response.status} : ${response.statusText}`
16
);
17
18
const authenticatedUser: AuthenticatedUser = await response.json();
19
console.log("authenticatedUser: ", authenticatedUser);
20
21
setAuthenticatedUser(authenticatedUser);
22
} catch (err) {
23
console.error("loginUser::error: ", err);
24
throw err;
25
}
26
};
27
28
// Reste du code
29
30
const fullPizzaContext: PizzeriaContext = {
31
addPizza,
32
pizzas,
33
setPizzas,
34
actionToBePerformed,
35
setActionToBePerformed,
36
clearActionToBePerformed,
37
drinks,
38
registerUser,
39
loginUser,
40
};

Comme le contexte a été mis à jour, nous devons mettre à jour le type associé PizzeriaContext au sein de /src/types.ts :

ts
interface PizzeriaContext {
pizzas: Pizza[];
setPizzas: (pizzas: Pizza[]) => void;
actionToBePerformed: boolean;
setActionToBePerformed: (actionToBePerformed: boolean) => void;
clearActionToBePerformed: () => void;
drinks: Drink[];
addPizza: (newPizza: NewPizza) => Promise<void>;
registerUser: (newUser: User) => Promise<void>;
loginUser: (user: User) => Promise<void>;
}

Et voici le code de la nouvelle LoginPage (veuillez créer cette page) qui fait appel au contexte pour récupérer la fonction loginUser :

tsx
1
import { useState, SyntheticEvent } from "react";
2
import "./index.css";
3
import { useNavigate, useOutletContext } from "react-router-dom";
4
import { PizzeriaContext } from "../../types";
5
6
const LoginPage = () => {
7
8
const { loginUser } : PizzeriaContext = useOutletContext();
9
10
const navigate = useNavigate();
11
const [username, setUsername] = useState("");
12
const [password, setPassword] = useState("");
13
14
const handleSubmit = async (e: SyntheticEvent) => {
15
e.preventDefault();
16
try {
17
await loginUser({ username, password });
18
navigate("/");
19
} catch (err) {
20
console.error("LoginPage::error: ", err);
21
}
22
};
23
24
const handleUsernameInputChange = (e: SyntheticEvent) => {
25
const input = e.target as HTMLInputElement;
26
setUsername(input.value);
27
};
28
29
const handlePasswordChange = (e: SyntheticEvent) => {
30
const input = e.target as HTMLInputElement;
31
setPassword(input.value);
32
};
33
34
return (
35
<div>
36
<h1>Connectez un utilisateur</h1>
37
<form onSubmit={handleSubmit}>
38
<label htmlFor="username">Username</label>
39
<input
40
value={username}
41
type="text"
42
id="username"
43
name="username"
44
onChange={handleUsernameInputChange}
45
required
46
/>
47
<label htmlFor="password">Password</label>
48
<input
49
value={password}
50
type="text"
51
id="password"
52
name="password"
53
onChange={handlePasswordChange}
54
required
55
/>
56
<button type="submit">S'authentifier</button>
57
</form>
58
</div>
59
);
60
};
61
62
export default LoginPage;

Maintenant nous devons mettre à jour la configuration de notre router pour offrir la LoginPage : pour ce faire, veuillez mettre à jour /src/main.tsx :

tsx
1
const router = createBrowserRouter([
2
{
3
path: "/",
4
element: <App />,
5
children: [
6
{
7
path: "",
8
element: <HomePage />,
9
},
10
{
11
path: "add-pizza",
12
element: <AddPizzaPage />,
13
},
14
{
15
path: "register",
16
element: <RegisterPage />,
17
},
18
{
19
path: "login",
20
element: <LoginPage />,
21
}
22
],
23
},
24
]);

Il faut aussi mettre à jour la Navbar pour offrir un lien vers la LoginPage seulement si les utilisateurs ne sont pas connectés :

tsx
1
const NavBar = ({authenticatedUser} : NavBarProps) => {
2
const navigate = useNavigate();
3
4
if(authenticatedUser) {
5
return (
6
<nav>
7
<button onClick={() => navigate("/")}>Home</button>
8
<button onClick={() => navigate("/add-pizza")}>Ajouter une pizza</button>
9
</nav>
10
);
11
}
12
13
return (
14
<nav>
15
<button onClick={() => navigate("/")}>Home</button>
16
<button onClick={() => navigate("/register")}>Créer un utilisateur</button>
17
<button onClick={() => navigate("/login")}>Se connecter</button>
18
</nav>
19
);
20
};

Veuillez exécuter le frontend et vous assurer que pour l'utilisateur préalablement créé, vous pouvez l'utiliser pour vous loguer.

Sauvegarde des données de session

Maintenant, bien que l'utilisateur soit connecté et donc authentifié, si nous faisons un refresh de la page, nous perdons les données de session.

Nous allons donc voir comment sauvegarder authenticatedUser dans le localStorage.

💭 Mais quel est le format d'un AuthenticatedUser ?
Celui-ci est fixé par notre API et nous l'avons déjà défini dans types.ts... Voici un exemple de sa forme :

json
{
"username": "me",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1lIiwiaWF0IjoxNzE5ODIxMTEwLCJleHAiOjE4MDYyMjExMTB9.cyw8HYhLxEWIsdAJQ0dO_wuokib20zTNxqYLpj74Wp4"
}

💭 A quel moment sauvegarder les données de session ?
Ici, l'utilisateur est connecté lors du register, ou lors du login. C'est donc à se moment là qu'il faut sauvegarder ses données dans le localStorage.
Comme nous allons réutiliser un même traitement (tant dans login que dans register), nous pouvons directement éviter une duplication de code en créant une fonction storeAuthenticatedUser. De plus, nous avons besoin d'une fonction qui nous permettra de récupérer l'utilisateur authentifié du localStorage.
Et finalement, afin de prévoir le nettoyage des données de session, nous allons créer une fonction clearAuthenticatedUser.

Veuillez créer un nouveau script /src/utils/session.ts et y ajouter ce code-ci :

ts
import { AuthenticatedUser, MaybeAuthenticatedUser } from "../types";
const storeAuthenticatedUser = (authenticatedUser: AuthenticatedUser) => {
localStorage.setItem("authenticatedUser", JSON.stringify(authenticatedUser));
};
const getAuthenticatedUser = (): MaybeAuthenticatedUser => {
const authenticatedUser = localStorage.getItem("authenticatedUser");
if (!authenticatedUser) return undefined;
return JSON.parse(authenticatedUser);
};
const clearAuthenticatedUser = () => {
localStorage.removeItem("authenticatedUser");
};
export { storeAuthenticatedUser, getAuthenticatedUser, clearAuthenticatedUser };

Veuillez mettre à jour App afin de faire appelle à storeAuthenticatedUser lors du login et du register :

tsx
1
const registerUser = async (newUser: User) => {
2
try {
3
const options = {
4
method: "POST",
5
body: JSON.stringify(newUser),
6
headers: {
7
"Content-Type": "application/json",
8
},
9
};
10
11
const response = await fetch("/api/auths/register", options);
12
13
if (!response.ok)
14
throw new Error(
15
`fetch error : ${response.status} : ${response.statusText}`
16
);
17
18
const createdUser: AuthenticatedUser = await response.json();
19
20
setAuthenticatedUser(createdUser);
21
storeAuthenticatedUser(createdUser);
22
23
console.log("createdUser: ", createdUser);
24
} catch (err) {
25
console.error("registerUser::error: ", err);
26
throw err;
27
}
28
};
29
30
const loginUser = async (user: User) => {
31
try {
32
const options = {
33
method: "POST",
34
body: JSON.stringify(user),
35
headers: {
36
"Content-Type": "application/json",
37
},
38
};
39
40
const response = await fetch("/api/auths/login", options);
41
42
if (!response.ok)
43
throw new Error(
44
`fetch error : ${response.status} : ${response.statusText}`
45
);
46
47
const authenticatedUser: AuthenticatedUser = await response.json();
48
console.log("authenticatedUser: ", authenticatedUser);
49
50
setAuthenticatedUser(authenticatedUser);
51
storeAuthenticatedUser(authenticatedUser);
52
} catch (err) {
53
console.error("loginUser::error: ", err);
54
throw err;
55
}
56
};

💭 A quel moment devons nous faire appel à getAuthenticatedUser pour récupérer les données du localStorage ?
En fait, cela doit se faire au chargement de la page. Et nous souhaitons, si nous récupérons un utilisateur authentifié, passer cet utilisateur à la Navbar. Une façon simple de réaliser cette action, c'est la toute première fois que App est appelé, en utilisant son useEffect. Veuillez donc mettre à jour le useEffect de App :

tsx
useEffect(() => {
fetchPizzas();
const authenticatedUser = getAuthenticatedUser();
if (authenticatedUser) {
setAuthenticatedUser(authenticatedUser);
}
}, []);

Nous allons voir dans le localStorage s'il existe des données de session et récupérons l'utilisateur authentifié si c'est le cas. Ensuite, nous mettons à jour l'état authenticatedUser avec cet utilisateur authentifié, ce qui permet un "rerender" de l'UI (dont la mise à jour de l'affichage de la Navbar).

Veuillez connecter un utilisateur et faire un refresh de la page... Et voila, vous devriez rester connecté : )

N'hésitez pas à aller voir le localStorage de votre browser. Pour Chrome, dans vos outils de développeurs, celui-ci se trouve dans l'onglet Application.

Déconnexion d'un utilisateur

Il est à noter que pour avoir une application complète, il va aussi falloir penser à faire effacer les données de session.

Nous allons ajouter un élément à la Navbar qui se nomme Se déconnecter. Lorsqu'on cliquera sur cet élément, nous devons mettre à jour la variable d'état authenticatedUser qui se trouve dans App et nous devons effacer les données de session du localStorage. Comme l'état est géré dans le composant "parent" de la Navbar, nous allons créer une fonction dans App qui permette d'agir sur cet état.

Veuillez donc mettre à jour App en lui ajoutant cette fonction clearUser et en passant cette fonction à la Navbar :

tsx
1
//....
2
const clearUser = () => {
3
clearAuthenticatedUser();
4
setAuthenticatedUser(undefined);
5
}
6
// ... reste du code
7
return (
8
<div className="page">
9
<Header
10
title="We love Pizza"
11
version={0 + 1}
12
handleHeaderClick={handleHeaderClick}
13
/>
14
<main>
15
<NavBar authenticatedUser={authenticatedUser} clearUser={clearUser}/>
16
<Outlet context={fullPizzaContext} />
17
</main>
18
<Footer />
19
</div>
20
);

Voici le code de la Navbar mis à jour pour ajouter l’élément de déconnexion et l'action associée :

tsx
1
interface NavBarProps {
2
authenticatedUser: MaybeAuthenticatedUser;
3
clearUser: () => void;
4
}
5
6
const NavBar = ({ authenticatedUser, clearUser }: NavBarProps) => {
7
const navigate = useNavigate();
8
9
if (authenticatedUser) {
10
return (
11
<nav>
12
<button onClick={() => navigate("/")}>Home</button>
13
<button onClick={() => navigate("/add-pizza")}>
14
Ajouter une pizza
15
</button>
16
<button onClick={() => clearUser()}>Se déconnecter</button>
17
</nav>
18
);
19
}
20
21
return (
22
<nav>
23
<button onClick={() => navigate("/")}>Home</button>
24
<button onClick={() => navigate("/register")}>
25
Créer un utilisateur
26
</button>
27
<button onClick={() => navigate("/login")}>Se connecter</button>
28
</nav>
29
);
30
};

Veuillez tester l'application, vous connecter, déconnecter... Cela devrait bien fonctionner.

Il nous reste à faire en sorte que l'on puisse autoriser l'opération de création de pizza.

Autorisation de l'appel à une opération protégée

Veuillez connecter un utilisateur, cliquer sur Ajouter une pizza, compléter le formulaire d'ajout d'une pizza, cliquer sur Ajouter et regarder dans la console : il devrait y avoir une erreur qui s'affiche avec le "status code" 401 : Unauthorized.
En effet, l'API attend un token pour autoriser l'opération de création d'une pizza.

Nous allons donc mettre à jour la fonction permettant de créer une pizza qui est définie dans App pour ajouter le token de l'utilisateur authentifié au sein du header de la requête :

tsx
1
const addPizza = async (newPizza: NewPizza) => {
2
try {
3
if(!authenticatedUser) {
4
throw new Error("You must be authenticated to add a pizza");
5
}
6
const options = {
7
method: "POST",
8
body: JSON.stringify(newPizza),
9
headers: {
10
"Content-Type": "application/json",
11
Authorization: authenticatedUser.token,
12
},
13
};
14
// Suite du code

Veuillez vous connecter à l'IHM à l'aide du compte manager et tenter d'ajouter une pizza. Cela ne devrait toujours pas fonctionner. Veuillez regarder dans la console : il devrait y avoir une erreur qui s'affiche avec le "status code" 403 : Forbidden.
En effet, l'API attend un token pour autoriser l'opération de création d'une pizza, mais seulement l'utilisatrice ou l'utilisateur admin a le privilège d'ajouter une pizza au menu !

Déconnectez-vous, reconnectez-vous à l'aide du compte admin (password admin par défaut ; ), et ajoutez une pizza. Voila ! Le site devrait être entièrement fonctionnel !

En cas de souci, vous pouvez utiliser le code du tutoriel session-jwt.

💭 Est-ce que c'est "safe" que notre IHM affiche le menu "Ajouter une pizza" pour un utilisateur qui n'est pas l'admin ?
En fait oui, c'est "safe", vous l'avez testé. L'API ne doit jamais faire confiance aux applications clientes pour appliquer la sécurité. Ainsi, même si le frontend autorise l'accès à des opérations qui ne devraient pas être permises, au regard des autorisations appliquées par l'API, ça n'a pas d'importance point de vue sécurité.
De la même façon, c'est pour ça qu'une API doit aussi toujours valider les paramètres qu'elle reçoit. Elle ne peut pas faire confiance aux applications clientes, comme par exemple à une application web tournant dans un browser, pour valider tous les champs d'un formulaire.
La raison est simple, l'API est développée indépendamment des applications clientes, elle ne peut pas supposer que les requêtes seront toujours bien construites.

💭 OK, tout est "safe" si l'API fait toutes les vérifications nécessaires. Néanmoins, n'y a-t-il pas des règles de bonnes pratiques au niveau des IHM, pour ne pas permettre de faire n'importe quelles requêtes vers des API ?
Et bien oui, au niveau des IHM, pour des questions d'ergonomie, d'expérience utilisateur, on va faire en sorte :

  • de ne pas offrir des opérations qui ne seront pas autorisées. Par exemple, dans le cadre de ce tutoriel sur un site permettant de gérer une pizzeria, il ne faut pas "frustrer" les utilisateurs en leur faisant croire qu'ils ont accès à l'opération de créer une pizza ! Imaginez-vous, vous créez une nouvelle pizza de 32 ingrédients, et lors de la soumission, vous recevez un message comme quoi vous n'êtes pas l'admin du site et que vous n'avez donc pas le droit de créer une pizza 😲!
  • de ne pas demander du travail à une API quand l'IHM peut détecter que ce n'est pas utile. Ainsi, quand une IHM offre des formulaires, qui amèneront à des requêtes vers des API, on évitera d'autoriser la soumission des données tant que les champs n'ont pas été validés. Tout ce que l'IHM peut valider côté client, elle doit le faire. Le feedback sera plus rapide pour l'utilisateur, et les ressources de l'API seront économisées (pas d'appel inutile).

N'hésitez donc pas à mettre à jour ce tutoriel pour faire en sorte d'afficher "Ajouter une pizza" que si le compte connecté est admin.

💭 Ca n'est pas un peu "cheap" que seul le compte admin puisse avoir le privilège d'admin du site ?
Hé bien oui, c'est "cheap". Généralement, dans le cadre d'applications plus robustes, nous allons ajouter un ou plusieurs rôle(s) aux utilisateurs. Par exemple, dans le cadre d'applications où les rôles sont simples, qu'il n'y a jamais qu'un seul rôle associé à un utilisateur, il suffirait d'ajouter au niveau de l'API la propriété role aux utilisateurs. La majorité des utilisateurs pourrait avoir un rôle dont la valeur serait default, et une minorité d'utilisateurs aurait le rôle d'admin...

On n'affiche actuellement pas de message d'erreur aux utilisateurs lorsque la réponse d'une API renvoie une erreur. Pour améliorer l'expérience de l'utilisateur, ce serait une amélioration à faire.

Exercice 3.4 : Authentification et appel d'opérations protégées par JWT

Veuillez continuer l'exercice précédent nommé /exercises/3.3-4-4b afin de compléter l'application myMovies.

Pour tout cette exercice, vous devez avoir démarré l'API sécurisée par JWT de vos films. Veuillez donc exécuter le code de votre exercice précédent /exercises/3.1. En cas de souci, vous trouvez le code de cette API ici : https://github.com/e-vinci/ts-exercises

Authentification et appel d'opérations nécessitant une autorisation JWT

Veuillez implémenter ces cas d'utilisation :

  • register : les utilisateurs doivent pouvoir créer un compte.
  • login : les utilisateur doivent pouvoir se loguer.
  • logout : les utilisateurs doivent pouvoir se déconnecter.

N'hésitez pas à reprendre le code du tutoriel ; )

Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex3.4-authentification.

Votre application doit maintenant autoriser les opérations suivantes que pour des utilisateurs authentifiés :

  • UC2 : l'ajout d'une ressource de type films via un formulaire d'ajout d'un film.
  • UC3 : la suppression d'un film.

Comme auparavant, cette opération est permise pour tous les utilisateurs, anonymes ou authentifiés :

  • UC1 : l'affichage, sous forme de tableau, de toutes les ressources de type films.

Pensez à bien mettre à jour votre Navbar pour afficher les bons éléments en fonction que l'utilisateur est authentifié ou pas.

Faites attention, il n'est pas autorisé, pour des raisons d'ergonomie, que le frontend offre les fonctionnalités d'écriture de ressources pour les utilisateur non authentifiés. Vous devez donc rendre invisible les opérations non autorisées aux utilisateurs.

UC2 (create) & UC3 (delete) doivent donc être invisibles pour les utilisateurs anonymes.

Veuillez faire en sorte de rajouter dans le web storage ces nouvelles données de session lors du login ou du register : le token et le username.

Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex3.4.

🤝 Tips

  • 💭 Comment rendre invisible les opérations d'écriture de certaines ressources au sein du frontend ?
    Par exemple, vous pourriez afficher des boutons de type Effacer que si l'utilisateur est authentifié.

Exercice 3.4b : Modification de ressources & JWT

Veuillez continuer l'exercice précédent nommé /exercises/3.3-4-4b afin de compléter l'application myMovies.

Votre application doit maintenant autoriser cette opération suivante que pour des utilisateurs authentifiés :

  • UC4 : la mise à jour des données d'un film (à l'exception de l'id associé à un film).

Faites attention, il n'est pas autorisé, pour des raisons d'ergonomie, que le frontend offre les fonctionnalités d'écriture de ressources pour les utilisateur non authentifiés. Vous devez donc rendre invisible les opérations non autorisées aux utilisateurs.

UC4 (update) doit donc être invisible pour les utilisateurs anonymes.

Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex3.4b.

🍬 Challenge optionnel : Gestion de session & remember me

S'il vous reste du temps, vous pourriez continuer l'exercice précédent et ajouter une fonction "remember me" à votre formulaire de "login" et de "register" et faire en sorte que vos données de session soient sauvegardées :

  • dans le localStorage si l'on clique sur une checkbox "Remember me" ;
  • dans le sessionStorage si l'on ne clique pas sur la checkbox "Remember me" lors du login ou du register.

Une fois tout fonctionnel, veuillez faire un commit avec le message suivant : new:ex3.4b+.

🍬 Persistance de données de session via des cookies

Authentification & autorisation JWT à l'aide de cookies

Dans la partie optionnelle sur l'Authentification & autorisation JWT à l'aide de cookies, nous avons vu comment mettre à jour l'API afin d'intégrer les tokens JWT aux cookies.

Veuillez démarrer la version cookies de l'API de la pizzeria.

Nous allons voir maintenant comment le frontend peut utiliser ces cookies.

Gestion de session côté client via une IHM et des cookies

Pour ce tutoriel, veuillez créer une copie du tutoriel session-jwt, si nécessaire voici le code du tutoriel session-jwt, et l'appeler cookies. Changez le nom du projet dans package.json.

Afin de sauvegarder les données de session, c'est à dire l'objet authenticatedUser contenant juste un username, nous ne devons même pas mettre à jour le fichier /src/utils/session.js. En effet, l'API cookies renvoie un objet du genre {username: "manager"}. Au niveau de l'IHM, le code est donc toujours fonctionnel pour sauvegarder le username grâce à authenticatedUser.

Il ne reste donc qu'à changer le code où nous avons besoin d'une autorisation. Pour l'application de gestion de la pizzeria, il s'agit de la création de pizza.
Veuillez donc mettre à jour App pour enlever la ligne s'occupant de l'authorization header : Authorization: authenticatedUser.token :

tsx
1
const addPizza = async (newPizza: NewPizza) => {
2
try {
3
if (!authenticatedUser) {
4
throw new Error("You must be authenticated to add a pizza");
5
}
6
const options = {
7
method: "POST",
8
body: JSON.stringify(newPizza),
9
headers: {
10
"Content-Type": "application/json",
11
},
12
};
13
// Suite du code ...

Veuillez tester votre dernière version du frontend. Loguez-vous avec l'utilisateur admin (et le password admin).
Ajoutez une pizza et vérifiez qu'elle s'affiche bien.

💭 Comment vérifier le cookie ?
Tout en ayant la fenêtre de votre application ouverte, via Chrome, allez dans vos outils de développeurs : F12.
Puis, dans l'onglet Application, cliquez sur Cookies, vous verrez apparaître http://localhost:8080. Cliquez sur cette URL, et vous verrez vos 2 cookies de session, user.sig et user.
N'hésitez pas à aller décoder la valeur du cookie user sur base64decode en faisant un copier / coller de Value. Vous devriez voir quelque chose apparaître du style {"username":"manager","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Im1hbmFnZXIiLCJpYXQiOjE2NjE3NzUxMDgsImV4cCI6MTc0ODE3NTEwOH0.sAZqq6vbrjCCZZoLH-n8hJKBoXJJJ8jEoupk8xKu5WI"} !

Toujours dans l'onglet Application des outils de développeurs de Chrome, faites un clear des cookies : clic droit sur http://localhost:8080, Clear.
Tentez maintenant d'ajouter une pizza... Ca ne fonctionne plus, et c'est bien normal, car il n'y a plus de token qui est envoyé à l'API !

En cas de souci, vous pouvez utiliser le code du tutoriel cookies.

💭 Notons que cette version de notre frontend pourrait être améliorée. Actuellement, lorsqu'on fait un logout, on n'efface pas le cookie du browser.
Comment feriez vous ?
Vous pourriez par exemple appeler la méthode GET /auths/logout 😉.