•
阅读时长 3 分钟

Node.js 登录接口中的 Cookie 和 Session 是怎么配合的

目录

登录后,前端调用用户信息接口,通常不需要再传一次账号密码,服务端却能知道当前用户是谁。使用 Cookie 和 Session 登录时,这个过程依赖两份数据:浏览器保存 Session ID,服务端保存这个 ID 对应的用户信息。

Cookie 是怎么写入浏览器的?后续请求又是怎么带上它的?下面用一个 Node.js 服务,把登录、查询用户和退出登录连起来。

登录过程

浏览器 POST /login
  ↓
服务端创建 Session,生成随机 ID
  ↓
响应头 Set-Cookie: sid=这个ID
  ↓
浏览器保存 Cookie
  ↓
浏览器 GET /me,自动携带 Cookie: sid=这个ID
  ↓
服务端查询 Session,返回用户信息

这里不是把用户名直接写进 Cookie。客户端带回的是随机 ID,服务端需要查询这份 ID 是否存在、是否过期,才能确认用户身份。

Node.js 示例

把下面代码保存为 server.mjs,运行 node server.mjs,打开 http://127.0.0.1:3000。登录固定使用 demo 用户,省略账号密码校验,只观察 Cookie 的保存和发送。

import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';

const sessions = new Map();
const ttl = 60_000;

function getSessionId(header = '') {
  const item = header.split(';').map(value => value.trim())
    .find(value => value.startsWith('sid='));
  return item ? item.slice(4) : '';
}

createServer((req, res) => {
  const id = getSessionId(req.headers.cookie);
  const headers = {
    'Content-Type': 'application/json; charset=utf-8',
    'Cache-Control': 'no-store',
  };
  let status = 200;
  let result;

  if (req.method === 'GET' && req.url === '/') {
    res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    res.end('<h1>Cookie 登录示例</h1><p>打开 Console,按正文执行请求。</p>');
    return;
  }
  if (req.method === 'POST' && req.url === '/login') {
    const sid = randomBytes(24).toString('hex');
    sessions.set(sid, { user: 'demo', expires: Date.now() + ttl });
    headers['Set-Cookie'] =
      `sid=${sid}; HttpOnly; SameSite=Lax; Path=/; Max-Age=60`;
    result = { user: 'demo' };
  } else if (req.method === 'GET' && req.url === '/me') {
    const session = sessions.get(id);
    if (session && session.expires > Date.now()) {
      result = { user: session.user };
    } else {
      sessions.delete(id);
      status = 401;
      result = { error: '未登录或登录已过期' };
    }
  } else if (req.method === 'POST' && req.url === '/logout') {
    sessions.delete(id);
    headers['Set-Cookie'] =
      'sid=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0';
    result = { ok: true };
  } else {
    status = 404;
    result = { error: '接口不存在' };
  }
  res.writeHead(status, headers);
  res.end(JSON.stringify(result));
}).listen(3000, '127.0.0.1');

在页面的 Console 中依次执行:

await fetch('/me');                     // 401
await fetch('/login', { method: 'POST' });
await (await fetch('/me')).json();      // { user: 'demo' }
await fetch('/logout', { method: 'POST' });
await fetch('/me');                     // 401

同源 fetch 默认会携带符合条件的 Cookie,不需要前端把登录响应中的 sid 再写到 document.cookie。

打开开发者工具,分别观察三个位置:

  1. Network:登录接口的响应头中有 Set-Cookie。
  2. Application:Cookies 中出现 sid,有效期约一分钟。
  3. Network:查询用户接口的请求头中有 Cookie: sid=...。

然后在 Console 执行 document.cookie。虽然 Application 中能看到 sid,这里却读不到它,因为设置了 HttpOnly。

HttpOnly 限制 JavaScript 读取 Cookie,不影响浏览器发送 Cookie。它能降低脚本直接获取会话 ID 的风险,但被注入的脚本仍然可能以当前用户身份发请求,所以 XSS 和 CSRF 仍然需要处理。

属性含义
HttpOnly不允许通过 document.cookie 读取
Path=/当前主机下路径符合条件的请求可以携带
Max-Age=60浏览器中的有效期为 60 秒
SameSite=Lax限制部分跨站请求携带 Cookie
Secure通过安全连接发送,正式 HTTPS 环境应设置

本地示例用普通 HTTP,没有加 Secure。Cookie 不按端口隔离,同一主机的不同端口不能当成独立的 Cookie 空间。

跨源 fetch 需要另外考虑 credentials 和服务端 CORS 配置;跨站还涉及 SameSite 及浏览器隐私限制。仅设置 credentials: 'include',不能绕过这些规则。属性细节参考 Set-Cookie 文档。

过期和退出登录

浏览器 Cookie 的过期时间和服务端 Session 的过期时间都设置为一分钟。两边都要检查,不能因为浏览器应该删除 Cookie,就省略服务端的过期判断。

退出时也要处理两边:删除服务端 Session,向浏览器发送同名、同 Path 的 Cookie,并设置 Max-Age=0。如果设置过 Domain,删除时也要匹配 Domain。

只删除浏览器 Cookie,其他已经拿到 sid 的客户端仍然可能使用它。只删除服务端 Session,浏览器则继续携带一个无效 ID。

这个例子的 Session 放在 Map 中,重启服务会丢失,多个 Node 实例也不能自动共享。持续运行的服务还需要清理过期记录、限制容量,并使用合适的共享存储。账号密码校验和登录时的其他安全处理也不在这个示例中。