Menu
CoddyTech

JWT là gì

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 14 trên 24.

Mỗi yêu cầu cần tra cứu cơ sở dữ liệu để xác thực phiên. JWT (JSON Web Token) tự mang theo thông tin: ba phần được nối với nhau bằng dấu chấm. Mỗi phần được viết bằng base64url, một cách biểu diễn byte bằng chữ cái, chữ số, - và _. Phần header cho biết thuật toán, phần payload chứa các claim (sub cho biết token thuộc về ai, exp cho biết khi nào token hết hạn), còn chữ ký chứng minh máy chủ đã tạo token.

Base64url là cách mã hóa, không phải mã hóa bảo mật. Bất kỳ ai có token đều có thể đọc payload của nó:

const payload = token.split('.')[1];
JSON.parse(Buffer.from(payload, 'base64url').toString());
// { sub: '42', name: 'Lin', exp: 1767225600 }

decodeJwt(token) từ jose, gói JWT, cũng làm tương tự. Hàm này đọc payload mà không kiểm tra chữ ký và báo lỗi nếu văn bản không phải JWT. Vì vậy, đừng bao giờ đặt bí mật trong payload và đừng bao giờ quyết định điều gì dựa trên một claim mà decodeJwt đưa cho bạn: bất kỳ ai cũng có thể viết bất kỳ claim nào.

challenge icon

Thử thách

Dễ

Chương này bổ sung một cách đăng nhập thứ hai bằng token, dành cho các ứng dụng gọi API của bạn. Bài học bắt đầu từ một bản sao nhỏ hơn của máy chủ, có đăng ký và đăng nhập nhưng không có phiên đăng nhập hay ghi chú. JWT chứa thông tin bên trong và bất kỳ ai cũng có thể đọc chúng. Hãy tạo một route hiển thị nội dung của token, đồng thời đóng trang quản trị đang tin vào mọi thông tin mà token tự khai báo.

  1. Trong server.js, hãy viết GET /inspect. Route này đọc token từ chuỗi truy vấn, như trong GET /inspect?token=eyJ..., rồi trả về các claim bên trong dưới dạng JSON. Đọc chúng bằng decodeJwt, hàm này đã được import. Với token có payload là {"sub": "lin", "plan": "free"}, route trả về chính xác {"sub": "lin", "plan": "free"}.
  2. decodeJwt ném lỗi khi văn bản không phải JWT. Hãy bắt lỗi đó và phản hồi với trạng thái 400 cùng {"error": "Not a JWT"}. Ví dụ, GET /inspect?token=hello sẽ nhận trạng thái 400 đó.
  3. Xem GET /admin. Route này đọc token bằng decodeJwt và hiển thị thông tin bí mật khi token khai báo "admin": true. Nhưng decodeJwt không bao giờ kiểm tra chữ ký, nên bất kỳ ai cũng có thể tự tạo token khai báo "admin": true. Máy chủ này chưa thể kiểm tra chữ ký. Vì vậy, hãy để GET /admin phản hồi mọi yêu cầu với trạng thái 403 và {"error": "Admins only"}, bất kể token nhận được là gì. 403 Forbidden có nghĩa là “bạn không được phép xem nội dung này”. Sang chương 4, trang quản trị sẽ mở lại, nhưng chỉ dành cho quản trị viên thật sự.

Hãy thử: Nhấn Chạy. Thanh yêu cầu bắt đầu bằng GET /admin?token=, theo sau là token này: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYWxsb3J5IiwiYWRtaW4iOnRydWV9.c2lnbmF0dXJl. Kẻ tấn công tên mallory đã tự viết token này mà không cần khóa bí mật. Hãy gửi yêu cầu trước khi thay đổi bất cứ điều gì: phiên bản ban đầu trả về {"secret": "The vault key is 42"}. Sau khi thay đổi, hãy nhấn Chạy lần nữa và gửi cùng yêu cầu: nhận trạng thái 403 cùng {"error": "Admins only"}. Gửi GET /inspect?token= theo sau là cùng token đó: nhận {"sub": "mallory", "admin": true}, được đọc mà không cần khóa nào. Gửi GET /inspect?token=hello: nhận trạng thái 400 cùng {"error": "Not a JWT"}.

Tự làm thử

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

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

// Hiển thị nội dung của token: GET /inspect?token=...
app.get('/inspect', (req, res) => {
    res.status(501).json({ error: 'Not written yet' });
});

// Trang quản trị.
app.get('/admin', (req, res) => {
    const claims = decodeJwt(String(req.query.token ?? ''));
    if (claims.admin === true) {
        return res.json({ secret: 'The vault key is 42' });
    }
    res.status(403).json({ error: 'Admins only' });
});

app.listen(3000, () => {
    console.log('Vault is running on port 3000');
});
quiz iconTự kiểm tra

Bài học này có một bài trắc nghiệm ngắn. Bắt đầu bài học để trả lời và theo dõi tiến độ.

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

Tự luyện tập: Playground Express