Menu
CoddyTech

Thời hạn token

Thuộc phần Xác thực và bảo mật trong hành trình Node.js của Coddy. Bài học 17 trên 24.

Máy chủ không thể thu hồi token: không có gì được lưu lại, nên token bị đánh cắp vẫn hoạt động cho đến khi hết hạn. Hãy đặt thời hạn ngắn cho token, tính bằng phút thay vì ngày, và để ứng dụng khách yêu cầu token mới.

jwtVerify đã từ chối token khi exp đã qua. exp là một thời điểm được tính bằng giây kể từ ngày 1 tháng 1 năm 1970. Hàm này ném ra một errors.JWTExpired. err instanceof errors.JWTExpired trả về true với loại lỗi đó, vì vậy bạn có thể phân biệt token hết hạn với token giả mạo và thông báo điều đó: ứng dụng khách đọc được Token expired sẽ biết cần đăng nhập lại.

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

Thử thách

Dễ

Máy chủ không thể thu hồi token, nên token bị đánh cắp vẫn hoạt động cho đến khi hết hạn. Hãy đặt thời hạn token ngắn và báo rõ cho ứng dụng khi token hết hạn để ứng dụng biết cần đăng nhập lại.

  1. Trong server.js, ở POST /token, hãy đặt thời hạn token là 15 phút kể từ lúc tạo, thay vì một giờ.
  2. Trong requireUser, hãy kiểm tra lỗi mà jwtVerify ném ra. Với token hết hạn, jose ném ra lỗi errors.JWTExpired. Hãy nhập errors từ jose. Khi lỗi thuộc loại này, hãy trả về trạng thái 401 và {"error": "Token expired"}.
  3. Mọi vấn đề khác vẫn trả về trạng thái 401 và {"error": "Log in first"}: không có header, token đã bị thay đổi hoặc token được ký bằng khóa khác.

Thử nhé: Nhấn Chạy. Gửi POST /signup, sau đó gửi POST /token, cả hai đều dùng {"username": "ada", "password": "correct horse"}. Gửi GET /inspect?token= kèm theo token mới: giờ đây exp cách iat 900 giây. Cả hai đều tính số giây kể từ ngày 1 tháng 1 năm 1970. Tiếp theo là token được ký bằng khóa bí mật của máy chủ này, đã hết hạn vào ngày 1 tháng 1 năm 2026: eyJhbGciOiJIUzI1NiJ9.eyJ1c2VybmFtZSI6ImFkYSIsInN1YiI6IjY2ZjFjMmE5ZTRiMGExYjJjM2Q0ZTVmNiIsImlhdCI6MTc2NzIyNTYwMCwiZXhwIjoxNzY3MjI2NTAwfQ.PL8VCHpGNwBJ-7EawZ60veEEE-ZyosNC7xYQQpgT2uE Trong trường Headers, đặt Authorization thành Bearer, thêm một dấu cách rồi đến token đó, sau đó gửi GET /me: trạng thái 401 và {"error": "Token expired"}. Thay một chữ cái ở phần giữa của token rồi gửi lại: trạng thái 401 và {"error": "Log in first"}. Token đã bị thay đổi sẽ không vượt qua bước kiểm tra chữ ký, trước khi hệ thống đọc ngày hết hạn.

Tự làm thử

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

// Kiểm tra token trong header Authorization, nếu không hợp lệ thì trả về 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);
});

// Hiển thị nội dung của 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' });
    }
});

// Trang quản trị. Chỉ có thể tin một claim sau khi kiểm tra chữ ký của nó.
app.get('/admin', (req, res) => {
    res.status(403).json({ error: 'Admins only' });
});

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

Tất cả bài học trong Xác thực và bảo mật

Tự luyện tập: Playground Express