Ton API sert la même to-do à tout le monde. Dans ce tuto, chacun aura son compte et ses tâches : inscription, connexion, mots de passe hachés, cookie de session. C'est l'étape où on apprend à se méfier de tout ce qui arrive du navigateur.
Durée 2 semainesNiveau étapes 1 à 5 du backend faitesProjet la to-do, avec des comptesMis à jour
À la fin, tu sauras
Expliquer la différence entre authentification et autorisation
Stocker des mots de passe hachés et salés, jamais en clair
Créer des sessions avec un cookie httpOnly, et savoir à quoi sert chaque option
Protéger des routes avec un middleware, et isoler les données de chaque utilisateur
Brancher une page de connexion sur ton API
Repérer les failles classiques : XSS, injection SQL, force brute, secrets dans Git
Les missions
Tout se passe dans ton projet api-taches, dans l'ordre. Garde le terminal du serveur à l'œil : quand une requête échoue, l'erreur exacte est là.
Mission 0
Pour l'instant, ton API est une to-do partagée avec la planète : n'importe qui qui connaît l'adresse voit et supprime toutes les tâches. On va y mettre des comptes. Deux mots à ne pas confondre :
Authentification : prouver qui tu es. « Je suis Alex, voici mon mot de passe. »
Autorisation : ce que t'as le droit de faire. « Alex peut voir et modifier les tâches d'Alex. Pas celles de Sam. »
Problème : HTTP n'a aucune mémoire. Chaque requête arrive seule, le serveur ne sait pas qu'elle vient de la même personne que la précédente. On ne va pas renvoyer le mot de passe à chaque clic. La solution classique, c'est le cookie de session :
Le principe
1. Navigateur → POST /api/auth/login { "email": "...", "password": "..." }
2. Serveur : le mot de passe est bon. Il tire un jeton au hasard (8f3c...),
le range dans la table sessions avec l'id d'Alex.
3. Serveur → 200 OK
Set-Cookie: session=8f3c...; HttpOnly; SameSite=Lax
4. Navigateur : garde le cookie et le renvoie tout seul à chaque requête
GET /api/tasks
Cookie: session=8f3c...
5. Serveur : cherche 8f3c... dans sessions → c'est Alex → il renvoie SES tâches
Le jeton, c'est un ticket de vestiaire : il ne contient rien, juste un numéro au hasard. C'est le serveur qui sait à qui il appartient.
Celui qui a le ticket récupère le manteau. Si quelqu'un vole le jeton, il est connecté à ta place. D'où toutes les précautions de la mission 4.
Un cookie, c'est un petit texte que le serveur demande au navigateur de garder (Set-Cookie) et que le navigateur renvoie tout seul (Cookie) au même site.
Tu entendras parler de JWT : une autre façon de faire, plus compliquée à sécuriser. Les sessions en base sont plus simples et très solides : c'est ce qu'on fait.
Va voir en vrai : ouvre un site où t'es connecté (GitHub, par exemple), F12 → onglet Application (Chrome) ou Stockage (Firefox) → Cookies.
Erreur fréquente
Croire que cacher un bouton protège quelque chose. Si le bouton « Supprimer » n'apparaît pas mais que la route DELETE ne vérifie rien, n'importe qui l'appelle avec curl. La sécurité se fait sur le serveur, à chaque requête. Le front, c'est du confort.
Demande à l'IA
Explique-moi la différence entre authentification et autorisation, puis comment un cookie de session permet à un serveur de se souvenir de moi alors que HTTP n'a pas de mémoire. Utilise une analogie de la vie de tous les jours, sans écrire de code.
Résultat attendu
Outils de développement > Application > Cookies (exemple)
Name Value Domain HttpOnly Secure SameSite
user_session xY3k... github.com ✓ ✓ Lax
Sur un site où t'es connecté, au moins un cookie de session, coché HttpOnly. Les noms et les valeurs changent d'un site à l'autre. Supprime-le et recharge : t'es déconnecté.
Mission 1
Il nous faut une table users, et une colonne user_id dans tasks pour savoir à qui est chaque tâche. On va repartir d'une base vide, pour deux raisons :
CREATE TABLE IF NOT EXISTS ne touche pas à une table qui existe déjà. Ton ancienne table tasks resterait sans user_id.
Tes tâches actuelles n'appartiennent à personne. Or user_id sera obligatoire (NOT NULL).
Dans la vraie vie, on ne jette pas les données des clients : on écrit des migrations, des petits scripts numérotés (ALTER TABLE ...) qui font évoluer la base pas à pas. Pour un projet d'apprentissage, repartir de zéro suffit.
Terminal
# 1. Arrête le serveur : Ctrl+C dans le terminal où tourne npm run dev
# 2. Supprime l'ancienne base (sur Windows, dans PowerShell : del data.db)
$ rm data.db
# 3. Remplace db.js par la version ci-dessous, puis relance
$ npm run dev
db.js
import { DatabaseSync } from "node:sqlite";
export const db = new DatabaseSync("data.db");
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
text TEXT NOT NULL,
done INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`);
email TEXT NOT NULL UNIQUE : la base refuse deux comptes avec le même email, même si ton code oublie de vérifier.
password_hash : pas password. On ne stockera jamais le mot de passe lui-même (mission 2).
REFERENCES users(id) : une clé étrangère. Impossible de créer une tâche pour un utilisateur qui n'existe pas.
ON DELETE CASCADE : si un compte est supprimé, ses tâches partent avec.
node:sqlite active les clés étrangères tout seul. Avec d'autres outils SQLite, il faut PRAGMA foreign_keys = ON;, sinon REFERENCES est ignoré.
Erreur fréquente
table tasks has no column named user_id ou no such column: user_id : l'ancienne data.db est toujours là. Arrête le serveur, supprime data.db, relance. Sur Windows, « fichier utilisé par un autre processus » : le serveur tourne encore, ou SQLite Viewer a le fichier ouvert.
$ curl -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{"text":"Test"}'
{"error":"Erreur serveur"}
# Et dans le terminal du serveur :
Error: NOT NULL constraint failed: tasks.user_id
Les deux tables sont là, tasks a sa colonne user_id. La 500 est normale : une tâche doit maintenant avoir un propriétaire, et ta route ne lui en donne pas encore. On répare ça en mission 5. (sqlite_sequence est une table interne de SQLite pour AUTOINCREMENT : n'y touche pas.)
Mission 2
Des bases de données fuitent tout le temps, même chez les gros. Si les mots de passe sont en clair, tout le monde les lit, et comme les gens réutilisent les mêmes partout, ça ouvre leurs mails, leur banque... Donc on stocke une empreinte (un hash) : un calcul qui marche dans un sens seulement. On ne peut pas retrouver le mot de passe à partir du hash, mais on peut vérifier qu'un mot de passe tapé donne le même hash.
Crée auth.js à la racine du projet. Tout vient de node:crypto, intégré à Node : rien à installer.
auth.js
import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto";
// Réglages de scrypt : un des jeux de valeurs conseillés par l'OWASP.
// Ne les change plus une fois que des comptes existent.
const SCRYPT = { N: 16384, r: 8, p: 5 };
const KEYLEN = 64; // longueur du hash, en octets
export function hashPassword(password) {
const salt = randomBytes(16).toString("hex");
const hash = scryptSync(password, salt, KEYLEN, SCRYPT).toString("hex");
return `${salt}:${hash}`;
}
export function verifyPassword(password, stored) {
const [salt, hash] = stored.split(":");
const expected = Buffer.from(hash, "hex");
const actual = scryptSync(password, salt, KEYLEN, SCRYPT);
return timingSafeEqual(actual, expected);
}
scryptSync : un algorithme de hachage fait exprès pour être lent et gourmand en mémoire. SHA-256, lui, est beaucoup trop rapide : une carte graphique en teste des milliards par seconde. Avec scrypt, chaque essai coûte cher à l'attaquant.
randomBytes(16) : le sel, 16 octets au hasard, différent pour chaque compte. Deux personnes avec le même mot de passe ont des hash différents, et les tables de hash précalculées ne servent à rien.
`${salt}:${hash}` : on range le sel avec le hash, en hexadécimal, dans password_hash. Le sel n'est pas secret, il faut juste le garder pour vérifier.
verifyPassword : refait le calcul avec le même sel et compare. timingSafeEqual compare en temps constant, alors que === s'arrête au premier caractère différent : le temps de réponse pourrait donner des indices.
scryptSync bloque le serveur le temps du calcul (une centaine de millisecondes). Pour apprendre, c'est parfait. Un gros site prendrait la version asynchrone scrypt.
Teste avec un fichier d'essai, comme à l'étape 4 :
essais-auth.js
import { hashPassword, verifyPassword } from "./auth.js";
const stored = hashPassword("motdepasse123");
console.log("Stocké :", stored);
console.log("Même mot de passe, autre hash :", hashPassword("motdepasse123"));
console.log("Bon mot de passe :", verifyPassword("motdepasse123", stored));
console.log("Mauvais mot de passe :", verifyPassword("MotDePasse123", stored));
Erreur fréquente
Input buffers must have the same byte length : le hash stocké n'a pas la même longueur que celui calculé. Tu as changé KEYLEN après avoir créé des comptes (ou modifié un hash à la main). Même chose si tu changes SCRYPT : les anciens mots de passe ne passent plus. Choisis tes réglages une fois pour toutes.
Demande à l'IA
Explique-moi pourquoi on hache un mot de passe au lieu de le chiffrer, à quoi sert le sel, et pourquoi un algorithme lent comme scrypt est meilleur que SHA-256 pour ça. Pas de code, juste les idées, avec des exemples concrets.
Résultat attendu
Terminal
$ node essais-auth.js
Stocké : 33b40378d4a53baadad5cc500ea30e0f:762d7e9aa0fc85d051c9e0fd6c68189e2435100662b790c5964cddecd997835ade657e0c7828c800e5920abbc223d0e08c3f37cf8e333ff9f245b2da1e455219
Même mot de passe, autre hash : 8736231580aaf6d230bcd79adb12f97f:fc514880bb4cdccdf17d81ed75d732dae73b77694852d54302ed4c7de157c9a5b90ed153f04640364d587403e92299fa271b66e00712fb0331fa927ad06f54a0
Bon mot de passe : true
Mauvais mot de passe : false
Le même mot de passe donne deux résultats différents : c'est le sel. 32 caractères de sel, deux-points, 128 caractères de hash. Les tiens seront différents, et c'est ce qui est rassurant.
Mission 3
Première route de comptes. Crée routes/auth.js, à côté de routes/tasks.js :
routes/auth.js
import { Router } from "express";
import { db } from "../db.js";
import { hashPassword } from "../auth.js";
const router = Router();
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
router.post("/register", (req, res) => {
const { email, password } = req.body ?? {};
if (typeof email !== "string" || !EMAIL_REGEX.test(email.trim())) {
return res.status(400).json({ error: "Email invalide" });
}
if (typeof password !== "string" || password.length < 8) {
return res.status(400).json({ error: "Le mot de passe doit faire au moins 8 caractères" });
}
const cleanEmail = email.trim().toLowerCase();
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(cleanEmail);
if (existing) {
return res.status(409).json({ error: "Un compte existe déjà avec cet email" });
}
const result = db.prepare("INSERT INTO users (email, password_hash) VALUES (?, ?)")
.run(cleanEmail, hashPassword(password));
res.status(201).json({ id: result.lastInsertRowid, email: cleanEmail });
});
export default router;
Puis branche-le dans server.js :
server.js
// En haut, avec les autres import
import authRouter from "./routes/auth.js";
// Juste au-dessus de app.use("/api/tasks", tasksRouter);
app.use("/api/auth", authRouter);
req.body ?? {} : si la requête n'a pas de corps JSON, req.body vaut undefined dans Express 5. On évite le plantage.
typeof ... !== "string" : on vérifie les types. Quelqu'un peut envoyer {"password": 12345678} ou un tableau, pas seulement ton formulaire.
EMAIL_REGEX : une vérification grossière (quelque chose, @, quelque chose, point, quelque chose). La seule vraie preuve qu'un email existe, c'est d'y envoyer un message de confirmation.
.trim().toLowerCase() : « Alex@exemple.com » et « alex@exemple.com » sont le même compte.
409 Conflict : le code pour « ça existe déjà ». Si deux inscriptions arrivent pile en même temps, le UNIQUE de la base bloque la seconde (elle finit en 500, mais aucun doublon).
La réponse contient id et email, jamaispassword_hash.
Terminal
$ curl -i -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"email":"Alex@exemple.com","password":"motdepasse123"}'
# Le même email une deuxième fois
$ curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"email":"alex@exemple.com","password":"autrechose99"}'
# Un mot de passe trop court
$ curl -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"email":"sam@exemple.com","password":"court"}'
Sur Windows, tape ces commandes dans Git Bash (installé avec Git) : les guillemets simples autour du JSON y marchent comme sur Mac.
Erreur fréquente
« Email invalide » alors que l'email est bon : t'as oublié -H "Content-Type: application/json". Sans lui, express.json() ne lit pas le corps, req.body est vide.
Résultat attendu
Terminal
$ curl -i -X POST http://localhost:3000/api/auth/register ... "Alex@exemple.com" ...
HTTP/1.1 201 Created
Content-Type: application/json; charset=utf-8
...
{"id":1,"email":"alex@exemple.com"}
$ curl -X POST http://localhost:3000/api/auth/register ... "alex@exemple.com" ...
{"error":"Un compte existe déjà avec cet email"}
$ curl -X POST http://localhost:3000/api/auth/register ... "court" ...
{"error":"Le mot de passe doit faire au moins 8 caractères"}
L'email ressort en minuscules. Dans SQLite Viewer, la ligne d'Alex a un password_hash en sel:hash, et nulle part « motdepasse123 ».
Mission 4
Un compte, c'est bien. Rester connecté, c'est mieux. Installe cookie-parser, qui lit les cookies des requêtes et les range dans req.cookies. Puis ajoute la table des sessions dans db.js, dans le même db.exec, après tasks :
db.js
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
expires_at INTEGER NOT NULL
);
Pas besoin de supprimer data.db cette fois : c'est une nouvelle table, IF NOT EXISTS la crée au prochain démarrage.
Voici routes/auth.js complet. L'inscription connecte maintenant directement, et trois routes arrivent :
routes/auth.js
import { Router } from "express";
import { randomBytes } from "node:crypto";
import { db } from "../db.js";
import { hashPassword, verifyPassword } from "../auth.js";
const router = Router();
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const SESSION_DURATION = 30 * 24 * 60 * 60 * 1000; // 30 jours, en millisecondes
const cookieOptions = {
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
};
// Crée une session en base et pose le cookie dans la réponse
function startSession(res, userId) {
// Au passage, on fait le ménage dans les sessions expirées
db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(Date.now());
const token = randomBytes(32).toString("hex");
db.prepare("INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)")
.run(token, userId, Date.now() + SESSION_DURATION);
res.cookie("session", token, { ...cookieOptions, maxAge: SESSION_DURATION });
}
router.post("/register", (req, res) => {
const { email, password } = req.body ?? {};
if (typeof email !== "string" || !EMAIL_REGEX.test(email.trim())) {
return res.status(400).json({ error: "Email invalide" });
}
if (typeof password !== "string" || password.length < 8) {
return res.status(400).json({ error: "Le mot de passe doit faire au moins 8 caractères" });
}
const cleanEmail = email.trim().toLowerCase();
const existing = db.prepare("SELECT id FROM users WHERE email = ?").get(cleanEmail);
if (existing) {
return res.status(409).json({ error: "Un compte existe déjà avec cet email" });
}
const result = db.prepare("INSERT INTO users (email, password_hash) VALUES (?, ?)")
.run(cleanEmail, hashPassword(password));
// Nouveau : connecté directement après l'inscription
startSession(res, result.lastInsertRowid);
res.status(201).json({ id: result.lastInsertRowid, email: cleanEmail });
});
router.post("/login", (req, res) => {
const { email, password } = req.body ?? {};
if (typeof email !== "string" || typeof password !== "string") {
return res.status(400).json({ error: "Email et mot de passe obligatoires" });
}
const user = db.prepare("SELECT id, email, password_hash FROM users WHERE email = ?")
.get(email.trim().toLowerCase());
// Même réponse que l'email ou le mot de passe soit faux
if (!user || !verifyPassword(password, user.password_hash)) {
return res.status(401).json({ error: "Email ou mot de passe incorrect" });
}
startSession(res, user.id);
res.json({ id: user.id, email: user.email });
});
router.post("/logout", (req, res) => {
const token = req.cookies.session;
if (typeof token === "string") {
db.prepare("DELETE FROM sessions WHERE token = ?").run(token);
}
res.clearCookie("session", cookieOptions);
res.status(204).end();
});
router.get("/me", (req, res) => {
const token = req.cookies.session;
if (typeof token !== "string") {
return res.status(401).json({ error: "Connecte-toi d'abord" });
}
const user = db.prepare(`
SELECT users.id, users.email
FROM sessions
JOIN users ON users.id = sessions.user_id
WHERE sessions.token = ? AND sessions.expires_at > ?
`).get(token, Date.now());
if (!user) {
return res.status(401).json({ error: "Session expirée, reconnecte-toi" });
}
res.json(user);
});
export default router;
Dans server.js, deux lignes. cookieParser() doit passer avant les routes :
server.js
// En haut, avec les autres import
import cookieParser from "cookie-parser";
// Juste après app.use(express.json());
app.use(cookieParser()); // lit les cookies et les range dans req.cookies
randomBytes(32).toString("hex") : 64 caractères tirés au hasard par un générateur fait pour la sécurité. Impossible à deviner. N'utilise jamaisMath.random() pour ça : il est prévisible.
httpOnly: true : le JavaScript de la page ne peut pas lire ce cookie (document.cookie ne le montre pas). Un script malveillant injecté ne peut donc pas le voler.
sameSite: "lax" : le navigateur n'envoie pas le cookie quand un autre site fait une requête vers le tien (un formulaire piégé qui POST sur ton API, par exemple). Il l'envoie seulement si on clique sur un lien vers ton site. Ça bloque l'attaque CSRF, à condition qu'aucune route GET ne modifie de données.
secure : cookie envoyé seulement en HTTPS. En local tu es en http, donc on ne l'active qu'en production (étape 7).
maxAge : en millisecondes pour Express. La date d'expiration est aussi en base (expires_at) : c'est elle qui fait foi, un cookie peut être trafiqué.
Connexion : le même message que l'email soit inconnu ou le mot de passe faux. Sinon, tu aides un attaquant à trouver quels emails ont un compte.
Déconnexion : on supprime la session en base, pas seulement le cookie. Un jeton volé devient inutile. clearCookie reprend les mêmes options que res.cookie, sinon le navigateur ne l'efface pas.
typeof token !== "string" : cookie-parser transforme un cookie qui commence par j: en objet. Ce test évite qu'un petit malin fasse planter la requête SQL.
Terminal
$ npm install cookie-parser
# Connexion : -c range les cookies reçus dans le fichier alex.txt
$ curl -i -c alex.txt -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"alex@exemple.com","password":"motdepasse123"}'
# -b renvoie les cookies du fichier : le serveur sait que c'est Alex
$ curl -b alex.txt http://localhost:3000/api/auth/me
# Sans cookie
$ curl http://localhost:3000/api/auth/me
# Mauvais mot de passe
$ curl -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"alex@exemple.com","password":"mauvais123"}'
# Déconnexion, puis on réessaie
$ curl -b alex.txt -c alex.txt -X POST http://localhost:3000/api/auth/logout
$ curl -b alex.txt http://localhost:3000/api/auth/me
Erreur fréquente
Erreur 500 et Cannot read properties of undefined (reading 'session') dans le terminal : req.cookies n'existe pas. T'as oublié app.use(cookieParser()), ou tu l'as mis aprèsapp.use("/api/auth", authRouter). Et si /me répond 401 avec curl alors que la connexion a marché : vérifie le -c au login et le -b ensuite.
Demande à l'IA
Explique-moi à quoi servent les options httpOnly, sameSite et secure d'un cookie de session, et quelle attaque chacune empêche (XSS, CSRF, écoute du réseau). Donne un scénario d'attaque concret pour chaque, sans écrire de code.
Résultat attendu
Terminal
$ curl -i -c alex.txt -X POST http://localhost:3000/api/auth/login ...
HTTP/1.1 200 OK
Set-Cookie: session=41cb07d464da92e19687ab4047675bd89c68d10a95e2d304fd3f185b685cba6a; Max-Age=2592000; Path=/; Expires=Sat, 07 Nov 2026 06:36:09 GMT; HttpOnly; SameSite=Lax
Content-Type: application/json; charset=utf-8
...
{"id":1,"email":"alex@exemple.com"}
$ curl -b alex.txt http://localhost:3000/api/auth/me
{"id":1,"email":"alex@exemple.com"}
$ curl http://localhost:3000/api/auth/me
{"error":"Connecte-toi d'abord"}
$ curl -X POST http://localhost:3000/api/auth/login ... "mauvais123" ...
{"error":"Email ou mot de passe incorrect"}
$ curl -b alex.txt -c alex.txt -X POST http://localhost:3000/api/auth/logout
$ curl -b alex.txt http://localhost:3000/api/auth/me
{"error":"Connecte-toi d'abord"}
Le Set-Cookie contient un jeton de 64 caractères, Max-Age=2592000 (30 jours en secondes), HttpOnly et SameSite=Lax. Pas de Secure en local : il n'apparaîtra qu'en production. Dans SQLite Viewer, la table sessions a une ligne par connexion, et perd la sienne à la déconnexion.
Mission 5
Le bout de code de /me qui retrouve l'utilisateur à partir du cookie, on en a besoin dans toutes les routes des tâches. On le range dans un middleware : une fonction qui passe avant la route, et qui la laisse passer (next()) ou non. Crée middleware/requireAuth.js :
middleware/requireAuth.js
import { db } from "../db.js";
export function requireAuth(req, res, next) {
const token = req.cookies.session;
if (typeof token !== "string") {
return res.status(401).json({ error: "Connecte-toi d'abord" });
}
const user = db.prepare(`
SELECT users.id, users.email
FROM sessions
JOIN users ON users.id = sessions.user_id
WHERE sessions.token = ? AND sessions.expires_at > ?
`).get(token, Date.now());
if (!user) {
return res.status(401).json({ error: "Session expirée, reconnecte-toi" });
}
req.user = user; // les routes suivantes savent qui parle
next();
}
Dans routes/auth.js, /me devient tout petit :
routes/auth.js
// En haut, avec les autres import
import { requireAuth } from "../middleware/requireAuth.js";
// Remplace toute l'ancienne route /me par :
router.get("/me", requireAuth, (req, res) => {
res.json(req.user);
});
Et voici ton routes/tasks.js de l'étape 4, complet, avec le contrôle en plus. Regarde chaque requête SQL : il y a toujoursuser_id dedans.
routes/tasks.js
import express from "express";
import { db } from "../db.js";
import { requireAuth } from "../middleware/requireAuth.js"; // nouveau
const router = express.Router();
// Nouveau : toutes les routes de ce fichier exigent d'être connecté
router.use(requireAuth);
// SQLite range done en 0 ou 1 : on renvoie un vrai booléen, comme avant
function toTask(row) {
return { id: row.id, text: row.text, done: row.done === 1 };
}
// Renvoie un message d'erreur, ou null si le texte est valide
function checkText(text) {
if (typeof text !== "string" || text.trim() === "") {
return "Le texte est obligatoire";
}
if (text.trim().length > 200) {
return "200 caractères maximum";
}
return null;
}
// GET /api/tasks et GET /api/tasks?done=true
router.get("/", (req, res) => {
let rows;
if (req.query.done !== undefined) {
const done = req.query.done === "true" ? 1 : 0;
rows = db.prepare("SELECT * FROM tasks WHERE user_id = ? AND done = ? ORDER BY id")
.all(req.user.id, done);
} else {
rows = db.prepare("SELECT * FROM tasks WHERE user_id = ? ORDER BY id").all(req.user.id);
}
res.json(rows.map(toTask));
});
// GET /api/tasks/3
router.get("/:id", (req, res) => {
const row = db.prepare("SELECT * FROM tasks WHERE id = ? AND user_id = ?")
.get(Number(req.params.id), req.user.id);
if (!row) {
return res.status(404).json({ error: "Tâche introuvable" });
}
res.json(toTask(row));
});
// POST /api/tasks corps : { "text": "..." }
router.post("/", (req, res) => {
const { text } = req.body ?? {};
const error = checkText(text);
if (error) {
return res.status(400).json({ error });
}
// Le propriétaire vient de la session, jamais du corps de la requête
const result = db.prepare("INSERT INTO tasks (user_id, text) VALUES (?, ?)")
.run(req.user.id, text.trim());
const row = db.prepare("SELECT * FROM tasks WHERE id = ?").get(result.lastInsertRowid);
res.status(201).json(toTask(row));
});
// PATCH /api/tasks/3 corps : { "text": "..." } et/ou { "done": true }
router.patch("/:id", (req, res) => {
const id = Number(req.params.id);
const { text, done } = req.body ?? {};
if (text === undefined && done === undefined) {
return res.status(400).json({ error: "Rien à modifier : envoie text et/ou done" });
}
if (text !== undefined) {
const error = checkText(text);
if (error) return res.status(400).json({ error });
}
if (done !== undefined && typeof done !== "boolean") {
return res.status(400).json({ error: "done doit valoir true ou false" });
}
// COALESCE(?, text) : si on envoie null, la colonne garde sa valeur
const result = db
.prepare("UPDATE tasks SET text = COALESCE(?, text), done = COALESCE(?, done) WHERE id = ? AND user_id = ?")
.run(
text === undefined ? null : text.trim(),
done === undefined ? null : done ? 1 : 0,
id,
req.user.id
);
if (result.changes === 0) {
return res.status(404).json({ error: "Tâche introuvable" });
}
const row = db.prepare("SELECT * FROM tasks WHERE id = ? AND user_id = ?").get(id, req.user.id);
res.json(toTask(row));
});
// DELETE /api/tasks/3
router.delete("/:id", (req, res) => {
const result = db.prepare("DELETE FROM tasks WHERE id = ? AND user_id = ?")
.run(Number(req.params.id), req.user.id);
if (result.changes === 0) {
return res.status(404).json({ error: "Tâche introuvable" });
}
res.status(204).end();
});
export default router;
router.use(requireAuth) : toutes les routes du fichier passent par le contrôle. Impossible d'en oublier une, même celles que tu ajouteras plus tard.
req.user : rempli par le middleware. Les routes savent qui parle, sans relire le cookie.
WHERE id = ? AND user_id = ? : c'est l'autorisation. Une tâche d'un autre, pour la base, c'est comme si elle n'existait pas.
404 et pas 403 : on ne confirme même pas que la tâche 1 existe. Moins on en dit à un curieux, mieux c'est.
Le user_id d'une nouvelle tâche vient de req.user.id, jamais de req.body. Sinon, n'importe qui crée des tâches chez les autres.
401 : « je ne sais pas qui tu es » (pas connecté). 403 : « je sais qui tu es, mais c'est non ».
Le test qui compte : deux comptes, et chacun chez soi. -c range les cookies reçus dans un fichier, -b les renvoie.
Terminal
# Deux comptes, deux fichiers de cookies
$ curl -c alex.txt -X POST http://localhost:3000/api/auth/login -H "Content-Type: application/json" -d '{"email":"alex@exemple.com","password":"motdepasse123"}'
$ curl -c sam.txt -X POST http://localhost:3000/api/auth/register -H "Content-Type: application/json" -d '{"email":"sam@exemple.com","password":"motdepasse456"}'
# Chacun ajoute ses tâches
$ curl -b alex.txt -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{"text":"Acheter du pain"}'
$ curl -b alex.txt -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{"text":"Finir la mission 5"}'
$ curl -b sam.txt -X POST http://localhost:3000/api/tasks -H "Content-Type: application/json" -d '{"text":"Appeler Léa"}'
# Chacun voit ses tâches
$ curl -b alex.txt http://localhost:3000/api/tasks
$ curl -b sam.txt http://localhost:3000/api/tasks
# Sam essaie de toucher à la tâche 1, celle d'Alex
$ curl -b sam.txt http://localhost:3000/api/tasks/1
$ curl -b sam.txt -X PATCH http://localhost:3000/api/tasks/1 -H "Content-Type: application/json" -d '{"done":true}'
$ curl -i -b sam.txt -X DELETE http://localhost:3000/api/tasks/1
# Et sans cookie du tout
$ curl -i http://localhost:3000/api/tasks
Erreur fréquente
Oublier les routes que t'as ajoutées toi-même. GET /api/stats est dans server.js : router.use(requireAuth) ne la protège pas, elle répond encore à tout le monde avec les chiffres de tout le monde. Écris app.get("/api/stats", requireAuth, ...) et ajoute WHERE user_id = ?. Ta recherche ?q= et « Supprimer les tâches faites », s'ils sont dans routes/tasks.js, sont protégés, mais leur SQL doit aussi filtrer par user_id. Sinon Alex supprime les tâches faites de Sam.
À toi
Ajoute une route POST /api/auth/logout-all, « Se déconnecter de partout » (pratique si tu as oublié de te déconnecter chez un ami). Indice : requireAuth te donne req.user.id, une seule requête DELETE sur sessions suffit, et n'oublie pas clearCookie. Teste avec deux fichiers de cookies pour le même compte.
Demande à l'IA
Voici mon fichier routes/tasks.js : [colle ton code]. Sans le réécrire, vérifie avec moi si une seule requête SQL permet à un utilisateur de lire, modifier ou supprimer les tâches d'un autre. Explique ton raisonnement route par route.
Alex voit ses deux tâches, Sam la sienne. La tâche 1 existe bien, mais pour Sam elle est « introuvable » : il ne peut ni la lire, ni la cocher, ni la supprimer. Et Alex peut toujours la cocher, lui.
Mission 6
Côté navigateur, bonne nouvelle : fetch vers le même site (ta page et ton API sont toutes les deux sur localhost:3000) envoie le cookie tout seul. Rien à faire de spécial. Crée public/login.html, avec deux formulaires :
// En haut, avec les autres querySelector
const userEmail = document.querySelector("#user-email");
const logoutBtn = document.querySelector("#logout-btn");
// Ton helper api() de l'étape 5, avec un cas en plus : le 401
async function api(path, options = {}) {
const response = await fetch(path, {
...options,
headers: { "Content-Type": "application/json", ...options.headers },
});
if (response.status === 401) {
// Pas (ou plus) connecté : retour à la page de connexion
location.href = "/login.html";
throw new Error("Connecte-toi d'abord");
}
if (response.status === 204) return null;
const data = await response.json();
if (!response.ok) throw new Error(data.error || "Erreur serveur");
return data;
}
public/script.js
// Tout en bas : remplace l'appel loadTasks(); par ceci
logoutBtn.addEventListener("click", async () => {
await api("/api/auth/logout", { method: "POST" });
location.href = "/login.html";
});
async function start() {
try {
const user = await api("/api/auth/me");
userEmail.textContent = user.email;
loadTasks();
} catch (error) {
showError(error); // ta fonction de l'étape 5 (serveur éteint, etc.)
}
}
start();
/api/auth/me au démarrage : le cookie est httpOnly, ton JavaScript ne peut pas le lire. Pour savoir si t'es connecté, on demande au serveur.
Le 401 dans api() : si la session expire pendant que la page est ouverte, le prochain clic renvoie vers la connexion.
index.html reste accessible sans compte, et c'est normal : le HTML n'a rien de secret. Ce qui est protégé, ce sont les données, côté API.
autocomplete="current-password" et "new-password" : les gestionnaires de mots de passe comprennent quoi remplir, et proposent un mot de passe solide à l'inscription.
minlength="8" et required : du confort pour l'utilisateur. Le vrai contrôle, c'est le serveur (mission 3) : n'importe qui peut contourner le HTML.
Les erreurs s'affichent avec textContent, jamais innerHTML (on en reparle à la mission 7).
Erreur fréquente
Tu ouvres login.html avec Live Server (port 5500) : la connexion semble marcher, puis rien. Ta page et ton API ne sont plus sur la même origine, le cookie et CORS s'en mêlent (étape 5). Ouvre toujours http://localhost:3000/login.html, servi par Express.
À toi
Si t'es déjà connecté et que tu ouvres /login.html, renvoie directement vers la to-do. Indice : au chargement de login.js, un fetch("/api/auth/me"), et si response.ok, location.href = "/". Attention : pas de redirection vers /login.html dans ce fichier, sinon la page tourne en boucle.
Résultat attendu
localhost:3000/login.html
Navigateur
http://localhost:3000/ → pas connecté : redirigé vers /login.html
/login.html, mauvais mot de passe → « Email ou mot de passe incorrect »
/login.html, bons identifiants → la to-do, avec « Connecté : alex@exemple.com »
Fenêtre privée, compte de Sam → la to-do de Sam, sans les tâches d'Alex
« Se déconnecter » → retour sur /login.html
Ici, l'aperçu montre la page sans ton CSS, avec l'erreur affichée après un mauvais mot de passe. Dans l'onglet Application > Cookies de localhost, un cookie session coché HttpOnly. Dans la console, document.cookie ne l'affiche pas : c'est voulu.
Mission 7
Ton appli a des comptes : elle devient une cible. Personne ne vise une to-do d'apprentissage, mais ces réflexes, tu les garderas sur tous tes projets. Passe la liste, un point à la fois :
Tout vérifier côté serveur. Types, longueurs, droits. Le front peut être contourné avec curl en dix secondes, tu l'as fait toi-même.
Requêtes préparées partout. Des ?, jamais de ${...} dans du SQL (étape 4). Cherche ${ dans tes fichiers routes/ : ça ne doit apparaître dans aucune requête.
Afficher avec textContent, pas innerHTML. Sinon, une tâche qui contient du HTML est exécutée par le navigateur de celui qui la voit : c'est une faille XSS. httpOnly empêche de voler le cookie, mais un script injecté peut quand même agir en ton nom.
Ne jamais renvoyer password_hash. Ni de SELECT * sur users envoyé tel quel avec res.json. Choisis les colonnes.
Ne jamais écrire de mot de passe dans les logs. Ton logger affiche la méthode et l'URL : parfait. N'y ajoute pas req.body.
Limiter les tentatives de connexion, contre ceux qui essaient des milliers de mots de passe (bonus ci-dessous).
Rien de secret dans Git.data.db contient des emails et des hash : c'est des données personnelles, elle reste dans .gitignore. Et à l'étape 7, les mots de passe de la base iront dans un .env, ignoré lui aussi.
HTTPS en production, avec secure: true sur le cookie. Ce sera automatique à l'étape 7.
Exemple
// Ce que fait ta to-do (fonction render) : le texte reste du texte
text.textContent = task.text;
// À NE JAMAIS FAIRE avec un texte venu d'un utilisateur
text.innerHTML = task.text;
Le test : ajoute une tâche avec ce texte, <img src=x onerror="alert('piraté')">. Avec textContent, il s'affiche tel quel. Avec innerHTML, une alerte s'ouvrirait : à la place, un pirate mettrait du code qui agit avec ta session.
Bonus : limiter les tentatives avec npm install express-rate-limit, dans routes/auth.js :
routes/auth.js
// En haut de routes/auth.js
import { rateLimit } from "express-rate-limit";
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // fenêtre de 15 minutes
limit: 10, // 10 essais par adresse IP dans la fenêtre
message: { error: "Trop de tentatives, réessaie dans 15 minutes" },
});
// La route de connexion passe d'abord par le limiteur
router.post("/login", loginLimiter, (req, res) => {
// ... le reste ne change pas
Honnêtement, il reste des trous connus, que les gros sites bouchent avec plus de travail : l'inscription répond 409, donc un curieux peut tester si un email a un compte. Les jetons sont stockés tels quels en base (on peut stocker leur hash à la place). Pas de confirmation d'email, pas de « mot de passe oublié ». Le Copenhagen Book, dans les ressources, explique tout ça.
Erreur fréquente
data.db a été commitée avant d'être dans .gitignore : Git continue de la suivre. git rm --cached data.db, puis un commit. Elle reste dans l'historique : si le dépôt est public et qu'il y avait de vrais comptes, change ces mots de passe. Côté limiteur : il compte en mémoire (remis à zéro au redémarrage) et par adresse IP. Derrière un hébergeur, il faudra lui dire de lire la vraie IP, on le fait à l'étape 7.
À toi
Ajoute une route DELETE /api/auth/me pour supprimer son compte, et un bouton dans la to-do. Indice : requireAuth, un DELETE FROM users WHERE id = ?, et ON DELETE CASCADE fait le reste (tâches et sessions). Vérifie dans SQLite Viewer. Pour bien faire, redemande le mot de passe avant de supprimer.
Demande à l'IA
Voici mes fichiers routes/auth.js et middleware/requireAuth.js : [colle ton code]. Joue le rôle d'un attaquant : liste les attaques que tu tenterais sur mon système de comptes, et dis-moi pour chacune si mon code la bloque et pourquoi. Ne corrige pas mon code, explique.
Résultat attendu
Navigateur et terminal
Tâche ajoutée : <img src=x onerror="alert('piraté')">
Affichée dans la liste, telle quelle, comme du texte. Aucune alerte.
$ git status --ignored
...
Ignored files:
data.db
node_modules/
Terminal (si tu fais le bonus)
$ npm install express-rate-limit
# 11 mauvaises connexions de suite :
{"error":"Email ou mot de passe incorrect"} (× 10)
HTTP/1.1 429 Too Many Requests
Retry-After: 900
{"error":"Trop de tentatives, réessaie dans 15 minutes"}
Ta tâche piège s'affiche comme un texte bizarre, sans popup. data.db est bien ignoré par Git. Et avec le limiteur, la 11e tentative reçoit un 429.
Le projet
La to-do, avec des comptes
Ta to-do devient multi-utilisateur pour de vrai. Crée deux comptes, remplis-les, et essaie de casser ton propre système : avec curl, sans cookie, avec le cookie de l'autre, avec un faux jeton, avec un JSON tordu. Chaque attaque doit finir en 400, 401 ou 404, jamais en données de l'autre.
Ton projet est validé quand :
Bonus
Ajoute express-rate-limit sur la connexion (mission 7), puis une route POST /api/auth/password pour changer de mot de passe : l'ancien est vérifié, le nouveau haché, et toutes les autres sessions du compte sont supprimées.
Si chaque compte vit chez lui et que tes attaques échouent toutes, t'as une vraie appli, avec des vrais utilisateurs. Dernière étape : la mettre en ligne, avec une base de données hébergée qui ne perd rien.