Menu
CoddyTech

登录会话

本课属于 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 });
challenge icon

挑战

简单

这一步会打开课程准备好的私密笔记 API 项目,而不是你在 Remembering with Cookies 中的项目文件(包括 server.js)。你之前的工作仍保留在原来的课中。编辑 server.js 前,先阅读提供的文件。此项目副本包含私密笔记的注册和登录路由。

登录可以正常工作,但下一个请求已经忘记了谁登录过。让服务器通过会话记住用户:会话是保存在服务器上并作为 Cookie 发送给浏览器的随机 ID。

  1. 在 server.js 中,从 node:crypto 导入 randomBytes。在从 ./db.js 导入 notes 和 users 的同时,导入 sessions。已锁定的 db.js 中已经有一个 sessions 集合。
  2. 在 POST /login 中,密码验证通过后,创建一个会话 ID:将 24 个随机字节写成 base64url 文本。也就是由字母、数字、- 和 _ 组成的 32 个字符,例如 oqjRxvxmXl_nRwAwOLxgZyI2sYq9hZtN。每次登录都要创建一个新 ID。
  3. 将会话保存到 sessions 中,作为包含两个字段的文档:
    • _id:会话 ID。
    • userId:已登录用户的 _id。
  4. 将 ID 作为名为 sid 的 Cookie 发送给浏览器,并设置 httpOnly 选项,这样页面中的脚本就无法读取它。登录仍会返回 {"username": "ada"}。
  5. 添加 GET /me。读取 sid Cookie,根据该 Cookie 查找具有相应 _id 的会话,然后查找该会话对应的用户。响应内容为 {"username": "ada"}。如果没有 sid Cookie,或者没有会话具有该 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 在线编辑器