Menu
CoddyTech

JWT 是什么

本课属于 Coddy Node.js 学习旅程的“身份验证与安全”单元。第 14 课,共 24 课。

每次请求都需要查询数据库来获取会话信息。JWT(JSON Web Token)本身携带这些信息,由三个用点连接的部分组成。每个部分都用 base64url 编码,这种编码方式用字母、数字、- 和 _ 来表示字节。头部指定算法,载荷包含声明(sub 表示令牌属于谁,exp 表示令牌何时过期),签名则证明它是由服务器生成的。

Base64url 是一种编码方式,不是加密。任何持有令牌的人都能读取它的载荷:

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

jose 这个 JWT 库中的 decodeJwt(token) 也会执行相同的操作。它会读取载荷,但不会检查签名;如果文本不是 JWT,它就会抛出错误。因此,千万不要把秘密信息放进载荷,也不要根据 decodeJwt 返回的声明做任何决定:任何人都可以写入任意声明。

challenge icon

挑战

简单

本章会为调用你的 API 的应用添加另一种登录方式,使用令牌。本章从一个较精简的服务器副本开始,其中有注册和登录功能,但没有会话或笔记。JWT 会在自身内部携带信息,任何人都能读取这些信息。你要创建一个路由来显示令牌中的内容,并关闭一个会信任令牌所声明内容的管理员页面。

  1. 在 server.js 中编写 GET /inspect。它会从查询字符串中读取令牌,例如 GET /inspect?token=eyJ...,并以 JSON 格式返回令牌中的声明。使用已经导入的 decodeJwt 来读取这些声明。对于载荷为 {"sub": "lin", "plan": "free"} 的令牌,该路由会原样返回 {"sub": "lin", "plan": "free"}。
  2. 当文本不是 JWT 时,decodeJwt 会抛出错误。捕获这个错误,并以状态码 400 和 {"error": "Not a JWT"} 作为响应。例如,GET /inspect?token=hello 会得到状态码 400。
  3. 看看 GET /admin。它会使用 decodeJwt 读取令牌,并在令牌声明 "admin": true 时显示一条秘密信息。但是 decodeJwt 从不检查签名,因此任何人都可以输入一个声明 "admin": true 的令牌。这个服务器目前还无法检查签名。因此,让 GET /admin 对每个请求都返回状态码 403 和 {"error": "Admins only"},无论收到什么令牌。403 Forbidden 表示“你不能查看此内容”。第 4 章会重新开放管理员页面,但只允许真正的管理员访问。

试试看:按“运行”。请求栏默认显示 GET /admin?token=,后面跟着这个令牌:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJtYWxsb3J5IiwiYWRtaW4iOnRydWV9.c2lnbmF0dXJl。一个名叫 mallory 的攻击者手动编写了这个令牌,没有使用密钥。在做任何修改之前发送它:初始版本会返回 {"secret": "The vault key is 42"}。完成修改后,再次按“运行”并发送相同的请求:状态码为 403,响应为 {"error": "Admins only"}。发送 GET /inspect?token=,后面跟着同一个令牌:{"sub": "mallory", "admin": true},无需任何密钥就能读取其中的内容。发送 GET /inspect?token=hello:状态码为 400,响应为 {"error": "Not a JWT"}。

亲手试试

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

// 显示 token 的内容:GET /inspect?token=...
app.get('/inspect', (req, res) => {
    res.status(501).json({ error: 'Not written yet' });
});

// 管理员页面。
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 icon自我测试

这节课包含一个小测验。开始学习即可作答,并记录你的进度。

身份验证与安全中的所有课

自己练习: Express 在线编辑器