Menu
CoddyTech

Limitare la frequenza degli accessi

Fa parte della sezione Autenticazione e sicurezza del percorso Node.js di Coddy. Lezione 23 di 24.

Uno script può provare migliaia di password al minuto su un singolo account. Conta gli accessi non riusciti per nome utente e, dopo cinque in un minuto, rifiuta di controllarne altri fino alla fine del minuto.

Lo stato 429 Troppe richieste spiega il motivo, mentre l'intestazione Retry-After indica quanti secondi attendere. Conta solo i tentativi non riusciti: chi digita la password corretta non deve mai bloccare il proprio account. Una Map memorizza i valori in base alla chiave: map.get(key) ne legge uno e map.set(key, value) ne scrive uno. Rimane in memoria, il che basta per un solo server, e si svuota quando il server si riavvia. Questo esempio consente un'email di reimpostazione al minuto, quindi conserva solo un orario. Il tuo sistema di accesso conta i tentativi non riusciti, quindi il suo record conserva un conteggio e un orario.

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());
    // ... invia l'email
});

Questa versione didattica conta i tentativi non riusciti completati in un singolo processo server. Le richieste simultanee possono superare il limite mentre i controlli della password sono in corso; un limitatore di produzione deve conteggiare un tentativo prima di controllare la password e conservare i conteggi in un archivio condiviso da tutti i server.

challenge icon

Sfida

Facile

Questo passaggio apre il progetto API per le note private predisposto per il corso, anziché i file del tuo progetto di Security Headers (incluso server.js). Il tuo lavoro precedente rimane nella sua lezione. Leggi i file forniti prima di modificare server.js. Questa copia contiene i filtri per il proprietario delle note private, i controlli dei ruoli e le intestazioni di sicurezza.

Uno script può provare migliaia di password al minuto contro un account. Rallentalo: dopo 5 password errate per un nome utente nell'arco di un minuto, smetti di controllare le password per quel nome finché non è trascorso il minuto.

  1. In server.js, sopra POST /login, tieni traccia degli accessi non riusciti per ogni nome utente in una Map. Per ogni nome, memorizza il numero di tentativi falliti e quando è avvenuto il primo.
  2. In POST /login, prima di controllare la password, cerca il record del nome utente. Un record il cui primo tentativo fallito risale a un minuto fa o più viene considerato inesistente. Quando il nome ha 5 tentativi falliti nell'arco del minuto, rispondi con lo stato 429 e {"error": "Too many failed logins. Try again later."}. Fallo anche quando la password sarebbe corretta. 429 Too Many Requests significa «rallenta».
  3. In caso di 429, imposta l'intestazione Retry-After sul numero intero di secondi rimanenti prima che sia trascorso un minuto dal primo tentativo fallito, per esempio Retry-After: 42.
  4. Quando la password è errata, aggiungi 1 al numero di tentativi falliti per quel nome utente, quindi rispondi con il consueto 401. Il primo tentativo fallito, o il primo dopo un record scaduto, avvia un nuovo conteggio da 1 con l'ora corrente.
  5. Non conteggiare mai un accesso riuscito. I tentativi falliti per un nome utente non limitano mai un altro nome.

Prova: premi «Esegui».

  • Invia POST /signup con {"username": "ada", "password": "correct horse"}.
  • Invia POST /login con {"username": "ada", "password": "guess"} cinque volte: stato 401 ogni volta.
  • Invialo una sesta volta: stato 429. La scheda Headers mostra Retry-After con un numero di secondi, al massimo 60.
  • Invia la password corretta, {"username": "ada", "password": "correct horse"}: riceverai comunque 429.
  • Invia POST /signup, poi POST /login, con {"username": "bob", "password": "correct horse"}: stato 200, perché è limitata solo ada. Dopo un minuto, ada potrà accedere di nuovo.

Provalo tu

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());

// Intestazioni che indicano al browser di trattare con cautela ogni risposta.
app.use((req, res, next) => {
    res.set({
        'X-Content-Type-Options': 'nosniff',
        'Referrer-Policy': 'no-referrer',
        'Content-Security-Policy': "default-src 'self'",
    });
    next();
});

// L'unico account amministratore. La sua password proviene dall'ambiente.
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 });
});

// Trova l'utente associato al cookie di sessione oppure risponde con 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();
}

// Risponde con 403 a meno che l'utente autenticato non abbia questo ruolo.
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();
});

// Le tue note in una pagina.
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>`);
});

// Note: ogni utente legge e scrive solo le proprie.
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();
});

// Per gli amministratori: quanti account esistono.
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');
});

Tutte le lezioni di Autenticazione e sicurezza

Esercitati da solo: Playground Express