登录后,前端调用用户信息接口,通常不需要再传一次账号密码,服务端却能知道当前用户是谁。使用 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。
在浏览器中看 Cookie
打开开发者工具,分别观察三个位置:
- Network:登录接口的响应头中有
Set-Cookie。 - Application:Cookies 中出现 sid,有效期约一分钟。
- Network:查询用户接口的请求头中有
Cookie: sid=...。
然后在 Console 执行 document.cookie。虽然 Application 中能看到 sid,这里却读不到它,因为设置了 HttpOnly。
HttpOnly 限制 JavaScript 读取 Cookie,不影响浏览器发送 Cookie。它能降低脚本直接获取会话 ID 的风险,但被注入的脚本仍然可能以当前用户身份发请求,所以 XSS 和 CSRF 仍然需要处理。
Cookie 属性的作用
| 属性 | 含义 |
|---|---|
| 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 实例也不能自动共享。持续运行的服务还需要清理过期记录、限制容量,并使用合适的共享存储。账号密码校验和登录时的其他安全处理也不在这个示例中。