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 :
ts1const storeName = 'user';23const setUserSessionData = (user) => {4const storageValue = JSON.stringify(user);5localStorage.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 :
ts1const storeName = 'user';23const getUserSessionData = () => {4const retrievedUser = localStorage.getItem(storeName);5if (!retrievedUser) return;6return 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 :
ts1const storeName = 'user';23const removeSessionData = () => {4localStorage.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 :
URI | Méthode HTTP | Opération |
---|---|---|
auths/login | POST | Vérifier les credentials d'une ressource de type "users" et renvoyer le username et un token JWT si les credentials sont OK |
auths/register | POST | Cré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
) :
tsx1// code existant2const [authenticatedUser, setAuthenticatedUser] =3useState<MaybeAuthenticatedUser>(undefined);4// ...5const registerUser = async (newUser: User) => {6try {7const options = {8method: "POST",9body: JSON.stringify(newUser),10headers: {11"Content-Type": "application/json",12},13};1415const response = await fetch("/api/auths/register", options);1617if (!response.ok)18throw new Error(19`fetch error : ${response.status} : ${response.statusText}`20);2122const createdUser: AuthenticatedUser = await response.json();2324setAuthenticatedUser(createdUser);25console.log("createdUser: ", createdUser);26} catch (err) {27console.error("registerUser::error: ", err);28throw err;29}30}31// reste du code3233const fullPizzaContext: PizzeriaContext = {34addPizza,35pizzas,36setPizzas,37actionToBePerformed,38setActionToBePerformed,39clearActionToBePerformed,40drinks,41registerUser,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 unAuthenticatedUser
ou `undefined).
Pour cela, veuillez mettre à jour /src/types.ts
:
ts1interface PizzeriaContext {2pizzas: Pizza[];3setPizzas: (pizzas: Pizza[]) => void;4actionToBePerformed: boolean;5setActionToBePerformed: (actionToBePerformed: boolean) => void;6clearActionToBePerformed: () => void;7drinks: Drink[];8addPizza: (newPizza: NewPizza) => Promise<void>;9registerUser: (newUser: User) => Promise<void>;10}1112interface User {13username: string;14password: string;15}1617interface AuthenticatedUser {18username: string;19token: string;20}2122type MaybeAuthenticatedUser = AuthenticatedUser | undefined;2324export type {25Pizza,26NewPizza,27Drink,28PizzeriaContext,29User,30AuthenticatedUser,31MaybeAuthenticatedUser,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
:
tsx1import { useState, SyntheticEvent } from "react";2import "./index.css";3import { useNavigate, useOutletContext } from "react-router-dom";4import { PizzeriaContext } from "../../types";56const RegisterPage = () => {7const { registerUser }: PizzeriaContext = useOutletContext();89const navigate = useNavigate();10const [username, setUsername] = useState("");11const [password, setPassword] = useState("");1213const handleSubmit = async (e: SyntheticEvent) => {14e.preventDefault();15try {16await registerUser({ username, password });17navigate("/");18} catch (err) {19console.error("RegisterPage::error: ", err);20}21};2223const handleUsernameInputChange = (e: SyntheticEvent) => {24const input = e.target as HTMLInputElement;25setUsername(input.value);26};2728const handlePasswordChange = (e: SyntheticEvent) => {29const input = e.target as HTMLInputElement;30setPassword(input.value);31};3233return (34<div>35<h1>Ajoutez un utilisateur</h1>36<form onSubmit={handleSubmit}>37<label htmlFor="username">Username</label>38<input39value={username}40type="text"41id="username"42name="username"43onChange={handleUsernameInputChange}44required45/>46<label htmlFor="password">Password</label>47<input48value={password}49type="text"50id="password"51name="password"52onChange={handlePasswordChange}53required54/>55<button type="submit">Créer le compte</button>56</form>57</div>58);59};6061export 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
:
tsx1const router = createBrowserRouter([2{3path: "/",4element: <App />,5children: [6{7path: "",8element: <HomePage />,9},10{11path: "add-pizza",12element: <AddPizzaPage />,13},14{15path: "register",16element: <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 :
tsx1interface NavBarProps {2authenticatedUser: MaybeAuthenticatedUser;3}45const NavBar = ({authenticatedUser} : NavBarProps) => {6const navigate = useNavigate();78if(authenticatedUser) {9return (10<nav>11<button onClick={() => navigate("/")}>Home</button>12<button onClick={() => navigate("/add-pizza")}>Ajouter une pizza</button>13</nav>14);15}1617return (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
:
tsx1return (2<div className="page">3<Header4title="We love Pizza"5version={0 + 1}6handleHeaderClick={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
:
tsx1const loginUser = async (user: User) => {2try {3const options = {4method: "POST",5body: JSON.stringify(user),6headers: {7"Content-Type": "application/json",8},9};1011const response = await fetch("/api/auths/login", options);1213if (!response.ok)14throw new Error(15`fetch error : ${response.status} : ${response.statusText}`16);1718const authenticatedUser: AuthenticatedUser = await response.json();19console.log("authenticatedUser: ", authenticatedUser);2021setAuthenticatedUser(authenticatedUser);22} catch (err) {23console.error("loginUser::error: ", err);24throw err;25}26};2728// Reste du code2930const fullPizzaContext: PizzeriaContext = {31addPizza,32pizzas,33setPizzas,34actionToBePerformed,35setActionToBePerformed,36clearActionToBePerformed,37drinks,38registerUser,39loginUser,40};
Comme le contexte a été mis à jour, nous devons mettre à jour le type associé PizzeriaContext
au sein de /src/types.ts
:
tsinterface 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
:
tsx1import { useState, SyntheticEvent } from "react";2import "./index.css";3import { useNavigate, useOutletContext } from "react-router-dom";4import { PizzeriaContext } from "../../types";56const LoginPage = () => {78const { loginUser } : PizzeriaContext = useOutletContext();910const navigate = useNavigate();11const [username, setUsername] = useState("");12const [password, setPassword] = useState("");1314const handleSubmit = async (e: SyntheticEvent) => {15e.preventDefault();16try {17await loginUser({ username, password });18navigate("/");19} catch (err) {20console.error("LoginPage::error: ", err);21}22};2324const handleUsernameInputChange = (e: SyntheticEvent) => {25const input = e.target as HTMLInputElement;26setUsername(input.value);27};2829const handlePasswordChange = (e: SyntheticEvent) => {30const input = e.target as HTMLInputElement;31setPassword(input.value);32};3334return (35<div>36<h1>Connectez un utilisateur</h1>37<form onSubmit={handleSubmit}>38<label htmlFor="username">Username</label>39<input40value={username}41type="text"42id="username"43name="username"44onChange={handleUsernameInputChange}45required46/>47<label htmlFor="password">Password</label>48<input49value={password}50type="text"51id="password"52name="password"53onChange={handlePasswordChange}54required55/>56<button type="submit">S'authentifier</button>57</form>58</div>59);60};6162export 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
:
tsx1const router = createBrowserRouter([2{3path: "/",4element: <App />,5children: [6{7path: "",8element: <HomePage />,9},10{11path: "add-pizza",12element: <AddPizzaPage />,13},14{15path: "register",16element: <RegisterPage />,17},18{19path: "login",20element: <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 :
tsx1const NavBar = ({authenticatedUser} : NavBarProps) => {2const navigate = useNavigate();34if(authenticatedUser) {5return (6<nav>7<button onClick={() => navigate("/")}>Home</button>8<button onClick={() => navigate("/add-pizza")}>Ajouter une pizza</button>9</nav>10);11}1213return (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 :
tsimport { 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 :
tsx1const registerUser = async (newUser: User) => {2try {3const options = {4method: "POST",5body: JSON.stringify(newUser),6headers: {7"Content-Type": "application/json",8},9};1011const response = await fetch("/api/auths/register", options);1213if (!response.ok)14throw new Error(15`fetch error : ${response.status} : ${response.statusText}`16);1718const createdUser: AuthenticatedUser = await response.json();1920setAuthenticatedUser(createdUser);21storeAuthenticatedUser(createdUser);2223console.log("createdUser: ", createdUser);24} catch (err) {25console.error("registerUser::error: ", err);26throw err;27}28};2930const loginUser = async (user: User) => {31try {32const options = {33method: "POST",34body: JSON.stringify(user),35headers: {36"Content-Type": "application/json",37},38};3940const response = await fetch("/api/auths/login", options);4142if (!response.ok)43throw new Error(44`fetch error : ${response.status} : ${response.statusText}`45);4647const authenticatedUser: AuthenticatedUser = await response.json();48console.log("authenticatedUser: ", authenticatedUser);4950setAuthenticatedUser(authenticatedUser);51storeAuthenticatedUser(authenticatedUser);52} catch (err) {53console.error("loginUser::error: ", err);54throw 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
:
tsxuseEffect(() => {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
:
tsx1//....2const clearUser = () => {3clearAuthenticatedUser();4setAuthenticatedUser(undefined);5}6// ... reste du code7return (8<div className="page">9<Header10title="We love Pizza"11version={0 + 1}12handleHeaderClick={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 :
tsx1interface NavBarProps {2authenticatedUser: MaybeAuthenticatedUser;3clearUser: () => void;4}56const NavBar = ({ authenticatedUser, clearUser }: NavBarProps) => {7const navigate = useNavigate();89if (authenticatedUser) {10return (11<nav>12<button onClick={() => navigate("/")}>Home</button>13<button onClick={() => navigate("/add-pizza")}>14Ajouter une pizza15</button>16<button onClick={() => clearUser()}>Se déconnecter</button>17</nav>18);19}2021return (22<nav>23<button onClick={() => navigate("/")}>Home</button>24<button onClick={() => navigate("/register")}>25Créer un utilisateur26</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 :
tsx1const addPizza = async (newPizza: NewPizza) => {2try {3if(!authenticatedUser) {4throw new Error("You must be authenticated to add a pizza");5}6const options = {7method: "POST",8body: JSON.stringify(newPizza),9headers: {10"Content-Type": "application/json",11Authorization: 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 typeEffacer
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
:
tsx1const addPizza = async (newPizza: NewPizza) => {2try {3if (!authenticatedUser) {4throw new Error("You must be authenticated to add a pizza");5}6const options = {7method: "POST",8body: JSON.stringify(newPizza),9headers: {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
😉.