登录失效时我们很容易先去看浏览器Cookie 是不是没了但有时打开开发者工具那个 Cookie 明明还在。再请求一次个人信息服务却说没有登录。这两件事为什么可以同时成立旧的测试笔记里有一句“Cookie 和 Session 的区别”。我们不先背定义搭一个只在本机运行的小例子跟着一次登录走。这里没有真实账号也不研究某个平台的登录实现只演示一种常见的服务端会话方案。第一次请求时服务器凭什么知道是你先请求本例的/me它负责返回当前用户。此时还没有模拟登录返回HTTP 401 {error:not_authenticated}接着点“模拟登录”。我们暂时跳过密码验证这个单独的问题直接模拟“身份已经验证成功”之后要做的事服务生成一个随机会话编号。在自己这边记下这个编号对应demo-user并记录失效时间。在响应里用Set-Cookie让浏览器保存这个编号。图里用S1代替随机编号它只是方便看关系的符号不是程序真的只生成这两个字符。登录完成后 浏览器保存sidS1 服务器保存 S1 - 用户demo-user 失效时间下次点“查询身份”浏览器向同一个本地站点发请求会带上符合发送条件的 Cookie。服务读出S1再去查自己的记录浏览器带sidS1请求/me | v 服务器查S1 找到、没过期 | v 返回demo-user在这个设计里浏览器保存的主要是“拿哪个编号来找”用户与过期时间等会话内容留在服务端。Cookie 是携带编号的方式Session 是服务端维护的那份会话状态它们并不是二选一。Cookie 也可以用于偏好设置等其他用途不是每个 Cookie 都代表登录。MDN 的 Cookie 使用说明只删掉服务器的记录会发生什么我们故意把本例的退出做成一个便于观察的版本只删服务端会话记录先不让浏览器删除 Cookie。点“退出会话”之后浏览器sidS1 没动 服务器S1对应的记录 已删再点“查询身份”浏览器仍带sidS1 | v 服务器查S1找不到 | v 返回401这次 Cookie 不是丢了也不是浏览器没发。它送到了但送来的编号已经不对应一个有效会话。所以“Cookie 在不在”只能回答浏览器有没有保存它不能直接回答这次请求是否登录成功。真实应用退出时一般还会发一个让浏览器过期该 Cookie 的响应让两边都清理。这里特意留下浏览器的一份只是为了把服务端失效这一步单独看清不推荐照着这个演示省掉客户端清理。反过来如果只让浏览器删 Cookie没有让服务端会话失效也不能据此认定那个会话编号已经不能再用了。退出要考虑的是会话失效不只是页面上看起来“回到了未登录”。Cookie能保存一小时Session就能用一小时吗本例设置了两个不同的时间浏览器这边Cookie最多保留3600秒 服务器这边会话60秒后失效它们是两条不同的规则。登录后不退出等到服务端的 60 秒过去。Cookie 可能仍会随请求送来但服务器检查时发现会话超过有效期照样返回 401。如果业务采用“每次访问续期”这里的变化又会不同。本例用的是从创建时起计算的固定有效期查/me不会续期别拿它推断所有网站都采用一样的计时方式。Max-Age控制浏览器保存 Cookie 的时长并不自动替你实现服务端会话超时。HttpOnly、SameSite等属性则在约束读取和发送含义也不同。Set-Cookie 属性说明新开一个标签为什么通常还登录着同一个浏览器上下文里新标签可以使用同一套 Cookie。它没有点过模拟登录按钮只要符合 Cookie 的发送条件带上有效编号服务器也能认出会话。这里的“上下文”可以先理解成一套独立的 Cookie 等站点状态。自动化工具能够在同一个浏览器进程里创建多套彼此隔离的状态不是看进程或窗口的名字来判断。新建一个独立的空浏览器上下文它没有那份 Cookie访问/me就是 401。同一上下文 标签A -- -- 同一份Cookie -- 同一会话 标签B -- 独立空上下文没有该Cookie -- 401这里说的是共享 Cookie 的条件不是“所有窗口肯定互通”。不同用户配置、隐私窗口、隔离的自动化上下文不能仅凭都叫 Edge 就认定它们共享登录。也不能把“有Max-Age”理解成平台保证你永远不用重新登录。服务端会话可能已经失效浏览器配置也可能不一样。实际排查需要分别找证据。另外本例设置了HttpOnly。在页面脚本里看document.cookie看不到这条会话 Cookie但浏览器仍可以在符合条件的 HTTP 请求里带上它。脚本读不到和浏览器不发送不是同一回事。真遇到掉登录我会按哪几步查先找那次真正需要身份的请求比如本例的/me。不能仅凭页面上出现一个“登录”按钮就认定 Cookie 全被清了。先看请求有没有带预期的会话标记。没有带再查是否用错浏览器配置以及 Cookie 的域、路径、过期和发送条件。标记带了也还需要看服务端响应是否明确认证失败还是请求压根没有正常完成。在自己能控制服务的情况下再查会话是否存在、是否超时或被撤销。如果是多实例服务还要考虑请求打到另一台实例时会话数据是否一致。本例把 Session 放在进程内的Map。进程重启记录就没了。这能解释为什么这种简单实现会丢会话但不能据此认定某个真实平台也用内存 Map更不能把所有掉登录都解释成服务器重启。这个实验说明了一种原因能够怎样发生不证明某次真实网站的登录问题就是这个原因。真实故障要对照当时的浏览器配置、请求和响应不能拿一个能复现的现象替代诊断。本地完整程序使用 Node.js 22下面两个文件放在同一个目录不依赖第三方包。它只监听本机没有密码验证、真实用户或持久化会话不要拿去部署成登录服务。真实系统还要处理身份验证、HTTPS、安全属性、请求防护和会话存储等问题。server.mjsimport { createServer } from node:http; import { randomBytes } from node:crypto; import { readFile } from node:fs/promises; import { pathToFileURL } from node:url; export async function start({ now Date.now, ttlMs 60000 } {}) { const html await readFile(new URL(./session.html, import.meta.url)); const sessions new Map(); const observations []; const server createServer((req, res) { res.setHeader(cache-control, no-store); const send (status, data) { res.writeHead(status, { content-type: application/json; charsetutf-8 }); res.end(JSON.stringify(data)); }; // This local demo recognizes one fixed-format cookie, not arbitrary cookie formats. const match (req.headers.cookie ?? ).match(/(?:^|;\s*)blog_demo_sid([a-f0-9]{32})(?:;|$)/); const sid match?.[1]; if (req.method GET req.url /) { res.writeHead(200, { content-type: text/html; charsetutf-8 }); return res.end(html); } if (req.method POST req.url /login) { // Simulate successful identity verification; no real account or password is used. if (sid) sessions.delete(sid); const fresh randomBytes(16).toString(hex); sessions.set(fresh, { user: demo-user, expiresAt: now() ttlMs }); res.setHeader(set-cookie, blog_demo_sid${fresh}; Path/; HttpOnly; SameSiteStrict; Max-Age3600); return send(200, { user: demo-user, sessionSeconds: ttlMs / 1000 }); } if (req.method POST req.url /logout) { if (sid) sessions.delete(sid); // Intentionally retain the browser cookie to isolate server-side invalidation. return send(200, { loggedOut: true, cookieRetainedForExperiment: true }); } if (req.method GET req.url /me) { const session sessions.get(sid); const live Boolean(session now() session.expiresAt); observations.push({ hasSessionCookie: Boolean(sid), authenticated: live }); if (!live) { if (session) sessions.delete(sid); return send(401, { error: not_authenticated }); } return send(200, { user: session.user }); } return send(404, { error: not_found }); }); await new Promise((resolve, reject) { server.once(error, reject); server.listen(0, 127.0.0.1, resolve); }); return { server, observations, url: http://127.0.0.1:${server.address().port} }; } if (process.argv[1] import.meta.url pathToFileURL(process.argv[1]).href) { const app await start(); console.log(app.url); }session.html!doctype html html langzh-CN meta charsetutf-8 meta nameviewport contentwidthdevice-width,initial-scale1 titleCookie与会话实验/title style body { max-width: 600px; margin: 40px auto; padding: 20px; font: 17px/1.8 sans-serif; } h1 { font-size: 28px; } nav { display: flex; flex-wrap: wrap; gap: 8px; } button { font: inherit; } output { display: block; margin-top: 16px; overflow-wrap: anywhere; } /style h1会话实验/h1 nav aria-label会话操作 button>node server.mjs打开打印出来的本地地址依次点“查询身份”“模拟登录”“查询身份”“退出会话”“查询身份”。状态依次是401、200、200、200、401。想看超时就重新模拟登录等待超过 60 秒再查询身份。开发者工具里可以看本地演示 Cookie 的属性和请求但不要把真实网站的登录值截图或贴出来。程序里用的是本地 HTTP因此没有给这个演示 Cookie 加Secure生产 HTTPS 下的会话配置不能照抄。Cookie 名称也固定为本例专用解析只支持这个演示生成的格式不是通用 Cookie 解析器。验证不只看最后一个401如果只断言“退出后返回 401”测试可能漏掉一个错误服务端其实没撤销会话只是浏览器删了标记暂时看起来像退出。所以这里分别核对正常请求成功时服务有没有收到标记只撤销服务端会话后浏览器的标记是否仍在、下一次请求是否确实携带以及服务是否拒绝。只记录这些真假状态不把随机会话值写进日志。超时检查还把服务使用的时间函数单独传入先检查到期前仍然有效再把测试时钟推进到到期点检查它被拒绝。这样不用让自动测试每次真等一分钟也不会因为电脑卡了一下就把结果搞得不确定。浏览器的 Cookie 时间没有被一起修改正好可以独立核对两边。沿着这几个状态走完再回头回答“Cookie 和 Session 有什么区别”就不只是“一个在客户端一个在服务端”了。还应该能说清这个请求带了什么服务拿它查什么哪一边发生变化会让下一次请求失效。
阅读完成 · 觉得有帮助?