Menu
CoddyTech

Scadenza dei token

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

Un server non può revocare un token: non viene memorizzato nulla, quindi un token rubato funziona fino alla sua scadenza. Fai in modo che i token abbiano una breve durata, di minuti anziché di giorni, e consenti al client di richiederne uno nuovo.

jwtVerify rifiuta già un token il cui exp è scaduto. exp è un istante espresso in secondi a partire dal 1º gennaio 1970. Genera un errore errors.JWTExpired. err instanceof errors.JWTExpired è vero per questo tipo di errore, quindi puoi distinguere un token scaduto da uno contraffatto e comunicarlo: un client che legge Token scaduto sa che deve effettuare di nuovo l’accesso.

import { errors, jwtVerify } from 'jose';

try {
    await jwtVerify(inviteToken, inviteKey);
} catch (err) {
    if (err instanceof errors.JWTExpired) {
        return res.status(410).json({ error: 'This invite has run out' });
    }
    return res.status(400).json({ error: 'Not a real invite' });
}
challenge icon

Sfida

Facile

Un server non può revocare un token, quindi un token rubato continua a funzionare finché non scade. Fai in modo che i token abbiano una durata breve e comunica chiaramente a un’app quando il suo token è scaduto, così saprà di dover accedere di nuovo.

  1. In server.js, in POST /token, fai scadere i token 15 minuti dopo la loro creazione, invece che dopo un’ora.
  2. In requireUser, controlla l’errore generato da jwtVerify. Per un token scaduto, jose genera un errore errors.JWTExpired. Importa errors da jose. Quando l’errore è di quel tipo, rispondi con lo stato 401 e {"error": "Token expired"}.
  3. Per ogni altro problema, restituisci comunque lo stato 401 e {"error": "Log in first"}: nessun header, un token modificato oppure un token firmato con un’altra chiave.

Prova: premi «Gioca». Invia POST /signup, poi POST /token, entrambi con {"username": "ada", "password": "correct horse"}. Invia GET /inspect?token= seguito dal nuovo token: exp è ora 900 secondi dopo iat. Entrambi contano i secondi dal 1° gennaio 1970. Ora ecco un token firmato con il segreto di questo server, scaduto il 1° gennaio 2026: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkYSIsInN1YiI6IjY2ZjFjMmE5ZTRiMGExYjJjM2Q0ZTVmNiIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI2NTAwfQ.PL8VCHpGNwBJ-7EawZ60veEEE-ZyosNC7xYQQpgT2uE Nel campo Headers, imposta Authorization su Bearer, uno spazio e quel token, poi invia GET /me: lo stato è 401 e {"error": "Token expired"}. Cambia una lettera nella parte centrale e invialo di nuovo: lo stato è 401 e {"error": "Log in first"}. Un token modificato non supera il controllo della firma, quindi la sua data non viene letta.

Provalo tu

import express from 'express';
import { SignJWT, decodeJwt, jwtVerify } from 'jose';
import { users } from './db.js';
import { hashPassword, verifyPassword } from './passwords.js';

const secret = new TextEncoder().encode(process.env.JWT_SECRET);

const app = express();
app.use(express.json());

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, 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' });
    }
    res.json({ username: user.username });
});

app.post('/token', 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 token = await new SignJWT({ username: user.username })
        .setProtectedHeader({ alg: 'HS256' })
        .setSubject(user._id.toHexString())
        .setIssuedAt()
        .setExpirationTime('1h')
        .sign(secret);
    res.json({ token });
});

// Verifica il token nell'header Authorization, altrimenti risponde con 401.
async function requireUser(req, res, next) {
    const token = (req.get('authorization') ?? '').replace(/^Bearer /, '');
    let payload;
    try {
        ({ payload } = await jwtVerify(token, secret));
    } catch {
        return res.status(401).json({ error: 'Log in first' });
    }
    req.user = { id: payload.sub, username: payload.username };
    next();
}

app.get('/me', requireUser, (req, res) => {
    res.json(req.user);
});

// Mostra cosa contiene un token: GET /inspect?token=...
app.get('/inspect', (req, res) => {
    try {
        res.json(decodeJwt(String(req.query.token ?? '')));
    } catch {
        res.status(400).json({ error: 'Not a JWT' });
    }
});

// La pagina di amministrazione. Nessuna dichiarazione può essere considerata attendibile prima che ne sia stata verificata la firma.
app.get('/admin', (req, res) => {
    res.status(403).json({ error: 'Admins only' });
});

app.listen(3000, () => {
    console.log('Vault is running on port 3000');
});

Tutte le lezioni di Autenticazione e sicurezza

Esercitati da solo: Playground Express