一篇面向实战的 Web 认证博客:从 HTTP 无状态、Cookie / Session,到 Express 记账本注册登录 的完整链路。每个示例都可独立运行,所有路径以博客所在目录为基准。
参考:MDN HTTP Cookie | RFC 6265 | express-session | cookie-parser | JWT.io | OWASP Session Management
目录
- 零、导读与学习价值
- 导读:知识架构与权威参考
- 〇、前置回顾
- 一、会话控制介绍
- 二、Cookie 机制
- 三、Session 机制
- 四、Token(JWT)概述
- 五、实战:记账本注册登录与权限
- 六、安全与最佳实践
- 七、核心示例速查与知识点归纳
- 总结
零、导读与学习价值
0.1 示例覆盖清单
本文每一个知识点都配有可运行示例,下表列出全部示例与对应章节,确保读者「读完即能动手」:
| 模块 | 练习要点 | 本文章节 |
|---|---|---|
| Cookie 增删查示例 | cookie-parser、/set /get /delete、独立端口 | §2.3 |
| document.cookie 演示 | 浏览器端读写 Cookie、HttpOnly 边界 | §2.5 |
| Session 增删查示例 | express-session、FileStore、sessions/*.json 持久化 | §3.4 |
| 三种浏览器存储对比 | Cookie / localStorage / sessionStorage 关标签页对比 | §3.6 |
| JWT 结构拆解演示 | Header.Payload.Signature、Base64URL 解码 | §4 |
| 记账本注册登录项目 | MongoDBStore、users 注册登录、checkLogin、userid 隔离账单 | §5 |
| 登录 / 注册页示例 | 表单与后端的字段契约、客户端校验、Tab 切换 | §5.8 |
| CSRF 原理演示 | 跨站自动携带 Cookie 的攻击面 | §6 |
| Cookie vs Session 对照表 | 客户端存什么、服务端存什么 | §7.7 |
0.2 核心名词速查
| 术语 | 一句话解释 |
|---|---|
| 无状态(Stateless) | HTTP 不记上次请求,需额外机制认人 |
| Cookie | 浏览器存的小文本,同源请求自动带回 |
| Session | 服务端会话对象,Cookie 只带 Session ID |
| Set-Cookie / Cookie | 下发 Cookie 的响应头 / 回传 Cookie 的请求头 |
| express-session | Express 中间件,提供 req.session |
| Store | Session 持久化后端(内存 / 文件 / MongoDB / Redis) |
| 签名(Signed Cookie) | 用密钥对 ID 做 HMAC,防客户端篡改 |
| checkLogin | 路由守卫中间件,未登录 redirect 登录页 |
| JWT | 自包含 Token,API 无状态鉴权 |
| Session 固定攻击 | 攻击者预置 ID,诱导受害者用该 ID 登录 |
0.3 为什么要学本篇
- 岗位:Node 全栈、后台管理系统、电商、社区——几乎所有需要「登录」的应用都建立在会话控制之上。
- 工程:纯数据库版的记账本只有数据层;本章补上身份与按用户隔离,才是一个可上线的多用户应用。
- 衔接:Session 与 Cookie 是理解后续 Ajax 带凭证(
withCredentials)、JWT 鉴权、OAuth 第三方登录、单点登录(SSO)的前置基础。 - 面试:「Cookie 和 Session 的区别」「JWT 与 Session 怎么选」「如何防 CSRF / XSS」是后端与全栈岗位的高频必考题。
导读:知识架构与权威参考
本文解决什么问题
| 阶段 | 你会掌握 | 典型产出 |
|---|---|---|
| 无状态 | 为何需要会话 | 理解「每次请求认不出人」 |
| Cookie | Set-Cookie、客户端存储、属性语义 | 主题、A/B 测试、记住用户名 |
| Session | 服务端存数据,Cookie 只带 ID | 传统服务端渲染的登录态 |
| 中间件 | checkLogin 保护路由 | 未登录跳转登录页 |
| 记账本 | 注册 / 登录 / 按 userid 查账 | 多用户账单站 |
| 安全 | XSS / CSRF / Session 固定的成因与防护 | 可上线的安全配置 |
知识脉络(Mermaid)
【代码注释】(知识脉络图)这张流程图表达的是整篇文章的推进逻辑,阅读时应顺着箭头理解「为什么会有下一步」。
- HTTP 无状态 是根因:没有会话机制时,服务器无法把多次请求关联到同一人。
- Cookie 把数据放在浏览器,适合主题、语言等非敏感偏好。
- Session 把业务数据放服务端,Cookie 只带一个短 ID,是传统 MVC 登录的主流(本章重点)。
- JWT 把身份信息编码进 Token,适合 SPA / 移动端 API,与 Session 可并存。
- 学习路径:先 Cookie 增删查示例 → Session + FileStore 持久化 → 记账本 MongoDBStore + 登录守卫。
市面应用:这套「无状态 → Cookie/Session → 中间件守卫」的分层思路,在 Express、Koa、Egg、NestJS 乃至 Java 的 Spring Security、PHP 的 Laravel 中完全一致,是后端框架的通用心智模型。
与数据层 / Web 层的衔接
- 数据层:
users/accounts集合与 Mongoose Model(库 → 集合 → 文档的 CRUD)。 - Web 层:Express 路由、
res.render服务端渲染、表单POST提交。 - 本章在记账本上叠加:密码 MD5 入库、Session 存用户名与
_id、账单表新增userid字段,最终实现「每个用户只看到自己的账单」。
〇、前置回顾
在进入会话控制之前,先快速对齐三块基础知识——它们是本章实战的地基:
| 知识点 | 要点 |
|---|---|
| MongoDB | 库 → 集合 → 文档三级结构;mongoose 提供 find / create / deleteOne 等 CRUD |
| Express | app.use 注册中间件,顺序极其重要;express.urlencoded() 解析表单 POST 体 |
| 记账本(无登录版) | GET/POST /account 直接操作全部账单,没有「这是谁的账单」的概念 |
本章正是在以上基础上补齐两件事:身份(你是谁)与数据隔离(你只能看你自己的数据)。
一、会话控制介绍
1.1 HTTP 无状态与需求
名词解释:
- 无状态(Stateless):服务器不保留任何与「上一次请求」相关的上下文;每一次 HTTP 请求/响应都是完全独立的事务。
- 会话(Session,广义):用户与站点之间一连串相关请求构成的逻辑过程,例如「打开商城 → 浏览 → 加购物车 → 下单」。
- 会话控制(Session Control):在无状态的 HTTP 之上,人为建立一套「让服务器识别同一用户」的机制。
概念与底层原理
为什么 HTTP 被设计成无状态? 这是规范有意的取舍。RFC 9110(HTTP 语义) 明确:每个请求都应包含服务器理解它所需的全部信息。无状态带来三个关键收益:
- 可水平扩展——任意一台服务器都能处理任意一个请求,因为没有「这个用户的上下文只在 3 号机」这种绑定,负载均衡才得以简单实现。
- 容错性强——某台机器宕机,请求转发到别的机器即可,不会丢失「会话内存」。
- 实现简单——服务器处理完一个请求即可释放资源,不必为每个连接维护长期状态。
代价就是:服务器天生认不出人。注意区分两个层次——TCP 连接可以靠 Keep-Alive 复用,但那只是「传输层的管道复用」;HTTP 协议语义层依然不记得「上一个请求是谁发的」。所以会话控制必须在应用层额外补一层身份标识,这正是 Cookie / Session / Token 的用武之地。
典型需求:
- 登录后访问个人中心、订单列表
- 购物车跨页面、跨请求保留
- 后台管理仅管理员可进
- 「记住我」7 天免登录
【代码注释】(无状态时序图)这张图刻画的是「没有会话控制时」的窘境,是后面所有方案要解决的问题。
- 每次请求在协议层彼此独立:服务器处理完请求 1 就「忘记」了它,处理请求 2 时毫无记忆。
- 若不在 Cookie / Session / Token 中携带身份标识,服务器只能把每个请求都当成新的匿名访客。
- 登录、购物车、权限控制本质上都是同一个问题——让服务器在无状态的协议上「认出同一个人」。
- 市面应用:理解这张图,就能理解为什么任何登录系统的第一步都是「下发一个凭证」——凭证就是把「记忆」从服务器转移到了「请求自身」。
1.2 三种方案对比
| 方案 | 数据存哪 | 浏览器带什么 | 服务端是否有状态 | 典型场景 |
|---|---|---|---|---|
| Cookie | 客户端 | 完整 Cookie 键值 | 无(数据全在客户端) | 语言、主题、追踪 |
| Session | 服务端 | 仅 Session ID(载于 Cookie) | 有(Store 里存会话) | 传统服务端渲染登录 |
| Token(JWT) | 客户端(或内存) | Authorization: Bearer <token> | 无(验签即可) | 前后端分离 API、移动端 |
概念与底层原理
三种方案的本质区别在于**「记忆」放在哪里**:
- Cookie 方案:把全部数据交给浏览器保管,服务器不存任何东西。优点是服务端零负担,缺点是数据暴露在客户端、可被篡改、体积受限。
- Session 方案:服务器保管数据,只把一个不可猜测、不可伪造的 ID交给浏览器。这是「最小暴露」原则——客户端只持有一把「钥匙」,「保险箱」始终在服务端。
- Token 方案:把数据编码进凭证本身并附上数字签名。服务器不存数据,但凭借签名能确认「这串数据确实是我签发、未被改动的」——它用「密码学验证」替代了「服务端查表」。
行业落点:
- 电商后台、论坛、记账工具(本章)→ Session + Cookie 存 ID,配合服务端渲染最自然。
- SPA + REST API、小程序、移动端 → JWT 更常见,天然适配无状态、跨域、水平扩展。
- 大厂常组合使用:Session 管理网页登录态 + JWT 管理对外开放 API。
【实战要点】
- 经典应用场景:未登录只能看首页,登录后服务端在 Session 写入
userId才能进订单列表——与本章记账本的设计完全同构。 - 常见坑:误以为「只要开启了 Session 中间件就自动登录」——中间件只是提供了
req.session这个容器,写入身份字段(req.session.xxx = ...)必须在登录接口里手动完成。 - 性能与最佳实践:识别用户优先「Session ID + 服务端 Store」;不要把整份用户对象(含权限、资料)塞进 Cookie,那既泄露信息又拖慢每个请求。
【本章小结】
| 维度 | 关键点 | 记忆锚点 |
|---|---|---|
| 根因 | HTTP 无状态,协议层不记忆 | 「每次请求都是陌生人」 |
| 收益 | 无状态换来可扩展、容错、简单 | 「认不出人是设计取舍」 |
| 解法 | Cookie / Session / Token 三选一或组合 | 「记忆放客户端 / 服务端 / 凭证里」 |
| 选型 | SSR 用 Session,API 用 JWT | 「页面 Session,接口 Token」 |
记忆口诀:无状态是病根,三方案是药方——存客户端、存服务端、存凭证里。
【面试考点】
Q1:HTTP 为什么是无状态的?无状态有什么好处和代价?
A:无状态指服务器不保留请求间的上下文,每个请求自包含。好处是可水平扩展(任意机器处理任意请求)、容错强、实现简单;代价是服务器认不出人,必须靠 Cookie/Session/Token 在应用层补一层身份。追问「Keep-Alive 算不算有状态」时要点明:Keep-Alive 是 TCP 连接复用,属于传输层优化,与 HTTP 语义层的无状态是两回事。
Q2:Cookie、Session、Token 三种会话方案的核心区别?
A:区别在「记忆存哪、靠什么验证」。Cookie 把数据存客户端,每次全量回传;Session 把数据存服务端 Store,客户端只持有一个不可伪造的 ID;Token(JWT)把数据编码进凭证并签名,服务端不存数据、靠验签确认真伪。前两者服务端「查表」,JWT 服务端「验签」。
二、Cookie 机制
2.1 概念与底层原理
名词解释:
- Cookie:服务器通过响应头下发、由浏览器保存的一小段文本(键值对),之后同源请求会自动把它带回。
- Set-Cookie:服务器下发 Cookie 用的响应头,一个响应可包含多个
Set-Cookie。 - Cookie 请求头:浏览器回传 Cookie 用的请求头,所有匹配的 Cookie 用
;拼接成一行。 - Cookie Jar(Cookie 罐):浏览器内部按「域 + 路径 + 名字」组织的 Cookie 存储区。
- 会话级 Cookie(Session Cookie):未设过期时间的 Cookie,浏览器进程退出时清除(注意:与服务端 Session 是两个概念)。
Cookie 本质是浏览器保存的小文本,由服务器通过响应头下发,之后同源请求自动带回。
【代码注释】(Cookie 时序图)这张图展示 Cookie 的完整生命周期:下发 → 存储 → 回传 → 识别。
- 第一次响应通过
Set-Cookie头让浏览器保存键值;之后符合条件的请求会自动带上Cookie头。 - 服务器从
req.headers.cookie(原始字符串)或经cookie-parser解析后的req.cookies对象中取值。 - Cookie 存的是完整键值(不像 Session 只存一个 ID),所以敏感数据不宜直接写入 Cookie。
- 市面应用:几乎所有网站的「主题/语言偏好」「未登录购物车」「广告追踪 ID」都靠这套下发-回传机制实现。
浏览器「要不要带这个 Cookie」的判定算法。 这是 Cookie 机制最值得深挖的底层细节。依据 RFC 6265,浏览器在发出一个请求前,会逐条检查 Cookie Jar 里的每个 Cookie,全部条件满足才携带:
- 域匹配(Domain Match):请求的主机名等于 Cookie 的
Domain,或是其子域。未显式设置Domain时只匹配「下发它的那个确切主机」。 - 路径匹配(Path Match):请求路径以 Cookie 的
Path为前缀。Path=/admin的 Cookie 不会发往/user。 - 是否过期:
Max-Age/Expires已到期的 Cookie 会被丢弃,不再携带。 - Secure 检查:带
Secure的 Cookie 只在 HTTPS 请求中携带。 - SameSite 检查:
Strict完全禁止跨站携带;Lax(现代浏览器默认)只在「跨站的顶级导航 GET」时携带;None允许跨站携带但必须同时带Secure。
理解这个算法,就能解释一连串现实问题:为什么删 Cookie 必须带上和设置时一致的 Path/Domain(否则定位不到同一条记录)、为什么 SameSite 能缓解 CSRF(跨站请求不再自动带 Cookie)、为什么本地 HTTP 开发不能开 Secure。
HTTP 头格式:
- 响应:
Set-Cookie: name=value; Path=/; Max-Age=3600; HttpOnly; SameSite=Lax - 请求:
Cookie: name=value; other=1(多个 Cookie 在一行内用;分隔)
SameSite 默认值的演进——一段值得了解的历史。 SameSite 不是一开始就有的。早期浏览器对跨站请求一律自动携带 Cookie,CSRF 因此泛滥。后来规范引入 SameSite 属性,而 Chrome 自 80 版(2020 年)起把未显式声明 SameSite 的 Cookie 默认按 Lax 处理——这是一次影响全网的变更:它让「跨站表单 POST、跨站 iframe、跨站 <img>」默认不再携带 Cookie,等于给 CSRF 防御兜了底。代价是依赖第三方场景(跨域 iframe 嵌入、第三方登录回跳)的 Cookie 必须显式声明 SameSite=None; Secure,否则会「莫名其妙丢失」。
Cookie 前缀(Cookie Prefixes)。 规范定义了两个有特殊语义的 Cookie 名前缀,浏览器会对它们强制额外约束(见 MDN:Set-Cookie):
__Secure-前缀:浏览器只接受带Secure属性、且由 HTTPS 页面下发的同名 Cookie,否则直接拒收。__Host-前缀:约束更严——除Secure外,还要求不带Domain(只绑定确切主机)、且Path=/。它能阻止子域把 Cookie「写串」到父域。
把 Session ID 这条 Cookie 命名为 __Host-sess,就能在浏览器层面杜绝「子域写父域 Cookie」这类 Cookie 注入 / Session 固定的攻击路径——这是一个零成本的安全加固。
第一方 Cookie、第三方 Cookie 与「无第三方 Cookie 的未来」。 同一个页面里,发往「地址栏域名」的 Cookie 是第一方 Cookie,发往「其它域名(广告、统计脚本等)」的是第三方 Cookie。第三方 Cookie 是跨站追踪的技术基础,正因隐私问题被各大浏览器逐步淘汰:Safari、Firefox 已默认拦截。替代方案 CHIPS(分区 Cookie)引入 Partitioned 属性——Cookie 按「顶级站点」分区存储,同一个第三方脚本嵌入 A、B 两站时拿到的是互相隔离的两份 Cookie,既保留必要功能又切断跨站追踪。理解这条趋势就能明白:登录态尽量走「第一方 Cookie + 服务端 Session」是更稳的长期选择。
2.2 Express:cookie-parser
安装:
npm install cookie-parser
【代码注释】这行命令安装 Express 官方维护的 Cookie 解析中间件。
cookie-parser是一个 Express 中间件,作用是把请求头里的Cookie原始字符串解析为req.cookies对象。- 没有它时,
req.headers.cookie是一整行字符串(如"a=1; b=2"),需要自己 split;用了它就能直接req.cookies.a。 - 它与
express-session各管各的:cookie-parser只负责普通 Cookie,Session ID 的解析由express-session自己完成(现代版本express-session已不依赖cookie-parser)。
配置与 API:
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser()); // 注册中间件,须在路由之前
// 设置(添加或覆盖)
app.get('/demo-set', (req, res) => {
res.cookie('userName', 'zhangsan');
res.cookie('age', 18, { maxAge: 20 * 1000 }); // maxAge 单位:毫秒
res.send('已设置');
});
// 读取
app.get('/demo-get', (req, res) => {
console.log(req.cookies); // { userName: 'zhangsan', age: '18' }
res.json(req.cookies);
});
// 删除(path / domain 须与设置时一致)
app.get('/demo-del', (req, res) => {
res.clearCookie('userName');
res.send('已删除');
});
app.listen(8080);
【代码注释】这段代码演示 Express 操作 Cookie 的「增、查、删」三件套。
app.use(cookieParser())全局生效;注册之后所有路由都能通过req.cookies读取 Cookie。res.cookie('userName', 'zhangsan')在响应头追加一个Set-Cookie;同名会覆盖旧值,这就是「修改」Cookie 的方式。maxAge: 20 * 1000表示约 20 秒后过期;也可以用expires: new Date(...)指定绝对时间,两者二选一。req.cookies是普通对象,注意所有值都是字符串——age取出来是'18'而非数字18。clearCookie('userName')的原理是「下发一个同名、立即过期的 Set-Cookie」。关键坑:若设置时带过path/domain,删除时参数必须完全一致,否则浏览器找不到同一条记录,删不掉。- 市面应用:
res.cookie常用于「记住用户名」「下发非敏感偏好」;管理后台框架(如各类 admin-template)的「主题切换」就是这套 API。
常用 Cookie 属性:
| 属性 | 作用 | 安全意义 |
|---|---|---|
maxAge / expires | 过期时间(相对 / 绝对) | 控制凭证有效期 |
httpOnly | 禁止 document.cookie 读取 | 减 XSS 窃取风险 |
secure | 仅 HTTPS 传输 | 防中间人嗅探 |
sameSite | Strict / Lax / None | 缓解 CSRF |
path | 哪些路径下携带 | 缩小暴露面 |
domain | 哪些域名下携带 | 控制子域共享 |
2.3 实战示例:Cookie 增删查
一个最小可运行的 Cookie 增删查服务(index.js),三个路由 /set、/get、/delete。启动方式:node index.js,再用浏览器依次访问三个地址验证。
const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());
app.get('/set', (req, res) => {
res.cookie('username', 'admin');
res.cookie('userid', '932232323');
res.cookie('address', '上海', { maxAge: 3600 * 1000 }); // 1 小时
res.cookie('parents', [10, 20, 30, 40, 50]); // 数组会被序列化
res.send('cookie 设置成功');
});
app.get('/get', (req, res) => {
console.log(req.cookies);
res.send(`
cookie 查看成功<br>
用户名:${req.cookies.username}<br>
地址:${req.cookies.address}
`);
});
app.get('/delete', (req, res) => {
res.clearCookie('username');
res.send('cookie 删除成功');
});
app.listen(8080, () => {
console.log('http server is running on :8080');
});
【代码注释】这是一个完整可运行的入门示例,把 §2.2 的 API 串成一条「增 → 查 → 删」的体验链路。
/set、/get、/delete三个路由分别对应 Cookie 的增、查、删。res.cookie('parents', [10,20,...]):数组会被cookie-parser序列化成字符串存储,读取时拿到的是字符串,需JSON.parse才能还原成数组。address带maxAge: 3600 * 1000(1 小时),是「持久 Cookie」;username未设过期时间,是「会话级 Cookie」,关闭浏览器可能被清除(取决于浏览器策略)。- 验证顺序很重要:必须先访问
/set再访问/get,否则req.cookies.username是undefined。 clearCookie('username')只删username这一个键;userid、address等其它键仍会随后续请求发送。- 市面应用:打开 Chrome DevTools → Application → Cookies,可直观看到键值、过期时间、
HttpOnly/SameSite等属性——这是排查登录问题时最常用的面板。
2.4 Cookie 的缺点与适用场景
缺点:
- 容量小——单条约 4KB,每个域名下数量有限(约 50 个),不适合存大量业务数据。
- 自动携带带来开销——每次请求都会自动带上该域所有匹配的 Cookie,体积大时会拖慢每个请求(尤其首包)。
- 存于客户端,可被篡改——用户能在 DevTools 里随意改 Cookie 值,因此绝不能存敏感业务数据(应只存 ID 或非敏感偏好),也不能把「是否管理员」这种判定依据放进去。
- 隐私与合规——第三方追踪 Cookie 受 GDPR 等法规约束,浏览器也在逐步淘汰第三方 Cookie。
经典应用场景:
| 场景 | 说明 |
|---|---|
| 主题 / 语言 | theme=dark、lang=zh,非敏感偏好 |
| 记住用户名 | 仅为登录页自动填充,不是安全凭证 |
| 广告 / 统计 | 第三方追踪 ID、UV 统计 |
| A/B 测试 | abGroup=A,保证同一用户每次落入同一实验组 |
【实战要点】
- 经典应用场景:电商站「记住用户名」、文档站
theme=dark主题、增长团队的 A/B 实验分组 Cookie——共同点是「丢了也不致命、被改了也无所谓」。 - 常见坑:①
clearCookie未带与res.cookie相同的path/domain,导致「点了删除却删不掉」;② 数组 / 对象类型的 Cookie 读出来是字符串,忘了JSON.parse直接当数组用会报错。 - 性能与最佳实践:严格控制 Cookie 数量与体积——把静态资源放在不带 Cookie 的独立域名(cookie-free domain)是大型站点的经典优化,能让 CDN 请求头更小。
【本章小结】
| 维度 | Cookie 的特性 | 实践建议 |
|---|---|---|
| 存储位置 | 浏览器 Cookie Jar | 只放非敏感、小体积数据 |
| 下发 / 回传 | Set-Cookie / Cookie 头 | 用 res.cookie / req.cookies |
| 携带判定 | 域 + 路径 + 过期 + Secure + SameSite | 删除时 path/domain 要对齐 |
| 安全属性 | httpOnly 防 XSS、sameSite 防 CSRF | 凭证类 Cookie 必开 httpOnly |
| 容量 | 单条 ≤4KB | 大数据走 Session |
记忆口诀:Cookie 是浏览器的便签——小、明文、自动带;存偏好可以,存密码不行。
【面试考点】
Q1:Cookie 存在哪里?是不是每次请求都会带上?
A:存在浏览器的 Cookie Jar 里。不是「每次都带」,而是浏览器对每个 Cookie 逐条做「域匹配 + 路径匹配 + 未过期 + Secure + SameSite」五项判定,全部通过才携带。HttpOnly 的 Cookie JS(document.cookie)读不到,但请求时仍会自动带上。
Q2:Cookie 有哪些安全属性?分别防什么?
A:HttpOnly 禁止 JS 读取 Cookie,防的是 XSS 偷 Cookie;Secure 限定只在 HTTPS 下传输,防中间人嗅探;SameSite(Strict/Lax/None)限制跨站请求是否携带 Cookie,缓解 CSRF。要点明:HttpOnly 不防 CSRF,因为 CSRF 攻击根本不需要「读」Cookie,浏览器会自动带——这正是 SameSite 要解决的问题。
2.5 可运行 HTML:理解 Cookie(浏览器端)
下面是一个完整可运行的入门示例:保存为 cookie-demo.html,用本地静态服务器或 Express 静态托管打开(file:// 协议下部分浏览器会限制 Cookie)。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Cookie 演示</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 520px; margin: 2rem auto; padding: 0 1rem; }
button { margin: 6px 6px 6px 0; padding: 8px 12px; cursor: pointer; }
pre { background: #f4f4f4; padding: 12px; border-radius: 6px; }
</style>
</head>
<body>
<h1>document.cookie 演示</h1>
<p>本页演示浏览器端读写 Cookie,对应服务端 <code>res.cookie</code> / 请求头 <code>Cookie</code>。</p>
<button type="button" id="set">设置 theme=dark</button>
<button type="button" id="read">读取全部 Cookie</button>
<button type="button" id="del">删除 theme</button>
<pre id="out"></pre>
<script>
const out = document.getElementById('out');
document.getElementById('set').onclick = () => {
document.cookie = 'theme=dark; path=/; max-age=3600';
out.textContent = '已设置: ' + document.cookie;
};
document.getElementById('read').onclick = () => {
out.textContent = document.cookie || '(空)';
};
document.getElementById('del').onclick = () => {
document.cookie = 'theme=; path=/; max-age=0';
out.textContent = '已删除: ' + document.cookie;
};
</script>
</body>
</html>
【代码注释】这个示例让读者亲手感受「客户端的 Cookie」长什么样、能做什么、不能做什么。
- 浏览器端
document.cookie对应服务端res.cookie/ 请求头Cookie中非 HttpOnly 的那部分——这是关键边界:JS 永远读不到HttpOnly的 Cookie。 document.cookie的赋值语义很特殊:每次赋值是「写入一条」而非「覆盖全部」,读取时却返回「全部拼成一行」,这点常让初学者困惑。theme=dark; path=/; max-age=3600中path=/表示全站路径都会携带该 Cookie。- 无法通过 JS 设置
HttpOnly(这是规范有意的限制),因此 Session ID 这类凭证必须由服务端用cookie: { httpOnly: true }下发。 - 删除写法:写一条同名、
max-age=0(或expires为过去时间)的 Cookie,且path要和设置时一致才能删干净。 - 市面应用:前端框架的「记住主题」「关闭新手引导后不再弹出」等轻量偏好,常直接用
document.cookie或localStorage;而登录凭证一律走服务端HttpOnlyCookie,绝不用document.cookie。
三、Session 机制
3.1 概念与底层原理
名词解释:
- Session(服务端会话):保存在服务器端的、与某一用户会话一一对应的数据对象。
- Session ID:唯一标识一个 Session 的随机字符串,是浏览器与服务端 Session 之间的「钥匙」。
- Store(存储后端):Session 数据实际存放的地方——内存、文件、MongoDB、Redis 等。
- 签名(Signing):用密钥对 Session ID 做 HMAC 摘要,附在 Cookie 里,使客户端无法伪造 / 篡改 ID。
req.session:express-session中间件在每个请求上挂载的会话对象,读写它即读写当前用户的 Session。
Session 是保存在服务器的对象;浏览器 Cookie 里只存一个 Session ID。业务数据(用户名、权限、购物车)全部留在服务端,客户端只持有「钥匙」。
四步工作流程:
1. 服务器为某次会话创建 Session,生成全局唯一、不可猜测的 ID
2. 通过响应头 Set-Cookie 把该 ID 下发给浏览器
3. 业务数据写入 req.session(数据落到服务端 Store)
4. 后续请求带上 Cookie 中的 ID,服务器据此从 Store 加载对应 Session
【代码注释】(Session 四步文字)这四步是 Session 机制的「主干」,每一步都有值得注意的细节。
- 第 1 步:首次访问可能尚无 Session;
saveUninitialized: false时,不会为「只是路过、没写任何数据」的匿名访客创建空 Session。 - 第 2 步:
Set-Cookie下发的是签名后的 Session ID,不是业务 JSON——这是 Session 与 Cookie 方案最本质的区别。 - 第 3 步:
req.session.xxx = yyy写入的是服务端存储(内存 / 文件 / MongoDB)。 - 第 4 步:浏览器只回传 ID,服务器用 Store 按 ID 取出完整 Session 再挂到
req.session。
Session ID 为什么「不可伪造」? 这是 Session 安全性的核心,也是面试高频深挖点。express-session 做了两件事:
- 不可猜测:ID 由
uid-safe库用加密级随机源生成(默认 24 字节),熵足够大,攻击者无法靠遍历猜中别人的 ID。 - 不可篡改:ID 写进 Cookie 前会用
secret做一次 HMAC 签名(cookie-signature),Cookie 实际形如s:原始ID.签名。服务器收到后先验签——签名对不上,直接判定为伪造,当作没有 Session。所以即使攻击者改了一个字符,也通不过验签。
这就解释了 secret 为什么必须保密:secret 一旦泄露,攻击者就能为任意 ID 伪造合法签名,等于能伪造任意人的会话。
Store 接口长什么样? 所有 Session 存储(FileStore、MongoDBStore、RedisStore)都实现同一组方法,理解它就理解了「换存储为什么这么容易」:
get(sid, cb):按 ID 取出 Sessionset(sid, session, cb):保存 / 更新 Sessiondestroy(sid, cb):删除 Session(退出登录时调用)touch(sid, session, cb):仅刷新过期时间(用于「滑动过期」)
正因为接口统一,从内存换成 MongoDB 只需改一行 store 配置,业务代码一行不用动——这是「面向接口编程」的经典范例。
【代码注释】(Session 时序图)这张图把「登录写入」和「后续读取」两个阶段画在一起,是 Session 全链路的浓缩。
POST /login校验通过后,向 Store 写入{ userId, username }等字段。- 响应头
Set-Cookie: sess=...中的sess由name: 'sess'配置决定,值是s:前缀 + 原始 ID +.+ 签名。 GET /account时浏览器自动带 Cookie,中间件先验签还原出 sid,再get(sid)加载 Session,最后才执行checkLogin与业务逻辑。- 业务数据从不完整放进 Cookie,从根本上规避了篡改与体积问题。
- 市面应用:所有传统服务端渲染的后台系统(如各类企业 OA、CMS、电商管理后台)都是这套流程,区别只在 Store 选型。
3.2 Express:express-session
安装:
npm install express-session
【代码注释】安装 Express 官方生态的 Session 中间件。
express-session负责生成 / 解析 / 签名 Session ID,并把req.session挂到每个请求上。- 安装后必须在所有用到
req.session的路由之前app.use(session({...}))——中间件的注册顺序决定执行顺序。 - 它可与
cookie-parser共存:前者管 Session ID 与req.session,后者管普通req.cookies,互不干扰。
基础配置:
const session = require('express-session');
app.use(session({
name: 'sess', // Cookie 名,默认 connect.sid
secret: 'your-secret-key', // 签名密钥,生产环境务必用环境变量
saveUninitialized: false, // 未写入数据的 session 不下发 Cookie
resave: false, // session 未变更则不强制写回 Store(推荐)
cookie: {
httpOnly: true, // 禁止 JS 读取,防 XSS 偷 ID
maxAge: 1000 * 30 // 控制「Session ID 这条 Cookie」的过期时间
}
}));
【代码注释】这段配置是 express-session 的「标准模板」,每个选项都直接影响安全与性能。
name: 'sess'自定义 Cookie 名;默认是connect.sid,同域多应用时改名可避免互相覆盖。secret用于对 Session ID 做 HMAC 签名——泄露后攻击者可伪造任意会话,生产环境必须用足够长的随机串并放进环境变量。saveUninitialized: false:在你给req.session赋值之前不创建 Session,避免为「路过的爬虫 / 匿名访客」生成大量垃圾会话和无意义的Set-Cookie。resave: false:Session 内容未改动时不强制写回 Store,减少 IO(官方推荐)。cookie.httpOnly: true:防止 XSS 脚本用document.cookie偷走 Session ID。cookie.maxAge控制的是 「ID 这条 Cookie」 的过期时间;过期后即使服务端 Session 还在,浏览器也不再带 ID,等效于「掉登录」。
读写与销毁:
app.get('/setSession', (req, res) => {
req.session.userName = 'zhangsan'; // 写入 → 标记 session 已修改
res.send('设置成功');
});
app.get('/getSession', (req, res) => {
console.log(req.session.userName); // 读取
res.send('获取成功');
});
app.get('/delSession', (req, res) => {
req.session.destroy(() => { // 销毁整个 session
res.send('删除成功');
});
});
【代码注释】这段演示 req.session 的「读、写、销毁」三种基本操作。
req.session.userName = 'zhangsan'会把 session 标记为「已修改」;响应结束前中间件自动把它写回 Store,并视情况刷新 Cookie。getSession路由验证读取:刷新页面后仍能读到,说明「Cookie 里的 ID」与「Store 里的数据」关联成功。req.session.destroy(callback)删除服务端的整条会话记录——退出登录必须调用它,否则旧 ID 仍然有效。- 只想清掉某个字段时用
delete req.session.userName,会话本身仍在,适合「清除部分临时状态」。 - 最佳实践:在
destroy的回调里再做res.redirect或res.clearCookie,确保浏览器不再带着无效 ID。 - 市面应用:登录接口写
req.session、受保护接口读req.session、退出接口destroy——这套读写销毁是所有 Session 登录系统的固定套路。
req.session 的完整 API——超出「读 / 写 / destroy」的部分。 真正用好 Session,还要认识下面几个方法,它们各自对应一个登录安全或体验场景:
// 1. 登录成功后换一个全新的 Session ID —— 防 Session 固定攻击
app.post('/login', (req, res) => {
// ...校验账号密码通过...
req.session.regenerate(err => { // 旧 ID 立即作废,生成新 ID
if (err) return res.status(500).send('会话异常');
req.session.userid = 'xxx'; // 身份字段写进「新」会话
res.send('登录成功');
});
});
// 2. 主动把 session 写回 Store(异步收尾、确保已落盘)
req.session.save(err => { /* ... */ });
// 3. 从 Store 重新加载最新数据(并发请求时同步状态)
req.session.reload(err => { /* ... */ });
// 4. 仅刷新过期时间、不改数据 —— 「滑动过期」的底层动作
req.session.touch();
【代码注释】这段把 req.session 上「读写之外」的四个方法一次讲清。
regenerate是防 Session 固定攻击的标准手段:攻击者哪怕事先塞给受害者一个已知 ID,登录的瞬间这个 ID 就被换掉、随之作废。务必在「写入身份字段之前」调用它。save用在「响应已发出、但还有异步写入未结束」的边界场景,手动确保落盘;正常情况下中间件会在响应结束时自动保存。reload用于「同一用户多个请求并发」时从 Store 拉取最新数据,避免用到过期的内存副本。touch只更新过期时间,是「滑动过期」的底层动作。- 市面应用:正规登录系统的登录接口几乎都会先
regenerate再写身份——这是安全审计的必查项。
rolling:滑动过期(Sliding Expiration)。 默认情况下,Cookie 的 maxAge 从「下发那一刻」起算、到点即失效——哪怕用户一直在操作,也会被「准时踢下线」。把 express-session 的 rolling 设为 true 后,每一次请求都会重置 Cookie 的过期倒计时:只要用户持续活动,登录态就一直续期;连续 maxAge 时长没有任何请求,才真正过期。这正是网银、后台管理系统「闲置一段时间自动登出、但持续操作就不掉线」的实现方式。
并发请求与 Session 竞态。 Session 有一个容易被忽略的坑:同一用户的多个请求几乎同时到达时,它们各自读到一份 Session 副本、各自修改、再各自写回——后写回的会覆盖先写回的(「丢失更新」)。SPA 页面一次性并发拉取多个接口时尤其常见。规避思路有三:把需要并发的写操作合并;对关键写入用 reload + save 收敛;把高频变更的状态移出 Session,改用数据库的行级更新。
3.3 Session 存储:内存 / 文件 / MongoDB
| 存储 | npm 包 | 适用场景 |
|---|---|---|
| 默认内存 | 无(内置 MemoryStore) | 仅开发、单进程;重启即丢、有内存泄漏风险 |
| 文件 | session-file-store | 单机持久化、演示「重启不掉登录」 |
| MongoDB | connect-mongodb-session | 生产环境、多实例共享 Session |
| Redis | connect-redis | 高并发生产环境的主流选择 |
⚠️ 默认的内存 Store 仅供开发:官方明确警告它会随会话增多持续占用内存、且不能跨进程共享。生产环境必须换成文件 / MongoDB / Redis。
文件存储:
npm install session-file-store
【代码注释】安装文件型 Session 存储。
session-file-store把每个 Session 序列化成一个 JSON 文件,默认放在项目下的sessions/目录。- 适合单机开发、演示「重启 Node 进程后仍保持登录」;不适合多机负载均衡——各台机器的文件互不共享。
const session = require('express-session');
const FileStore = require('session-file-store')(session);
app.use(session({
name: 'sess',
secret: 'atguigu',
saveUninitialized: false,
resave: true,
cookie: { httpOnly: true, maxAge: 1000 * 3600 },
store: new FileStore() // 默认写入 ./sessions 目录
}));
【代码注释】这段把 Session 的存储后端从「内存」换成「文件」。
require('session-file-store')(session)是「工厂函数」写法——必须把session传进去,store 才能与express-session的接口对接。store: new FileStore()未传路径时使用默认./sessions目录;可配置path、ttl(存活时间)等参数。resave: true表示每次请求结束都把 Session 写回文件(即使未改动);文件存储场景常这么用,减少「数据没落盘」的困惑。maxAge: 1000 * 3600即 1 小时。- 关键限制:多实例部署必须换成 Redis / MongoDB Store——否则用户的请求被负载均衡分到不同机器时,会因「这台机器没有他的 Session 文件」而频繁掉登录。
MongoDB 存储:
npm install connect-mongodb-session
【代码注释】安装 MongoDB 型 Session 存储,用于生产与多实例场景。
connect-mongodb-session把每条 Session 存成 MongoDB 集合里的一个文档,与业务库可以是同一个数据库实例下的不同库名。- 使用前需保证
mongod已启动;连接 URI 的主机端口与业务库一致即可。 - 多进程(PM2 cluster)、滚动发布、负载均衡时,所有实例读写同一个集合,用户无需重复登录。
const session = require('express-session');
const MongoDBStore = require('connect-mongodb-session')(session);
app.use(session({
name: 'sess',
secret: 'atguigu',
saveUninitialized: false,
resave: true,
cookie: { httpOnly: true, maxAge: 1000 * 3600 * 24 * 7 }, // 7 天
store: new MongoDBStore({
uri: 'mongodb://127.0.0.1:27017/account-project-auth',
collection: 'mySessions'
})
}));
【代码注释】这段是生产级 Session 配置,记账本项目(§5)正是用它。
uri里的库名account-project-auth专门用于存 Session,与users/accounts业务库分离,职责清晰。collection: 'mySessions'集合里每条文档对应一个会话,含expires字段,由库自动维护过期清理。maxAge: 7 天(1000 * 3600 * 24 * 7)实现「一周免登录」,可按「记住我」产品策略调整。secret: 'atguigu'仅为演示;生产环境改为process.env.SESSION_SECRET。- Store 创建后,
app.use(session({ store }))必须在路由之前;它与mongoose.connect无强耦合,但要求 MongoDB 可达。 - 市面应用:中小型 Node 项目常用 MongoDBStore(省一个 Redis 依赖);高并发场景则首选 RedisStore,因为 Redis 的内存读写更快、还自带过期淘汰。
过期清理是怎么发生的? 一个常被忽略的细节:Session 必须有机制清理过期数据,否则 Store 会无限膨胀。不同 Store 的清理方式不同——FileStore 靠后台定时扫描 sessions/ 目录、删除过期文件;connect-mongodb-session 会在集合上建一个 TTL 索引(基于文档的 expires 字段),由 MongoDB 自身的后台线程自动删除过期文档;Redis 则天生支持 key 过期。选 Store 时除了关注「能否多实例共享」,也要关注「过期清理是否可靠」——这是 Session 体系长期运行不出问题的前提。补充一点:Node 生态里 connect-mongo 与 connect-mongodb-session 是两个不同的包,功能相近、API 略有差异,选其一即可。
3.4 实战示例:Session 增删查 + FileStore
一个完整可运行的 Session 增删查服务(index.js),演示 express-session + FileStore 的持久化效果。
const express = require('express');
const session = require('express-session');
const FileStore = require('session-file-store')(session);
const app = express();
app.use(session({
name: 'sess',
secret: 'atguigu',
saveUninitialized: false,
resave: true,
cookie: { httpOnly: true, maxAge: 1000 * 3600 },
store: new FileStore()
}));
app.get('/set', (req, res) => {
req.session.userName = 'zhangsan';
req.session.userpwd = '12212112';
req.session.userid = '1231';
req.session.parents = [10, 20, 30, 40, 50, 60];
res.send('session 设置成功');
});
app.get('/get', (req, res) => {
console.log(req.session);
res.send(`session 读取成功<br>用户名:${req.session.userName}`);
});
app.get('/delete', (req, res) => {
req.session.destroy(() => {
res.send('session 全部删除');
});
});
app.listen(8080);
【代码注释】这是 Session 的入门实战示例,结构与 §2.3 的 Cookie 示例刻意保持对称,方便对比记忆。
/set、/get、/delete三个路由 +FileStore文件存储。req.session.userpwd这里存了明文密码,仅为演示——生产环境的 Session 只应存userid、username等非敏感字段。parents数组可以直接存进 Session(服务端存储,不受 Cookie 4KB 限制),但仍不宜放过大的对象。/delete里调用destroy,清空后再访问/get应读不到userName。resave: true让每次请求都写文件;开发阶段可接受,高 QPS 时应评估改为false并配合合理的maxAge。- 市面应用:这种「三路由验证」是学习任何 Store(FileStore / RedisStore)时最快的上手方式。
FileStore 落盘说明:运行 /set 后,项目目录下会出现 sessions/ 文件夹,里面是若干 *.json 文件(文件名即 Session ID 的哈希)。重启 Node 进程后再访问 /get 仍能读到 userName——这证明 Session 已持久化到磁盘,而非默认的内存存储。
【代码注释】(FileStore 流程图)这张图说明「Cookie 里只有钥匙、数据在文件里」。
- Cookie 里只有 ID;
userName、userid等真实数据存在磁盘的 JSON 文件里。 - 多开浏览器、用无痕窗口访问会生成多个
sessions/*.json,分别对应不同访客。 - 虚线表示「重启进程数据仍在」——这正是文件存储相对内存存储的价值。
- 生产环境(§5.7)改用 MongoDBStore,多进程共享、不依赖某台机器的本地文件夹。
【实战要点】
- 经典应用场景:单机后台系统「记住登录一周」用 FileStore 足矣;一旦上多实例 / 容器编排,必须换 Redis 或 MongoDB Store。
- 常见坑:①
req.session为undefined——通常是app.use(session(...))没写在路由前面;② 「永远显示未登录」——往往是登录时写的字段名(如userName)和守卫中间件判断的字段名(如username)大小写 / 拼写不一致。 - 性能与最佳实践:Session 里只存
userid、username等最小必要字段,不要存密码、不要存大对象;需要展示的用户资料在请求时按userid现查。
【本章小结】
| 维度 | Session 的特性 | 关键配置 / 方法 |
|---|---|---|
| 数据位置 | 服务端 Store | req.session.xxx |
| 客户端持有 | 只有签名后的 Session ID | cookie: { httpOnly: true } |
| 防伪造 | uid-safe 随机 + HMAC 签名 | secret(务必保密) |
| 存储后端 | 内存 / 文件 / MongoDB / Redis | store 选项 |
| 销毁 | 服务端删除整条记录 | req.session.destroy() |
记忆口诀:Session 是服务端的保险箱,Cookie 只带一把签了名的钥匙——钥匙能丢、保险箱不动。
【面试考点】
Q1:Session 的工作流程是怎样的?Session ID 凭什么不能被伪造?
A:流程四步——服务端生成唯一 ID、用 Set-Cookie 下发、业务数据写入服务端 Store、后续请求带 ID 回来按 ID 取数据。不能伪造靠两点:一是 ID 用加密级随机源生成(uid-safe,熵足够大,猜不中);二是 ID 写进 Cookie 前用 secret 做了 HMAC 签名,服务端先验签,篡改一个字符就通不过。所以 secret 泄露等于「能伪造任意人的会话」。
Q2:express-session 的 resave 和 saveUninitialized 各是什么意思?为什么常设为 false?
A:saveUninitialized: false 表示「session 没被写入过任何数据就不创建、不下发 Cookie」,避免为匿名访客生成大量空会话;resave: false 表示「session 内容没变就不写回 Store」,减少不必要的 IO。两者都设 false 是官方推荐组合,能降低存储压力。文件存储演示里有时用 resave: true,是为了让初学者每次都能看到落盘效果。
3.5 Cookie 与 Session 的区别
| 对比项 | Cookie | Session |
|---|---|---|
| 存储位置 | 浏览器 | 服务器(内存 / 文件 / DB) |
| 传输内容 | 完整键值 | 通常仅 Session ID |
| 安全性 | 易被篡改、无 HttpOnly 时 XSS 可读 | 数据在服务端,相对安全 |
| 容量 | 单条 ≤4KB,数量有限 | 服务端理论上无硬上限 |
| 网络开销 | 每次请求带全部匹配 Cookie | 仅带一个短 ID |
| 生命周期 | 由 Max-Age/Expires 决定 | 由 Store 的 TTL + Cookie 过期共同决定 |
归纳:Cookie 适合存少量非敏感偏好;登录态等业务数据应放在 Session(或 JWT)。注意一个易混点——Session 通常仍以 Cookie 作为「ID 的载体」,所以它俩不是对立关系,而是「Session 借 Cookie 运 ID」。
3.6 易混辨析:sessionStorage、localStorage 与服务端 Session
「Session」这个词被三样完全不同的东西共用,是初学者最大的混淆源,必须一次厘清:
| 名称 | 存在哪 | 谁能访问 | 生命周期 | 是否随请求自动发给服务器 |
|---|---|---|---|---|
| 服务端 Session | 服务器 Store | 服务端代码(req.session) | Store TTL + Cookie 过期共同决定 | 否(只发 Session ID 这条 Cookie) |
| Cookie | 浏览器 Cookie Jar | JS(非 HttpOnly 时)+ 服务端 | Max-Age / Expires | 是,同源请求自动携带 |
localStorage | 浏览器,按源隔离 | 仅同源 JS | 永久,除非手动清除 | 否 |
sessionStorage | 浏览器,按「源 + 标签页」隔离 | 仅同源 JS、仅本标签页 | 标签页关闭即清空 | 否 |
关键结论:sessionStorage 与服务端 Session 毫无关系——前者是纯浏览器端、单标签页的临时存储,名字里的「session」指的是「浏览器标签页这一次访问」;后者是服务器为认证而维护的会话对象。三者里只有 Cookie 会「自动发回服务器」,所以登录态的「钥匙」只能放 Cookie,不能放 localStorage / sessionStorage——除非你手动在每个请求里把它塞进请求头,而那其实就是 JWT 的玩法(见 MDN:Web Storage API)。
下面这个示例把浏览器端可直接演示的三种存储并排放在一起,保存为 storage-compare.html 打开,亲手对比它们的差异:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Cookie / localStorage / sessionStorage 对比</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
button { margin: 4px 6px 4px 0; padding: 6px 10px; cursor: pointer; }
pre { background: #f4f4f4; padding: 12px; border-radius: 6px; white-space: pre-wrap; }
h3 { margin-bottom: 4px; }
</style>
</head>
<body>
<h1>三种浏览器端存储对比</h1>
<p>三种都写一遍后,<b>关闭标签页再重开本页</b>,看哪些数据还在。</p>
<h3>Cookie(同源请求会自动带回服务器)</h3>
<button type="button" id="setCookie">写 Cookie</button>
<h3>localStorage(永久,关标签页也在)</h3>
<button type="button" id="setLocal">写 localStorage</button>
<h3>sessionStorage(关标签页即清空)</h3>
<button type="button" id="setSession">写 sessionStorage</button>
<p><button type="button" id="read">读取全部</button></p>
<pre id="out"></pre>
<script>
const out = document.getElementById('out');
document.getElementById('setCookie').onclick = () => {
document.cookie = 'demo=cookie值; path=/; max-age=3600';
out.textContent = '已写 Cookie';
};
document.getElementById('setLocal').onclick = () => {
localStorage.setItem('demo', 'localStorage值');
out.textContent = '已写 localStorage';
};
document.getElementById('setSession').onclick = () => {
sessionStorage.setItem('demo', 'sessionStorage值');
out.textContent = '已写 sessionStorage';
};
document.getElementById('read').onclick = () => {
out.textContent =
'Cookie: ' + (document.cookie || '(空)') + '\n' +
'localStorage: ' + (localStorage.getItem('demo') || '(空)') + '\n' +
'sessionStorage: ' + (sessionStorage.getItem('demo') || '(空)');
};
</script>
</body>
</html>
【代码注释】这个示例让读者用「关标签页」这一个动作,亲手区分三种存储。
- 三个写入按钮分别调用
document.cookie、localStorage.setItem、sessionStorage.setItem——API 形态不同,但都只在浏览器端。 - 验证方法:三种都写一遍 → 关闭当前标签页 → 重新打开本页 → 点「读取全部」。会发现
sessionStorage已为空,localStorage和未过期的 Cookie 仍在——这就是「标签页会话」与「持久存储」的分界。 - 再开一个新标签页打开同一文件:
sessionStorage又是空的(按标签页隔离),localStorage却共享(按源隔离)。 - 自始至终,服务器只可能收到 Cookie——另外两个永远不会自动出现在请求里。
- 市面应用:
localStorage常存「主题、草稿、JWT」;sessionStorage常存「多步表单的临时步骤数据、单次访问的页面状态」;登录态的「钥匙」则交给HttpOnlyCookie。
四、Token(JWT)概述
名词解释:
- Token(令牌):登录成功后服务器签发给客户端的一串凭证字符串。
- JWT(JSON Web Token):最常见的 Token 实现,由
Header.Payload.Signature三段组成,见 RFC 7519。 - Header(头部):声明签名算法(如
HS256)与类型,Base64URL 编码。 - Payload(载荷):存放声明(claims),如
userId、exp(过期时间),Base64URL 编码——可被任何人解码,不能放敏感信息。 - Signature(签名):用
secret(或私钥)对前两段做签名,服务端靠它验证 Token 未被篡改。 - Bearer Token:放在请求头
Authorization: Bearer <token>中携带的令牌。
Token 常指 JWT:登录成功后服务器签发一串字符串,客户端之后把它放在请求头 Authorization: Bearer <token> 里访问 API。
概念与底层原理
JWT 的结构:Header.Payload.Signature,三段用 . 连接,每段都是 Base64URL 编码。
- 第一段 Header、第二段 Payload 都是 Base64URL,任何人都能解码——这意味着 JWT 不是「加密」,只是「编码 + 签名」,所以 Payload 里绝不能放密码、绝不能放隐私。
- 第三段 Signature 才是安全核心:服务端用
secret对Base64URL(Header) + "." + Base64URL(Payload)计算签名。客户端无法在不知道secret的情况下伪造出正确签名。 - 服务端验证时,用同样的
secret重新算一遍签名,与 Token 第三段比对——一致才信任 Payload,否则判定为伪造。
为什么说 JWT 是「无状态」的? 因为服务端验证一个 Token 时,只需要本地的 secret 做一次签名运算,不需要查任何数据库 / Store。这与 Session「拿 ID 去 Store 查」形成鲜明对比。无状态带来天然的水平扩展能力——任意一台 API 服务器都能独立验证 Token。
HS256 与 RS256:HS256 用同一个 secret 签名和验签(对称),适合「同一个团队的前后端」;RS256 用私钥签名、公钥验签(非对称),适合「签发方和验证方是不同服务」的场景(如统一认证中心签发、各微服务用公钥验证)。
JWT 该存在客户端的哪里?这是个安全权衡。 JWT 签发给客户端后,前端通常有两种存法,各有命门:
- 存
localStorage:跨域携带方便(手动塞进Authorization头),但localStorage能被任意 JS 读取——一旦页面有 XSS 漏洞,Token 会被整个偷走。 - 存
HttpOnlyCookie:JS 读不到,挡住了 XSS 偷 Token,但 Cookie 会被浏览器自动携带,于是又把 CSRF 问题请了回来,需要配合SameSite。
可见「JWT 一定比 Session 安全」是误解——换的只是攻击面。业界较稳的折中是:把短命的 accessToken 放在内存里(刷新页面即丢、XSS 窗口极小),把长命的 refreshToken 放进 HttpOnly + SameSite 的 Cookie。
Refresh Token 轮换(Rotation)。 「短 accessToken + 长 refreshToken」还有一个进阶实践:每次用 refreshToken 换新 accessToken 时,refreshToken 本身也一并换新、旧的立即作废。这样即便某个 refreshToken 被窃取,攻击者和真实用户只要有一方用过它,另一方再用就会失败——服务端借此侦测到泄露并踢掉整条会话链。这是对「JWT 无法主动注销」这一固有缺陷的有力补偿。
【代码注释】(JWT 时序图)这张图与 §3.1 的 Session 时序图刻意对照——区别就在「服务端验证那一步」。
- 登录成功后服务端返回 JWT 字符串,客户端通常存
localStorage或内存,再在后续请求里手动放进Authorization头(也可放 Cookie,看架构)。 - 关键差异:服务端收到 Token 后只做 验签 + 查
exp,不查 Store——这就是「无状态」的含义。 exp过期后必须重新登录,或走 Refresh Token 流程换新。- 无状态的代价:JWT 没法像 Session 那样
destroy一键注销——签出去的 Token 在过期前一直有效,要提前作废只能维护「黑名单」或把有效期设得很短。 - 市面应用:小程序登录、React/Vue SPA 调 REST API、微服务网关鉴权,几乎都用 JWT。
下面是一个可运行的 JWT 结构拆解示例,保存为 jwt-demo.html 打开,直观感受「JWT 三段是什么、Payload 为什么不能放密码」:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>JWT 结构拆解</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
input { width: 100%; padding: 8px; box-sizing: border-box; }
pre { background: #f4f4f4; padding: 12px; border-radius: 6px; white-space: pre-wrap; word-break: break-all; }
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; color: #fff; font-size: 12px; }
</style>
</head>
<body>
<h1>JWT 三段结构拆解</h1>
<p>粘贴一个 JWT(默认已填示例),点击「拆解」查看三段内容:</p>
<input id="jwt" value="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEwMDEsIm5hbWUiOiJ6aGFuZ3NhbiIsImV4cCI6MTczMzAwMDAwMH0.abc123signature">
<p><button type="button" id="parse">拆解</button></p>
<pre id="out"></pre>
<script>
function b64urlDecode(str) {
str = str.replace(/-/g, '+').replace(/_/g, '/');
try { return decodeURIComponent(escape(atob(str))); }
catch (e) { return '(无法解码)'; }
}
document.getElementById('parse').onclick = () => {
const parts = document.getElementById('jwt').value.split('.');
if (parts.length !== 3) {
document.getElementById('out').textContent = '不是合法 JWT(应为三段)';
return;
}
const [h, p, s] = parts;
document.getElementById('out').textContent =
'Header(算法声明,可解码):\n' + b64urlDecode(h) +
'\n\nPayload(业务声明,可解码——所以不能放密码!):\n' + b64urlDecode(p) +
'\n\nSignature(签名,需 secret 才能验证,无法被还原):\n' + s;
};
</script>
</body>
</html>
【代码注释】这个示例的价值在于让读者亲眼看到 Payload 是「明文可解」的。
b64urlDecode把 Base64URL 还原成普通文本:JWT 用的是 Base64URL(-_替代+/),不能直接atob。- 点击「拆解」后会发现 Header 和 Payload 直接就能读出内容——这印证了核心结论:JWT 不是加密,Payload 对任何人透明,绝不能放密码、身份证号等敏感数据。
- 第三段 Signature 是一串「算出来的摘要」,没有
secret既无法伪造、也无法从中反推出原文。 - 市面应用:jwt.io 官网的在线调试器就是这个原理的完整版;后端联调 JWT 时常把 Token 贴上去看 Payload 对不对。
JWT 与 Session 对比:
| 维度 | Session | JWT |
|---|---|---|
| 状态 | 有状态(服务端要查 Store) | 无状态(验签即可) |
| 水平扩展 | 需共享 Session 存储 | 天然适合,任意机器可验 |
| 主动注销 | destroy 立即失效 | 需黑名单或短过期 + Refresh Token |
| 体积 | Cookie 里只有短 ID | Token 较长,每次请求都带 |
| 跨域 | Cookie 跨域受限 | 放 Authorization 头,跨域友好 |
经典场景:小程序、React / Vue SPA 调 REST API、微服务网关鉴权。本章记账本采用 Session(服务端渲染 + 表单跳转),与服务端渲染技术栈一致;JWT 作为对照知识理解即可。
【实战要点】
- 经典应用场景:Vue / React 调 REST API 用 JWT;同一家公司的官网(服务端渲染)仍用 Session Cookie——选型看「是不是前后端分离」。
- 常见坑:① 把手机号、密码等敏感信息放进 JWT Payload(Base64 人人可解);② 以为 JWT 能像 Session 一样「服务端一键注销」——做不到,除非引入黑名单。
- 性能与最佳实践:采用「短有效期
accessToken+ 长有效期refreshToken」组合,兼顾安全与体验;Session 场景则在登录成功后调用req.session.regenerate()换新 ID,防 Session 固定攻击。
【本章小结】
| 维度 | JWT 的特性 | 关键点 |
|---|---|---|
| 结构 | Header.Payload.Signature | 三段 Base64URL |
| 安全 | 编码 ≠ 加密,靠签名防篡改 | Payload 不放敏感信息 |
| 状态 | 无状态,验签不查库 | 天生适合水平扩展 |
| 注销 | 无法直接作废 | 短过期 + Refresh / 黑名单 |
| 选型 | 前后端分离 / 移动端用 JWT | SSR 用 Session |
记忆口诀:JWT 是「带签名的明信片」——内容人人能看,但改一个字就会露馅。
【面试考点】
Q1:Session 和 JWT 该怎么选?
A:看架构。传统 MVC、表单提交、服务端渲染的页面 → 用 Session(配合 Cookie 自然、能主动注销);前后端分离的 REST API、小程序、移动端 → 用 JWT(无状态、跨域友好、易水平扩展)。大型系统常混合:网页登录态用 Session,对外开放 API 用 JWT。
Q2:JWT 能放密码吗?JWT 怎么实现「退出登录」?
A:不能放密码——Header 和 Payload 只是 Base64URL 编码,任何人都能解码,JWT 靠签名防篡改而非加密防偷看。「退出登录」是 JWT 的固有难点:Token 在 exp 之前一直有效,服务端没有它的存根可删。常见做法有三种——把有效期设得很短(如 15 分钟)、维护一个「失效 Token 黑名单」、或改用「短 accessToken + 长 refreshToken」并在退出时作废 refreshToken。
五、实战:记账本注册登录与权限
在 MongoDB 记账本(已有 accounts 数据层)的基础上,本章补齐三件事:用户系统、登录守卫、账单按用户隔离。
名词解释:
- 注册(Register):把新用户的账号与密码摘要写入
users集合。 - 登录(Login):校验账号密码,成功后把身份写入 Session。
- 登录守卫(Route Guard):拦在受保护路由之前的中间件,未登录就跳转登录页。
- 数据隔离(Data Isolation):每条账单带
userid,查询时按当前用户的userid过滤。 - MD5 摘要:对密码做的单向哈希,使数据库即使泄露也看不到明文密码(生产应升级为 bcrypt)。
5.1 路由与数据模型
用户路由表:
| 方法 | 路径 | 说明 | 是否需登录 |
|---|---|---|---|
| GET | /users/reg | 注册页 | 否 |
| POST | /users/reg | 执行注册 | 否 |
| GET | /users/login | 登录页 | 否 |
| POST | /users/login | 执行登录 | 否 |
| GET | /users/logout | 退出 | 否(会销毁 session) |
账单路由(需登录):
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /account | 账单列表 |
| GET | /account/create | 添加表单页 |
| POST | /account/create | 提交添加 |
| GET | /account/delete/:id | 删除账单 |
【代码注释】(记账本架构图)这张图把「用户域」和「账单域」分成两条线,是整个项目的骨架。
- users 路由:注册写库、登录写 Session、退出
destroy——这三个本身不需要checkLogin(用户此时还没登录)。 - account 路由:列表 / 增 / 删全部必须先过
checkLogin,再按req.session.userid操作 MongoDB。 - 数据流向:表单 POST →
md5比对密码 → Session 存username+_id→ 账单写入时带userid字段、查询时按userid过滤。
users 模型:
const mongoose = require('mongoose');
const usersSchema = new mongoose.Schema({
username: { type: String, unique: true },
userpwd: String
});
module.exports = mongoose.model('users', usersSchema);
【代码注释】定义存放账号的 users 集合模型。
users集合存账号信息:username唯一,userpwd存的是密码的 MD5 摘要,不是明文。unique: true在 Mongoose 层声明唯一约束;重复注册同名账号时create的回调会收到非空err。module.exports导出 Model,在路由文件中require('../models/users')即可使用。- 与无登录版相比,这是新增的一张表;账单表则通过
userid字段与它的_id关联。
accounts 模型(新增 userid):
const mongoose = require('mongoose');
const accountsSchema = new mongoose.Schema({
title: String,
remarks: String,
type: Number,
account: Number,
time: String,
userid: mongoose.Schema.Types.ObjectId // 新增:账单归属哪个用户
});
module.exports = mongoose.model('accounts', accountsSchema);
【代码注释】在原 accounts 模型上新增 userid 字段,这是「数据隔离」的地基。
- 在原
accountsSchema 上新增userid: ObjectId,让每条账单都归属一个确定的用户。 - 写入账单时
userid取req.session.userid(登录时存的data._id);查询时也用同一字段做过滤条件。 - 没有
userid的旧数据,在登录版里不会出现在任何人的列表中——做数据迁移时需要批量补上这个字段。 type、account、time等字段与无登录版一致,模板与表单的name属性也保持不变。- 删除账单仍可用
deleteOne({ _id });生产环境建议加上userid条件,防止越权删除他人账单(见 §5.4)。
5.2 注册与登录
npm install md5
【代码注释】安装 MD5 摘要库,用于密码的单向哈希。
md5包对密码做单向摘要:同一明文永远得到固定哈希,登录时用它和库里的userpwd比对。- 这里用 MD5 只为教学简化;生产环境应使用 bcrypt——它带盐、是「慢哈希」,能有效抵抗彩虹表和暴力破解。
- 安装后在路由文件顶部
require('md5')即可,不需要注册为全局中间件。
const express = require('express');
const md5 = require('md5');
const router = express.Router();
const usersModel = require('../models/users');
router.get('/login', (req, res) => {
res.render('users/login');
});
router.post('/login', (req, res) => {
const { username, userpwd } = req.body;
usersModel.findOne({ username, userpwd: md5(userpwd) }, (err, data) => {
if (err) return res.status(500).send('数据库异常');
if (data) {
req.session.username = data.username; // 写入身份
req.session.userid = data._id; // 写入用户 ID
res.render('account/success', { title: '登录成功', url: '/account' });
} else {
res.render('account/fail', {
title: '用户名或密码错误',
url: '/users/login'
});
}
});
});
router.get('/reg', (req, res) => {
res.render('users/reg');
});
router.post('/reg', (req, res) => {
usersModel.create({
username: req.body.username,
userpwd: md5(req.body.userpwd) // 存摘要,不存明文
}, err => {
if (err) {
res.render('account/fail', { title: '注册失败', url: '/users/reg' });
} else {
res.render('account/success', { title: '注册成功,请登录', url: '/users/login' });
}
});
});
module.exports = router;
【代码注释】这是用户系统的核心——注册与登录的完整路由实现。
GET /login、GET /reg渲染 EJS 表单页;POST处理表单提交,依赖app.use(express.urlencoded())才能拿到req.body。- 登录逻辑:
findOne({ username, userpwd: md5(userpwd) })用「用户名 + 密码摘要」同时匹配——这样库里全程没有明文密码参与比对。 - 登录成功的关键两行:
req.session.username与req.session.userid = data._id,把身份写进 Session,这之后用户才算「已登录」。 - 注册逻辑:
create时密码先过md5;err多半是唯一索引冲突,应向用户提示「用户名已存在」。 module.exports = router后在app.js里app.use('/users', usersRouter)挂载。- 前提:Session 中间件必须在
app.js里先于该路由配置,否则req.session赋值无效。 - 市面应用:「查库比对 → 写 Session」是所有传统登录接口的标准两步;区别只在密码哈希算法(生产用 bcrypt)和是否加验证码 / 限流。
5.3 登录校验中间件
module.exports = (req, res, next) => {
if (req.session.username) {
next(); // 已登录,放行
} else {
res.redirect('/users/login'); // 未登录,跳转登录页
}
};
【代码注释】这就是「登录守卫」——一个仅十行、却是整个权限体系核心的中间件。
- 中间件签名固定为
(req, res, next);调用next()表示放行、继续后面的路由处理,不调用就等于中断。 - 判断条件
req.session.username必须与登录时写入的字段严格一致——若登录只写了userid,这里就得改判req.session.userid,否则会「永远未登录」。 - 未登录用户访问受保护路由会收到 302 重定向,浏览器地址栏自动变成登录页。
- 文件放在
middlewares/checklogin.js,注意require路径与module.exports对应。
在 account 路由中,给每个需要保护的路由挂上它:
const checkLogin = require('../middlewares/checklogin');
router.get('/', checkLogin, (req, res) => { /* 列表逻辑 */ });
router.get('/create', checkLogin, (req, res) => { /* 添加表单 */ });
router.post('/create', checkLogin, (req, res) => { /* 提交添加 */ });
router.get('/delete/:id', checkLogin, (req, res) => { /* 删除 */ });
【代码注释】这段演示中间件如何「拦」在业务逻辑之前。
- Express 中间件按书写顺序执行:
checkLogin作为路由的第二个参数,会先于业务回调运行。 - 四个受保护路由都要挂——列表、添加页、提交添加、删除;漏挂任何一个,都会留下「未登录可访问」的漏洞。
- 业务回调里是原有的增删查逻辑,仅多了
checkLogin和userid条件,功能本身不减少。 - 纯 API 项目可把「未登录」改为返回
401+ JSON;服务端渲染场景用redirect跳登录页更符合表单交互习惯。 app.js中间件顺序建议:session→urlencoded→ 静态资源 →/users→/account。- 市面应用:所有后台系统的「未登录自动跳登录页」都是这个套路;更复杂的系统会在守卫里进一步判断角色 / 权限(RBAC)。
5.4 按用户隔离账单
router.get('/', checkLogin, (req, res) => {
accountsModel.find({ userid: req.session.userid }, (err, data) => {
if (err) return res.status(500).send('数据库读取失败');
res.render('account/index', {
data: data.reverse(),
username: req.session.username
});
});
});
router.post('/create', checkLogin, (req, res) => {
accountsModel.create({
userid: req.session.userid, // 关键:账单归属当前用户
...req.body
}, err => {
if (err) res.render('account/fail', { title: '添加失败', url: '/account' });
else res.render('account/success', { title: '添加成功', url: '/account' });
});
});
【代码注释】这段是「数据隔离」真正落地的地方——核心就在那个 userid 过滤条件。
find({ userid: req.session.userid })是隔离的核心:绝不能写成无过滤的find(),否则每个用户都会看到全员账单。data.reverse()把 MongoDB 默认顺序反转为「新的在前」,仅影响展示、不改库内顺序。res.render(..., { username: req.session.username })把当前登录名传给模板,用于页面顶部显示「你好,xxx」。create({ userid: req.session.userid, ...req.body }):用展开运算符合并表单字段;userid写在前面,避免被req.body里的同名字段覆盖(防止用户伪造userid把账单塞给别人)。req.body的字段来自表单的name属性,必须与 Schema 字段对应;type等要注意字符串与 Number 的类型统一。- 安全加固:删除路由建议改为
deleteOne({ _id: id, userid: req.session.userid })——双条件能防止「猜别人账单 ID 来删除」的越权操作。 - 市面应用:多租户 SaaS、多用户笔记 / 网盘 / 记账类产品,本质都是这套「每条数据带 owner 字段 + 查询按 owner 过滤」的隔离模型。
5.5 退出登录
router.get('/logout', (req, res) => {
req.session.destroy(() => {
res.render('account/success', { title: '退出登录', url: '/users/login' });
});
});
【代码注释】退出登录的本质,是「销毁服务端那条 Session 记录」。
GET /logout挂在 users 路由下,不需要checkLogin——即使未登录调用destroy也无害。req.session.destroy(callback)删除 Store 里的整条会话记录;回调里再res.render引导回登录页。- 建议补充
res.clearCookie('sess'):sess要和session({ name: 'sess' })一致,避免浏览器仍带着一个已失效的 ID。 - 退出后再访问
/account,应被checkLogin302 到登录页——可用无痕窗口验证。 - 关键坑:不要只
delete req.session.username而不destroy——那样 Session ID 仍然有效,存在被复用的风险。 - 市面应用:「退出登录」在 Session 体系里干净利落(一个
destroy即时生效),这正是它相对 JWT 的优势之一。
5.6 实施清单(六步)
把整个记账本登录改造拆成可执行的六步:
| 步骤 | 任务 |
|---|---|
| 1 | app.js 配置 express-session + MongoDBStore |
| 2 | models/users.js、models/accounts.js(accounts 新增 userid) |
| 3 | routes/users.js 实现注册 / 登录 / 退出 |
| 4 | middlewares/checklogin.js 编写登录守卫 |
| 5 | routes/account.js 给受保护路由挂 checkLogin,并按 userid 操作数据 |
| 6 | 模板 users/login.ejs、users/reg.ejs,列表页显示当前用户名 |
5.7 app.js 全局 Session(MongoDBStore)
记账本在 app.js 最前面(所有路由之前)挂载 Session,并用 MongoDB 存储会话(库 account-project-auth,集合 mySessions):
const session = require('express-session');
const MongoDBStore = require('connect-mongodb-session')(session);
app.use(session({
name: 'sess',
secret: 'atguigu',
saveUninitialized: false,
resave: true,
cookie: {
httpOnly: true,
maxAge: 1000 * 3600 * 24 * 7 // 7 天免登录
},
store: new MongoDBStore({
uri: 'mongodb://127.0.0.1:27017/account-project-auth',
collection: 'mySessions'
})
}));
app.use(express.urlencoded({ extended: false })); // 解析表单
app.use('/account', accountRouter);
app.use('/users', usersRouter);
【代码注释】这段是整个项目的「装配中心」,中间件顺序在这里一锤定音。
app.use(session(...))必须写在app.use('/users')/app.use('/account')之前,否则路由里req.session不可用。name: 'sess'即浏览器看到的 Cookie 名,要与退出时res.clearCookie('sess')保持一致。maxAge: 7 天控制的是 「Session ID 这条 Cookie」 的过期时间,不是某个业务字段的 TTL。MongoDBStore的 Session 库与业务库可同实例不同库名;多进程(PM2 cluster)部署时共享同一份登录态。resave: true让每次请求都写回 Store,演示阶段稳妥;生产可评估改false以省 IO。- 市面应用:这种「session → 解析器 → 静态 → 业务路由」的装配顺序,是 Express 项目近乎固定的模板。
登录 / 注册表单(users/login.ejs 要点):
<form action="/users/login" method="post">
<input type="text" name="username" />
<input type="password" name="userpwd" />
<button type="submit">登录</button>
</form>
<p>还没有账号?请<a href="/users/reg">注册</a></p>
【代码注释】登录表单的三个细节,决定了后端能不能正确拿到数据。
action="/users/login"+method="post"对应routes/users.js里的POST /login。name="username"/name="userpwd"必须与后端req.body解构出的字段名完全一致——这是表单与后端的「契约」。- 登录成功后渲染
account/success,url: '/account'引导用户进入账单列表(此时已能通过checkLogin)。
完整登录态请求链:
【代码注释】(登录链时序图)这张图把 §5 的所有代码串成一条完整的用户旅程。
- 登录成功的瞬间:
req.session.username/userid写入mySessions集合,响应带上Set-Cookie。 - 之后访问
/account不必再传账号密码,仅靠 Cookie 里的 Session ID 就能恢复会话。 accountsModel.find({ userid: req.session.userid })完成数据隔离——与无登录版「全员账单可见」形成对比。- 市面应用:这条链路就是一个最小但完整的「多用户 Web 应用」骨架,往上加角色、加分页、加搜索即可演进为真实后台系统。
5.8 可运行 HTML:登录 / 注册页
下面是一个完整可运行的登录 / 注册页,保存为 auth-page.html 直接用浏览器打开。它把 §5.2 的 POST /users/login、POST /users/reg 两个接口对应的表单合并在一页,并加上提交前的客户端校验——这是表单页在真实项目里必做的第一道关。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>登录 / 注册</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 360px; margin: 3rem auto; padding: 0 1rem; }
.tabs { display: flex; margin-bottom: 1rem; }
.tabs button { flex: 1; padding: 10px; border: none; background: #eee; cursor: pointer; }
.tabs button.active { background: #4f7cff; color: #fff; }
form { display: none; }
form.active { display: block; }
input { width: 100%; padding: 10px; margin: 6px 0; box-sizing: border-box; }
.submit { background: #4f7cff; color: #fff; border: none; cursor: pointer; }
.msg { color: #d33; min-height: 20px; font-size: 13px; }
</style>
</head>
<body>
<h1>记账本</h1>
<div class="tabs">
<button type="button" id="tab-login" class="active">登录</button>
<button type="button" id="tab-reg">注册</button>
</div>
<!-- 登录表单:对应后端 POST /users/login -->
<form id="form-login" class="active" action="/users/login" method="post">
<input type="text" name="username" placeholder="用户名">
<input type="password" name="userpwd" placeholder="密码">
<p class="msg" id="msg-login"></p>
<input type="submit" class="submit" value="登录">
</form>
<!-- 注册表单:对应后端 POST /users/reg -->
<form id="form-reg" action="/users/reg" method="post">
<input type="text" name="username" placeholder="用户名(≥3 位)">
<input type="password" name="userpwd" placeholder="密码(≥6 位)">
<input type="password" id="confirm" placeholder="确认密码">
<p class="msg" id="msg-reg"></p>
<input type="submit" class="submit" value="注册">
</form>
<script>
const login = document.getElementById('form-login');
const reg = document.getElementById('form-reg');
// Tab 切换
document.getElementById('tab-login').onclick = () => {
login.classList.add('active'); reg.classList.remove('active');
document.getElementById('tab-login').classList.add('active');
document.getElementById('tab-reg').classList.remove('active');
};
document.getElementById('tab-reg').onclick = () => {
reg.classList.add('active'); login.classList.remove('active');
document.getElementById('tab-reg').classList.add('active');
document.getElementById('tab-login').classList.remove('active');
};
// 登录:提交前校验非空
login.onsubmit = e => {
if (!login.username.value.trim() || !login.userpwd.value) {
e.preventDefault(); // 拦下提交
document.getElementById('msg-login').textContent = '用户名和密码不能为空';
}
};
// 注册:提交前校验长度与两次密码一致
reg.onsubmit = e => {
const msg = document.getElementById('msg-reg');
if (reg.username.value.trim().length < 3) {
e.preventDefault(); msg.textContent = '用户名至少 3 位'; return;
}
if (reg.userpwd.value.length < 6) {
e.preventDefault(); msg.textContent = '密码至少 6 位'; return;
}
if (reg.userpwd.value !== document.getElementById('confirm').value) {
e.preventDefault(); msg.textContent = '两次输入的密码不一致';
}
};
</script>
</body>
</html>
【代码注释】这个示例是 §5 用户系统的「前端入口」,演示登录 / 注册页该有的样子。
- 两个
<form>的action/method与后端routes/users.js的POST /users/login、POST /users/reg精确对应;<input>的name(username/userpwd)就是后端req.body解构出的字段名——这是前后端的「契约」,错一个字母就拿不到数据。 onsubmit里用e.preventDefault()在校验不通过时拦下提交:空值、用户名 / 密码长度、两次密码是否一致。客户端校验只为「快速反馈、减少无效请求」,服务端必须再校验一遍——前端校验随时能被绕过。- Tab 切换纯靠
classList控制display,不依赖任何框架。 - 双击用浏览器打开即可看到界面与校验效果;真正提交要由 §5 的 Express 服务接收(单独打开时点提交会因找不到
/users/login而报错,属正常现象)。 - 市面应用:几乎所有网站的登录注册页都是这套结构——表单 + 客户端即时校验 + 提交后端二次校验;区别只在 UI 框架和是否加验证码 / 第三方登录。
【实战要点】
- 经典应用场景:企业内部记账、OA、社区论坛——服务端渲染 + Session 已经足够,对 SEO 和表单交互都友好。
- 常见坑:①
checkLogin判断的是username但登录只写了userid(字段对不上,永远未登录);②accounts写入时漏了userid,导致列表永远为空;③ Session 中间件注册在了业务路由之后,req.session全程undefined。 - 性能与最佳实践:删除 / 修改账单一律加
{ _id, userid }双条件防越权;密码哈希从 MD5 升级为 bcrypt;secret改用环境变量注入。
【本章小结】
| 环节 | 关键代码 | 作用 |
|---|---|---|
| 注册 | create({ userpwd: md5(...) }) | 账号 + 密码摘要入库 |
| 登录 | findOne + req.session.userid = data._id | 校验并写入身份 |
| 守卫 | checkLogin 中间件 | 拦截未登录访问 |
| 隔离 | find({ userid: req.session.userid }) | 每人只见自己的数据 |
| 退出 | req.session.destroy() | 销毁服务端会话 |
记忆口诀:注册入库、登录写 Session、守卫拦门、查询带 userid、退出 destroy——五步成一个多用户应用。
【面试考点】
Q1:如何实现「未登录不能访问 /account」?
A:写一个中间件,读 req.session.username(或 userid),没有就 res.redirect('/users/login'),有就 next() 放行;然后把它作为受保护路由的第二个参数挂上去(router.get('/', checkLogin, handler))。要补充两点:每个受保护路由都得挂、漏一个就是漏洞;纯 API 项目把 redirect 换成 401 JSON 更合适。
Q2:多用户系统如何保证「每个人只能看到自己的数据」?
A:核心是「数据带 owner + 查询按 owner 过滤」。每条业务数据存一个 userid 字段,写入时取 req.session.userid,查询 / 删除 / 修改时都把 userid 作为条件之一。尤其删除和修改要用 { _id, userid } 双条件——只用 _id 的话,攻击者猜到别人的数据 ID 就能越权操作。
六、安全与最佳实践
名词解释:
- XSS(跨站脚本攻击):攻击者把恶意脚本注入页面,在受害者浏览器里执行,可窃取 Cookie、伪造操作。
- CSRF(跨站请求伪造):攻击者诱导已登录用户的浏览器,向目标站点发出「带着用户 Cookie」的请求。
- Session 固定攻击(Session Fixation):攻击者预先准备一个 Session ID,诱导受害者用它登录,之后攻击者用同一个 ID 冒充受害者。
- 彩虹表(Rainbow Table):预先算好的「哈希 → 明文」对照表,用于快速反查弱哈希(如无盐 MD5)。
- 盐(Salt):哈希前混入的随机串,使同样的密码每次得到不同哈希,令彩虹表失效。
概念与底层原理
| 风险 | 成因 | 防护 |
|---|---|---|
| XSS | 页面未转义地输出了用户输入,脚本被执行 | 输出转义、CSP;httpOnly 让脚本偷不到 Cookie |
| CSRF | 浏览器对跨站请求也自动带 Cookie | SameSite;关键操作用 POST + CSRF Token |
| Session 固定 | 登录前后未更换 Session ID | 登录成功后 req.session.regenerate() |
| 明文密码 | 库一旦泄露,密码直接暴露 | bcrypt / scrypt + 盐值 |
| MD5 弱哈希 | 无盐 MD5 易被彩虹表反查 | 升级为 bcrypt(带盐、慢哈希) |
XSS 与 CSRF 的本质区别——这是面试最爱考的对比,必须讲清:
- XSS 是「代码注入」:问题出在你的站点信任了用户输入并把它当代码执行。攻击者能在你的页面里跑任意 JS,自然能读非
HttpOnly的 Cookie。防御核心是「输出转义」——把用户输入里的<>等转成实体,让它永远是「数据」而不是「代码」。 - CSRF 是「凭证滥用」:问题出在浏览器会对跨站请求也自动携带 Cookie。攻击者并不需要读你的 Cookie,他只是「借用」浏览器自动带 Cookie 的行为,从第三方页面发起请求。所以
HttpOnly对 CSRF 完全无效——攻击根本不需要读 Cookie。
理解了这一点就明白:HttpOnly 防 XSS 偷 Cookie、SameSite 防 CSRF 蹭 Cookie,两个属性各管一件事,缺一不可。
【代码注释】(XSS vs CSRF 流程图)这张图把两种攻击并排画出,突出「防御点不同」。
- XSS 链路的命门在「脚本被执行」,
httpOnly切断的是最后一步「脚本读 Cookie」;根治还得靠输出转义。 - CSRF 链路里全程没有「读 Cookie」这一步——恶意页根本读不到,它只是触发请求让浏览器自动带,
SameSite切断的正是「跨站自动带 Cookie」。 - 这张图能直接回答面试题「为什么
httpOnly防不了 CSRF」。
CSRF 原理演示。 下面这个 HTML 演示「第三方页面如何利用已登录的 Cookie 发起请求」(保存为 csrf-demo.html,仅用于理解原理,切勿用于攻击):
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>CSRF 原理演示(仅供理解,勿滥用)</title>
</head>
<body>
<h1>恶意页:诱导浏览器带着 Cookie 访问敏感 URL</h1>
<p>若用户已在 <code>127.0.0.1:3000</code> 登录,下面这个 img 请求会自动携带该站 Cookie。</p>
<!-- 浏览器加载 img 时会向 src 发 GET 请求,并自动带上目标站 Cookie -->
<img src="http://127.0.0.1:3000/users/logout" alt="" width="1" height="1" />
<p>防护:敏感操作改用 POST + CSRF Token;Cookie 设置 <code>SameSite=Lax</code> 或 <code>Strict</code>。</p>
</body>
</html>
【代码注释】这个示例用最小代码揭示 CSRF 的攻击面。
- 浏览器加载
<img>、提交<form>、加载<link>时都会发出请求,并自动带上目标站的 Cookie——这就是 CSRF 的根基。 - 这里故意用
logout做演示(无破坏性);真实攻击会指向「转账」「改密码」等有副作用的接口——这也提醒我们:有副作用的操作绝不能用 GET。 httpOnly在这里毫无作用——攻击不需要读 Cookie。真正的防线是SameSite(让跨站请求不带 Cookie)、CSRF Token(恶意页拿不到这个一次性令牌)、以及校验Referer/Origin。- 市面应用:网上银行、支付、后台管理系统的关键操作普遍采用「CSRF Token + SameSite + 二次验证」三重防护。
Session 配置生产建议:
app.use(session({
secret: process.env.SESSION_SECRET, // 密钥从环境变量注入
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // 生产强制 HTTPS
sameSite: 'lax',
maxAge: 1000 * 60 * 60 * 24 * 7
},
store: mongoStore
}));
【代码注释】这是一份「可直接抄进生产」的 Session 安全配置。
process.env.SESSION_SECRET:密钥在部署时由服务器环境注入,绝不能提交到 Git。secure: true让 Cookie 只在 HTTPS 下传输;本地 HTTP 开发要设false,所以这里按NODE_ENV自动切换。sameSite: 'lax'降低跨站 GET 自动带 Cookie 的风险(CSRF 缓解之一);跨站表单 POST 仍需配 CSRF Token。resave: false+saveUninitialized: false是官方推荐组合,减少 IO 与空会话。store: mongoStore对应 §3.3 的 MongoDBStore,保证多进程共享登录态。- 市面应用:几乎所有 Node 生产项目的 Session 配置都是这套;再进一步会加
cookie.domain(多子域共享)和滚动过期。
【实战要点】
- 经典应用场景:任何带登录的线上系统都要做这套加固——电商、社区、SaaS 后台无一例外;安全配置不是「锦上添花」,是上线前的红线检查项。
- 常见坑:① 把
secret硬编码进代码并推到公开仓库(等于公开了「伪造会话的钥匙」);② 本地开发开了secure: true导致 HTTP 下 Cookie 死活下发不了;③ 用无盐 MD5 存密码,库一泄露常见密码秒被彩虹表反查。 - 性能与最佳实践:bcrypt 的 cost 因子要权衡——太低不安全、太高拖慢登录接口,一般取 10~12;登录接口务必加频率限制(限流 / 验证码),防撞库。
【本章小结】
| 风险 | 一句话成因 | 一句话防护 |
|---|---|---|
| XSS | 用户输入被当代码执行 | 输出转义 + CSP + httpOnly |
| CSRF | 浏览器跨站自动带 Cookie | SameSite + CSRF Token |
| Session 固定 | 登录前后没换 ID | 登录后 regenerate() |
| 弱口令哈希 | 无盐 MD5 易被反查 | bcrypt 带盐慢哈希 |
| 密钥泄露 | secret 进了代码库 | 环境变量注入 |
记忆口诀:httpOnly 防偷、SameSite 防蹭、regenerate 防固定、bcrypt 防破解、环境变量防泄密。
【面试考点】
Q1:XSS 和 CSRF 有什么区别?HttpOnly 能防住哪个?
A:XSS 是「代码注入」——站点把用户输入当代码执行了,攻击者能在页面跑任意 JS;CSRF 是「凭证滥用」——攻击者借浏览器「跨站也自动带 Cookie」的特性,从第三方页面冒名发请求。HttpOnly 只防 XSS(让注入的脚本读不到 Cookie),防不了 CSRF,因为 CSRF 根本不需要读 Cookie。防 CSRF 要靠 SameSite、CSRF Token、校验 Origin/Referer。
Q2:为什么生产环境密码不能用 MD5 存?应该怎么存?
A:MD5 是「快哈希」且无盐——攻击者能用预先算好的彩虹表瞬间反查出常见密码,库一泄露就等于明文泄露。正确做法是用 bcrypt / scrypt / argon2 这类「慢哈希」并加随机盐:慢哈希让暴力破解的成本高到不可行,盐让每个密码的哈希都不同、彩虹表彻底失效。bcrypt 还能调 cost 因子,随硬件升级而提高破解成本。
七、核心示例速查与知识点归纳
7.1 示例学习路线
| 顺序 | 示例 | 核心 API | 学到什么 |
|---|---|---|---|
| ① | Cookie 增删查 | res.cookie / req.cookies | 客户端存储、Set-Cookie |
| ② | Session 增删查(FileStore) | req.session / destroy | 服务端存储、持久化 |
| ③ | JWT 结构拆解 | Base64URL 解码 | Token 三段、签名 |
| ④ | 记账本注册登录(MongoDBStore) | 注册登录 + checkLogin + userid | 完整多用户应用 |
7.2 Cookie vs Session 操作速查
| 操作 | Cookie | Session |
|---|---|---|
| 写 | res.cookie(k, v, opt) | req.session.k = v |
| 读 | req.cookies | req.session |
| 删 | res.clearCookie(k) | req.session.destroy(cb) |
7.3 express-session 必背配置
| 选项 | 推荐值 | 含义 |
|---|---|---|
secret | 必填、放环境变量 | HMAC 签名密钥 |
saveUninitialized | false | 不为匿名访客创建空会话 |
resave | false | session 无变化则不写回 |
cookie.httpOnly | true | 防 XSS 偷 Session ID |
cookie.secure | 生产 true | 仅 HTTPS 传输 |
cookie.sameSite | 'lax' | 缓解 CSRF |
cookie.maxAge | 按需 | Session ID Cookie 过期时间 |
store | 生产必配 | 文件 / MongoDB / Redis |
7.4 登录守卫逻辑
请求 /account → checkLogin → req.session.username 存在?
是 → next() → 执行业务(按 userid 查数据)
否 → res.redirect('/users/login')
【代码注释】(登录守卫流程)这段伪代码是 §5.3 中间件的逻辑骨架。
- 所有
/account开头的受保护请求都先进入checkLogin,再执行find/create/delete。 - 「已登录」的判定依据是 Session 里有没有
username(或你自定义的userid)字段——判定字段必须与登录写入字段一致。 redirect对浏览器是 302,用户感知为「被赶回登录页」;纯 API 项目可改为返回401JSON。- 登录成功写入 Session 后,同一浏览器后续请求自动带 Cookie,无需反复输入密码。
7.5 常见坑速查
| 现象 | 原因 | 处理 |
|---|---|---|
req.session 为 undefined | 未 app.use(session) 或注册晚于路由 | 在路由前注册 |
| 登录后仍跳转登录页 | session 未写入,或守卫判断字段名不一致 | 核对 username 字段拼写 |
| 看到别人的账单 | 查询未按 userid 过滤 | 改为 find({ userid }) |
| 重启服务后掉登录 | 用了默认内存存储 | 换 FileStore / MongoDBStore |
| 注册总是失败 | 用户名已存在(唯一索引冲突) | 提示用户换个用户名 |
| HTTPS 才有的 Cookie 本地丢失 | 本地 HTTP 却开了 secure: true | 按 NODE_ENV 切换 |
7.6 行业应用场景归纳
| 场景 | 推荐方案 |
|---|---|
| 传统 MVC 站点(论坛、后台、CMS) | Session + Cookie |
| 移动 App / SPA 调 REST API | JWT |
| 混合架构(官网 + 开放平台) | 页面 Session + API JWT |
| 单点登录 SSO | 统一认证中心 + Token |
| 高并发、多实例 | Session 存 Redis,或全面 JWT |
7.7 可运行 HTML:Cookie vs Session 对照
保存为 session-cookie-compare.html,帮助厘清「客户端存什么」与「服务端存什么」:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>Cookie 与 Session 对照</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 640px; margin: 2rem auto; padding: 0 1rem; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }
th { background: #f0f4f8; }
</style>
</head>
<body>
<h1>Cookie vs Session(概念对照表)</h1>
<table id="t"></table>
<script>
const rows = [
['存储位置', '浏览器', '服务器(内存/文件/MongoDB)'],
['浏览器携带', '完整键值对', '通常仅 Session ID(Cookie 名如 sess)'],
['典型 API', 'document.cookie / res.cookie', 'req.session(express-session)'],
['登录密码', '不应存放', '不应存放(只存 userId、username)'],
['退出', 'clearCookie', 'session.destroy()'],
['上手路线', 'Cookie 增删查', 'Session FileStore → 记账本 MongoDBStore']
];
document.getElementById('t').innerHTML =
'<tr><th>对比项</th><th>Cookie</th><th>Session</th></tr>' +
rows.map(r => `<tr><td>${r[0]}</td><td>${r[1]}</td><td>${r[2]}</td></tr>`).join('');
</script>
</body>
</html>
【代码注释】这个纯静态示例用一张表把全文最容易混淆的概念钉死。
- 不连接任何服务端,纯前端渲染对照表,与 §2、§3 的文字、时序图互为补充。
- 第二行是关键记忆点:Cookie 把「完整数据」运来运去,Session 只运一个「ID」。
- 第三行强调:Session 的「ID」往往仍通过 HttpOnly Cookie 传递——Cookie 是「运输工具」,Session 是「数据本体」,二者不对立。
- 保存为
.html后用浏览器直接打开即可。
【本章小结】
本章是全文的「速查手册」——把前六章的 API、配置、坑点压缩成可随时翻阅的表格。建议把 §7.3(express-session 配置)和 §7.5(常见坑)截图存档,它们覆盖了实际开发中 90% 的会话控制问题。
记忆口诀:写 res.cookie / req.session、读 req.cookies / req.session、删 clearCookie / destroy——一张表走天下。
【实战要点】
- 经典应用场景:线上「用户反馈一直掉登录 / 登录不上」时,§7.5 的「常见坑速查」就是排障起点——按「中间件顺序 → 字段名一致性 → Store 类型 → Cookie 属性」四步逐一排查,能覆盖绝大多数会话故障。
- 常见坑:把速查表当成「背一遍就够了」——速查表是索引,真正定位问题还得回到 DevTools,看
Set-Cookie有没有下发、Cookie有没有带回、Store 里有没有这条记录。 - 性能与最佳实践:把 §7.3 的
express-session配置沉淀成团队的项目脚手架模板,新项目直接复用,避免每次手敲漏掉httpOnly/secure这类安全项。
【面试考点】
Q1:一个用户反馈「登录后刷新页面就掉登录」,你会怎么排查?
A:分层定位。① 看登录响应有没有 Set-Cookie——没有则是写入时机问题或根本没给 req.session 赋值;② 看下一次请求有没有带 Cookie——没带常是 secure: true 撞上了 HTTP、或 SameSite / 域 / 路径不匹配;③ 带了 ID 但仍掉登录,则查 Store 里有没有这条会话——内存 Store 重启即丢、多实例没共享 Store 会「轮到哪台没有就掉」;④ 最后核对守卫判断的字段名与登录写入的字段名是否一致。
Q2:会话控制这套体系,从一个请求进来到识别出「这是谁」,中间件是按什么顺序工作的?
A:典型顺序是 session → urlencoded / json → 静态资源 → 业务路由(含 checkLogin)。express-session 必须最先跑,它负责验签 Cookie 里的 Session ID、从 Store 取出数据挂到 req.session;之后 body 解析器让 req.body 可用;再之后业务路由里的 checkLogin 才能读 req.session 判断登录态。顺序错了——比如 session 放在路由后面——req.session 就会全程 undefined,整套机制失效。
总结
【代码注释】(知识回顾思维导图)这张导图是全文的「知识地图」,复习时对照它做自测。
- 「原理」是四种会话方案的取舍;「实战示例」是四个动手项目;「安全」是上线红线;「工程」是落地时的关键决策。
- 记账本是最终集成点——它把 Session + MongoDB + Express 路由守卫 + 数据隔离全部汇于一处。
- 能对着这张图把每个分支讲清楚,就算真正掌握了会话控制。
本章在 MongoDB 记账本之上完成了会话控制的完整闭环:
- HTTP 无状态 → 协议层认不出人,需要 Cookie / Session / Token 在应用层补身份。
- Cookie:数据存客户端,
cookie-parser读写,适合主题、语言等轻量非敏感偏好。 - Session:数据存服务端 Store,Cookie 只带签名后的 ID,
express-session+ Store 是核心。 - JWT:自包含、无状态的 API 鉴权方案,前后端分离与移动端的首选。
- 实战:注册(密码摘要入库)→ 登录(写 Session)→
checkLogin守卫 → 账单按userid隔离 → 退出destroy。 - 安全:
httpOnly防 XSS、SameSite防 CSRF、regenerate防 Session 固定、bcrypt 存密码、secret走环境变量。
高频面试题速查
- HTTP 为什么无状态?无状态的好处与代价?(§1.1)
- Cookie、Session、Token 三者的核心区别?(§1.2)
- Cookie 是不是每次请求都带上?浏览器如何判定?(§2.1)
- Cookie 有哪些安全属性?分别防什么?(§2.4)
- Session 的工作流程?Session ID 凭什么不能伪造?(§3.1)
resave和saveUninitialized是什么意思?为何设false?(§3.4)- Session 和 JWT 怎么选?(§4)
- JWT 能放密码吗?JWT 怎么实现退出登录?(§4)
- 如何实现「未登录不能访问某页面」?(§5.3)
- 多用户系统如何做数据隔离?删除为什么要双条件?(§5.4)
- XSS 和 CSRF 的区别?
HttpOnly防得了哪个?(§6) - 为什么密码不能用 MD5 存?应该怎么存?(§6)
- 「登录后刷新就掉登录」如何分层排查?(§7)
- 会话控制的中间件按什么顺序工作?(§7)
学习建议
- 先跑通再深究:依次跑通 Cookie 增删查 → Session FileStore → 记账本登录三个项目,再回头读「概念与底层原理」,体会会更深。
- 善用 DevTools:Application 面板看 Cookie、Network 面板看
Set-Cookie/Cookie头,是排查登录问题最快的手段。 - 往后延伸:掌握本章后,可继续学习 JWT 中间件鉴权(
jsonwebtoken)、OAuth 2.0 第三方登录、单点登录 SSO,以及把 Session 存储换成 Redis 的高并发实践。
延伸阅读:

4109

被折叠的 条评论
为什么被折叠?



