L'écran de connexion
Notre domaine sait émettre un jeton de session. Il est temps d'arrêter d'écrire un mot de passe en dur dans le code de la page.
Deux états, pas deux applications
Première question : où mettre l'écran de connexion ?
La réponse naturelle serait "sur une autre page, /login". Elle est correcte, et nous la mettrons en œuvre au chapitre suivant, quand nous aurons un routeur.
Mais aujourd'hui, formulons-le autrement : notre application a deux états, connecté ou non. Et l'affichage est une fonction de l'état, comme le disait le chapitre 24 :
UI = render(state)function App() {
const { session, logIn, logOut, error } = useSession();
if (!session) {
return <LoginPage onSubmit={logIn} error={error} />;
}
return <HomePage session={session} onLogOut={logOut} />;
}Sept lignes, et rien d'autre à comprendre. C'est le genre de composant racine qu'on veut pouvoir lire six mois plus tard.
Le hook de session
function useSession() {
const [session, setSession] = useState(() => readStoredSession());
const [error, setError] = useState(null);
const [pending, setPending] = useState(false);
const logIn = async ({ email, password }) => {
setPending(true);
const context = await app.run([login({ email, password })]);
const result = context.session();
setPending(false);
if (result.error) {
setError(result.error);
return;
}
setError(null);
setSession(result);
storeSession(result);
};
const logOut = async () => {
await app.run([logout(session.token)]);
setSession(null);
storeSession(null);
};
return { session, error, pending, logIn, logOut };
}Remarquez ce que le hook n'expose pas : le mot de passe. Il entre dans logIn, il part dans la commande, il n'est jamais rangé nulle part. Un mot de passe qui ne survit pas à l'appel de fonction ne peut pas fuir.
Et remarquez surtout que logIn est une action, au sens du chapitre 22 : elle invoque une commande, elle peut échouer, et son échec doit s'afficher. C'est le même schéma que le bouton "Réserver", avec un formulaire à la place.
Le formulaire
function LoginPage({ onSubmit, error, pending }) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = (event) => {
event.preventDefault();
onSubmit({ email, password });
};
return (
<form className="login" onSubmit={handleSubmit}>
<h1>Se connecter</h1>
<label htmlFor="email">Adresse email</label>
<input id="email" name="email" type="email" autoComplete="username"
value={email} onChange={(e) => setEmail(e.target.value)} required />
<label htmlFor="password">Mot de passe</label>
<input id="password" name="password" type="password" autoComplete="current-password"
value={password} onChange={(e) => setPassword(e.target.value)} required />
{error && <p role="alert" className="error">{error}</p>}
<button type="submit" disabled={pending}>
{pending ? "Connexion…" : "Se connecter"}
</button>
</form>
);
}Ce composant a l'air banal. Il contient pourtant six décisions que l'on voit rater tous les jours.
Un vrai <form>, avec un onSubmit. Pas un <div> avec un bouton et un onClick. La différence : la touche Entrée valide, les gestionnaires de mots de passe reconnaissent le formulaire, les lecteurs d'écran l'annoncent comme tel, et le navigateur propose l'enregistrement des identifiants. Un <div> cliquable ne fait rien de tout cela.
event.preventDefault(). Sans lui, le navigateur soumet le formulaire à la façon de 1997 : rechargement complet de la page, et votre état disparaît. Oublier cette ligne produit un bug très caractéristique — "ça marche, mais l'écran clignote et rien ne se passe".
Des <label htmlFor> reliés aux champs par leur id. Un placeholder n'est pas un label : il disparaît dès la première frappe, et il est ignoré par une partie des technologies d'assistance. Le htmlFor a aussi un effet très concret pour tout le monde : cliquer sur le texte place le curseur dans le champ.
Les attributs autoComplete. username sur l'email, current-password sur le mot de passe (et new-password sur un formulaire d'inscription). Ce sont les valeurs standardisées que les gestionnaires de mots de passe attendent. Sans elles, ils remplissent mal, ou pas du tout, et vos utilisateurs saisissent leur mot de passe à la main — donc en choisissent un plus court.
type="password". Évident. Ce qui l'est moins : type="email" sur l'autre champ fait apparaître un clavier adapté sur mobile, et donne une première validation gratuite.
disabled={pending}. Nous invoquons une commande. Le chapitre 4 nous a prévenus : relancer une commande en cours peut donner un résultat incertain. Ici, deux clics créent deux sessions. Sur le bouton "Réserver", deux clics créeraient deux réservations — et c'est un vrai problème que nous corrigeons dans la foulée :
<button className="book-button" onClick={onBook} disabled={booking}>
{booking ? "Réservation…" : "Réserver"}
</button>Le role="alert" sur le message d'erreur, enfin, fait annoncer le texte par un lecteur d'écran dès son apparition. Sans lui, un utilisateur non-voyant clique sur "Se connecter" et n'obtient rien : le message existe, mais personne ne le lui dit.
Où ranger le jeton ?
Question suivante, et elle est plus délicate qu'elle n'en a l'air : après un F5, l'utilisateur doit-il se reconnecter ?
Si nous gardons le jeton dans un useState, oui. Il disparaît au rechargement.
D'où la tentation, universelle :
function storeSession(session) {
if (session) localStorage.setItem("session", JSON.stringify(session));
else localStorage.removeItem("session");
}
function readStoredSession() {
const stored = localStorage.getItem("session");
return stored ? JSON.parse(stored) : null;
}Ça marche. Et il faut savoir ce que ça coûte.
localStorage est lisible par tout le JavaScript qui s'exécute sur votre page. Le vôtre, mais aussi celui d'une dépendance npm compromise, d'un script d'analytics, ou d'une faille XSS dans un champ de commentaire. Une seule de ces trois choses suffit pour que le jeton parte ailleurs.
La bonne réponse, dès qu'il y a un vrai serveur, est un cookie :
Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Max-Age=604800HttpOnly: le JavaScript de la page ne peut pas le lire. C'est tout l'intérêt.Secure: il ne part qu'en HTTPS.SameSite=Lax: il n'est pas envoyé depuis un site tiers, ce qui coupe la plupart des attaques CSRF.
Le frontend ne voit alors jamais le jeton : le navigateur le joint tout seul à chaque requête. C'est ce que nous mettrons en place au chapitre 40, quand le domaine passera derrière une API.
En attendant, localStorage est un pis-aller assumé et temporaire. Écrivez-le en commentaire dans le code, sans quoi il sera encore là dans deux ans.
Une déconnexion qui déconnecte vraiment
const logOut = async () => {
await app.run([logout(session.token)]);
setSession(null);
storeSession(null);
};Pourquoi une commande logout, alors qu'effacer le jeton côté client suffit à faire disparaître l'écran ?
Parce que le jeton effacé côté client fonctionne toujours côté serveur. Il reste valable jusqu'à son expiration, et quiconque en a une copie — un ordinateur partagé, un historique, un log — peut continuer à l'utiliser.
Se déconnecter, ce n'est pas oublier le ticket. C'est le faire annuler au vestiaire.
export function logout(token) {
return async function (dependencies, context) {
await dependencies.sessions.destroy(token);
return context;
};
}Notez qu'elle ne retourne aucune erreur, même si le jeton est inconnu. Une déconnexion réussit toujours : c'est le seul cas où l'idempotence prime sur la vérification. Refuser de déconnecter quelqu'un serait un comble.
Le bouton "Réserver" enfin honnête
Le mot de passe en dur peut disparaître :
async function action(session, accommodationId, criteria) {
const context = await app.run([
authenticate(session.token),
book({
accommodationId,
adults: criteria.adults,
children: criteria.children,
from: criteria.from,
to: criteria.to,
}),
]);
return context.session();
}Deux progrès d'un coup, et le second est passé inaperçu : les dates et le nombre de voyageurs viennent maintenant du bandeau de recherche. Jusqu'ici, l'utilisateur cherchait un logement pour six personnes en août et réservait pour cinq personnes en juin. La démo ne le montrait pas, parce que personne ne changeait les champs.
Et si la session a expiré entre l'affichage de la page et le clic, authenticate échoue, book n'est pas exécutée, et le contexte revient avec InvalidSession. Il ne reste plus qu'à réagir :
const result = await action(session, accommodation.id, criteria);
if (result.error === "Invalid or expired session") {
logOut(); // on renvoie proprement vers l'écran de connexion
return;
}Ce if sur le texte du message est laid, et nous ne le garderons pas. Il pose une vraie question — comment un appelant distingue-t-il quel type d'erreur il a reçu ? — dont nous ferons le sujet du chapitre 42, quand les erreurs devront en plus traverser le réseau.
Ce que cet écran ne protège pas
Terminons par une mise au point, parce qu'un développeur qui se trompe là-dessus construit des applications en carton.
Rien de ce que nous venons d'écrire n'est de la sécurité.
Notre domaine s'exécute dans le navigateur. N'importe qui peut ouvrir la console et taper :
app.run([book({ accommodationId: "accommodation-1", ... })]);Il obtiendra une erreur — shouldBeLogged — parce que la commande vérifie le contexte. Bien. Mais rien ne l'empêche de fabriquer lui-même un contexte contenant l'utilisateur de son choix, puisque tout le code est chez lui.
L'écran de connexion, ici, ne protège rien. Il organise l'accès : il sait qui vous êtes pour vous montrer ce qui vous concerne.
La sécurité, elle, commence le jour où la commande s'exécute sur une machine que l'utilisateur ne contrôle pas. C'est-à-dire au chapitre 40.
Ce n'est pas du travail perdu, au contraire : la commande book vérifie déjà context.loggedUser, et cette vérification-là est au bon endroit depuis le chapitre 11. Le jour où le domaine passera derrière une API, il n'y aura pas une ligne de règle d'autorisation à réécrire. Seul l'endroit d'exécution changera.
C'est très exactement ce que promettait la clean architecture. Il est agréable de le constater.
Notre utilisateur est connecté. Il a réservé. Il voudrait maintenant revoir sa réservation.