Limiter le débit des connexions
Fait partie de la section Authentification et sécurité du Journey Node.js de Coddy. Leçon 23 sur 24.
Un script peut essayer des milliers de mots de passe par minute sur un seul compte. Compte les échecs de connexion par nom d’utilisateur et, après cinq échecs en une minute, refuse d’en vérifier d’autres jusqu’à la fin de la minute.
Le statut 429 Too Many Requests explique pourquoi, et l’en-tête Retry-After indique combien de secondes attendre. Ne compte que les échecs : une personne qui saisit le bon mot de passe ne doit jamais se retrouver bloquée. Une Map stocke les valeurs par clé : map.get(key) en lit une et map.set(key, value) en écrit une. Elle réside en mémoire, ce qui suffit pour un seul serveur, et se vide au redémarrage du serveur. Cet exemple autorise un e-mail de réinitialisation par minute, il ne conserve donc qu’une heure. Ton système de connexion compte les échecs, son enregistrement conserve donc un compteur et une heure.
const lastSent = new Map();
app.post('/reset', (req, res) => {
const email = String(req.body.email ?? '').toLowerCase();
const wait = (lastSent.get(email) ?? 0) + 60_000 - Date.now();
if (wait > 0) {
res.set('Retry-After', String(Math.ceil(wait / 1000)));
return res.status(429).json({ error: 'One reset email a minute' });
}
lastSent.set(email, Date.now());
// ... envoyer l’e-mail
});Cette version pédagogique compte les échecs effectués dans un seul processus serveur. Des requêtes simultanées peuvent dépasser sa limite pendant que les vérifications de mot de passe sont en attente ; un limiteur utilisé en production doit compter une tentative avant de vérifier le mot de passe et conserver les compteurs dans un stockage partagé par tous les serveurs.
Défi
FacileCette étape ouvre le projet d’API de notes privées préparé pour le cours, plutôt que les fichiers de votre projet de Security Headers (y compris server.js). Votre travail précédent reste dans sa propre leçon. Lisez les fichiers fournis avant de modifier server.js. Cette copie contient les filtres de propriétaire des notes privées, les vérifications de rôle et les en-têtes de sécurité.
Un script peut essayer des milliers de mots de passe par minute sur un seul compte. Ralentissez-le : après 5 mots de passe incorrects pour un même nom d’utilisateur en une minute, cessez de vérifier les mots de passe pour ce nom jusqu’à la fin de la minute.
- Dans
server.js, au-dessus dePOST /login, conservez dans uneMapun relevé des échecs de connexion pour chaque nom d’utilisateur. Pour chaque nom, stockez le nombre d’échecs et le moment où le premier s’est produit. - Dans
POST /login, avant de vérifier le mot de passe, recherchez le relevé du nom d’utilisateur. Un relevé dont le premier échec remonte à une minute ou plus compte comme une absence de relevé. Lorsque le nom a 5 échecs en une minute, répondez avec le statut 429 et{"error": "Too many failed logins. Try again later."}. Faites-le même si le mot de passe est correct. Le code 429 Too Many Requests signifie « ralentissez ». - Pour cette réponse 429, définissez l’en-tête
Retry-Aftersur le nombre entier de secondes restantes avant qu’une minute se soit écoulée depuis le premier échec, par exempleRetry-After: 42. - Lorsque le mot de passe est incorrect, ajoutez 1 aux échecs de ce nom d’utilisateur, puis répondez avec le statut habituel 401. Le premier échec, ou le premier après un relevé expiré, commence un nouveau compte à 1 avec l’heure actuelle.
- Ne comptez jamais une connexion réussie. Les échecs pour un nom d’utilisateur ne limitent jamais les autres.
Essayez : appuyez sur « Run ».
- Envoyez
POST /signupavec{"username": "ada", "password": "correct horse"}. - Envoyez
POST /loginavec{"username": "ada", "password": "guess"}cinq fois : le statut est 401 à chaque fois. - Envoyez-le une sixième fois : le statut est 429. L’onglet Headers affiche
Retry-Afteravec un nombre de secondes, au plus 60. - Envoyez le bon mot de passe,
{"username": "ada", "password": "correct horse"}: la réponse est toujours 429. - Envoyez
POST /signup, puisPOST /login, avec{"username": "bob", "password": "correct horse"}: le statut est 200, car seule ada est limitée. Après une minute, ada peut se connecter de nouveau.
Essayez vous-même
import express from 'express';
import { ObjectId } from 'mongodb';
import { randomBytes } from 'node:crypto';
import { parseCookies } from './cookies.js';
import { notes, sessions, users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';
const app = express();
app.disable('x-powered-by');
app.use(express.json());
// En-têtes qui indiquent au navigateur de traiter chaque réponse avec prudence.
app.use((req, res, next) => {
res.set({
'X-Content-Type-Options': 'nosniff',
'Referrer-Policy': 'no-referrer',
'Content-Security-Policy': "default-src 'self'",
});
next();
});
// L’unique compte administrateur. Son mot de passe provient de l’environnement.
await users.insertOne({ username: 'admin', role: 'admin', passwordHash: await hashPassword(process.env.ADMIN_PASSWORD) });
app.post('/signup', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const password = String(req.body.password ?? '');
if (!username || password.length < 8) {
return res.status(400).json({ error: 'Pick a username and a password of at least 8 characters' });
}
if (await users.findOne({ username })) {
return res.status(409).json({ error: 'That username is taken' });
}
await users.insertOne({ username, role: 'member', passwordHash: await hashPassword(password) });
res.status(201).json({ username });
});
app.post('/login', async (req, res) => {
const username = String(req.body.username ?? '').trim().toLowerCase();
const user = await users.findOne({ username });
if (!user || !(await verifyPassword(String(req.body.password ?? ''), user.passwordHash))) {
return res.status(401).json({ error: 'Wrong username or password' });
}
const sid = randomBytes(24).toString('base64url');
await sessions.insertOne({ _id: sid, userId: user._id, createdAt: new Date() });
res.cookie('sid', sid, {
httpOnly: true,
sameSite: 'lax',
secure: true,
maxAge: 24 * 60 * 60 * 1000,
});
res.json({ username: user.username });
});
// Trouve l’utilisateur associé au cookie de session, sinon renvoie 401.
async function requireUser(req, res, next) {
const { sid } = parseCookies(req.get('cookie'));
const session = sid && (await sessions.findOne({ _id: sid }));
const user = session && (await users.findOne({ _id: session.userId }));
if (!user) {
return res.status(401).json({ error: 'Log in first' });
}
req.user = { id: user._id.toHexString(), username: user.username, role: user.role };
next();
}
// Renvoie 403 si l’utilisateur connecté n’a pas ce rôle.
function requireRole(role) {
return (req, res, next) => {
if (req.user.role !== role) {
return res.status(403).json({ error: 'Not allowed' });
}
next();
};
}
app.get('/me', requireUser, (req, res) => {
res.json({ username: req.user.username });
});
app.post('/logout', async (req, res) => {
const { sid } = parseCookies(req.get('cookie'));
if (sid) {
await sessions.deleteOne({ _id: sid });
}
res.clearCookie('sid');
res.status(204).end();
});
// Vos notes sous forme de page.
app.get('/', requireUser, async (req, res) => {
const list = await notes.find({ owner: req.user.id }).toArray();
const items = list.map((note) => `<li>${note.text}</li>`).join('');
res.send(`<!doctype html><title>Vault</title><h1>${req.user.username}'s notes</h1><ul>${items}</ul>`);
});
// Notes : chaque utilisateur ne lit et n’écrit que les siennes.
app.get('/notes', requireUser, async (req, res) => {
res.json(await notes.find({ owner: req.user.id }).toArray());
});
app.post('/notes', requireUser, async (req, res) => {
const note = { owner: req.user.id, text: String(req.body.text ?? '') };
await notes.insertOne(note);
res.status(201).json(note);
});
app.get('/notes/:id', requireUser, async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Note not found' });
}
const note = await notes.findOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
if (!note) {
return res.status(404).json({ error: 'Note not found' });
}
res.json(note);
});
app.delete('/notes/:id', requireUser, async (req, res) => {
if (!ObjectId.isValid(req.params.id)) {
return res.status(404).json({ error: 'Note not found' });
}
const { deletedCount } = await notes.deleteOne({ _id: new ObjectId(req.params.id), owner: req.user.id });
if (!deletedCount) {
return res.status(404).json({ error: 'Note not found' });
}
res.status(204).end();
});
// Pour les administrateurs : le nombre de comptes existants.
app.get('/admin', requireUser, requireRole('admin'), async (req, res) => {
res.json({ users: await users.countDocuments() });
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
Toutes les leçons de Authentification et sécurité
1Mots de passe
Octets et texte hexadécimalDériver une clé de mot de passeUn nouveau selNe stockez jamais les mots de passeComparer les octets de cléVérifier un mot de passeRègles d’inscription4Propriétaires et sécurité
Vos notes uniquementRôles d’administrationEntraînez-vous par vous-même : Playground Express