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 返回的声明做任何决定:任何人都可以写入任意声明。
挑战
简单本章会为调用你的 API 的应用添加另一种登录方式,使用令牌。本章从一个较精简的服务器副本开始,其中有注册和登录功能,但没有会话或笔记。JWT 会在自身内部携带信息,任何人都能读取这些信息。你要创建一个路由来显示令牌中的内容,并关闭一个会信任令牌所声明内容的管理员页面。
- 在
server.js中编写GET /inspect。它会从查询字符串中读取令牌,例如GET /inspect?token=eyJ...,并以 JSON 格式返回令牌中的声明。使用已经导入的decodeJwt来读取这些声明。对于载荷为{"sub": "lin", "plan": "free"}的令牌,该路由会原样返回{"sub": "lin", "plan": "free"}。 - 当文本不是 JWT 时,
decodeJwt会抛出错误。捕获这个错误,并以状态码 400 和{"error": "Not a JWT"}作为响应。例如,GET /inspect?token=hello会得到状态码 400。 - 看看
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');
});
这节课包含一个小测验。开始学习即可作答,并记录你的进度。
身份验证与安全中的所有课
自己练习: Express 在线编辑器