登录会话
本课属于 Coddy Node.js 学习旅程的“身份验证与安全”单元。第 10 课,共 24 课。
登录返回 200,但下一个请求却忘了是谁登录了。会话可以记住这一点。登录时,服务器会生成一个没人能猜到的 ID,存储它对应的用户,并将它作为 Cookie 发送。之后的每个请求都会带回这个 ID,服务器再查找它。
ID 必须是随机的。如果 Cookie 中存储的是用户名,任何人都可以输入 Cookie: sid=ada,然后冒充 Ada。randomBytes(24) 会生成 24 个随机字节,也就是 192 位,没人能猜到。toString('base64url') 会将它们写成由 32 个字母、数字、- 和 _ 组成的字符串,这些字符在 Cookie 中是安全的。将 Cookie 设置为 httpOnly,这样页面中的脚本就无法读取它。
const cartId = randomBytes(24).toString('base64url');
await carts.insertOne({ _id: cartId, items: [] });
res.cookie('cart', cartId, { httpOnly: true });
// 在后续请求中
const cart = await carts.findOne({ _id: parseCookies(req.get('cookie')).cart });挑战
简单这一步会打开课程准备好的私密笔记 API 项目,而不是你在 Remembering with Cookies 中的项目文件(包括 server.js)。你之前的工作仍保留在原来的课中。编辑 server.js 前,先阅读提供的文件。此项目副本包含私密笔记的注册和登录路由。
登录可以正常工作,但下一个请求已经忘记了谁登录过。让服务器通过会话记住用户:会话是保存在服务器上并作为 Cookie 发送给浏览器的随机 ID。
- 在
server.js中,从node:crypto导入randomBytes。在从./db.js导入notes和users的同时,导入sessions。已锁定的db.js中已经有一个sessions集合。 - 在
POST /login中,密码验证通过后,创建一个会话 ID:将 24 个随机字节写成 base64url 文本。也就是由字母、数字、-和_组成的 32 个字符,例如oqjRxvxmXl_nRwAwOLxgZyI2sYq9hZtN。每次登录都要创建一个新 ID。 - 将会话保存到
sessions中,作为包含两个字段的文档:_id:会话 ID。userId:已登录用户的_id。
- 将 ID 作为名为
sid的 Cookie 发送给浏览器,并设置httpOnly选项,这样页面中的脚本就无法读取它。登录仍会返回{"username": "ada"}。 - 添加
GET /me。读取sidCookie,根据该 Cookie 查找具有相应_id的会话,然后查找该会话对应的用户。响应内容为{"username": "ada"}。如果没有sidCookie,或者没有会话具有该 ID,则返回状态码 401 和{"error": "Log in first"}。如果有人手动输入 Cookie,例如sid=ada,也必须返回 401。
试试看:按“运行”。发送 POST /signup,并附上 {"username": "ada", "password": "correct horse"}。发送 GET /me:状态码为 401,响应内容为 {"error": "Log in first"}。发送 POST /login,并附上与注册时相同的请求体:状态码为 200。响应的 Headers 选项卡会显示 Set-Cookie: sid=...; Path=/; HttpOnly,其中的省略号会替换为你的随机 ID。再次发送 GET /me。浏览器会发回 Cookie,因此你会得到 {"username": "ada"}。打开 Data:sessions 中有一个文档,其中 _id 是你的 ID,userId 是 ada 的 _id。
亲手试试
import express from 'express';
import { parseCookies } from './cookies.js';
import { notes, 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 });
});
// 统计这个浏览器调用 GET /visit 的次数。
app.get('/visit', (req, res) => {
const cookies = parseCookies(req.get('cookie'));
const visits = Number(cookies.visits ?? 0) + 1;
res.cookie('visits', String(visits));
res.json({ visits });
});
// 笔记。目前任何人都可以读取和写入。
app.get('/notes', async (req, res) => {
res.json(await notes.find().toArray());
});
app.post('/notes', async (req, res) => {
const note = { text: String(req.body.text ?? '') };
await notes.insertOne(note);
res.status(201).json(note);
});
app.listen(3000, () => {
console.log('Vault is running on port 3000');
});
身份验证与安全中的所有课
自己练习: Express 在线编辑器