会话控制深度解析:Cookie、Session 与登录实战

一篇面向实战的 Web 认证博客:从 HTTP 无状态Cookie / Session,到 Express 记账本注册登录 的完整链路。每个示例都可独立运行,所有路径以博客所在目录为基准。
参考:MDN HTTP Cookie | RFC 6265 | express-session | cookie-parser | JWT.io | OWASP Session Management

目录


零、导读与学习价值

0.1 示例覆盖清单

本文每一个知识点都配有可运行示例,下表列出全部示例与对应章节,确保读者「读完即能动手」:

模块练习要点本文章节
Cookie 增删查示例cookie-parser/set /get /delete、独立端口§2.3
document.cookie 演示浏览器端读写 Cookie、HttpOnly 边界§2.5
Session 增删查示例express-sessionFileStoresessions/*.json 持久化§3.4
三种浏览器存储对比Cookie / localStorage / sessionStorage 关标签页对比§3.6
JWT 结构拆解演示Header.Payload.Signature、Base64URL 解码§4
记账本注册登录项目MongoDBStoreusers 注册登录、checkLoginuserid 隔离账单§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-sessionExpress 中间件,提供 req.session
StoreSession 持久化后端(内存 / 文件 / 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」是后端与全栈岗位的高频必考题。

导读:知识架构与权威参考

本文解决什么问题

阶段你会掌握典型产出
无状态为何需要会话理解「每次请求认不出人」
CookieSet-Cookie、客户端存储、属性语义主题、A/B 测试、记住用户名
Session服务端存数据,Cookie 只带 ID传统服务端渲染的登录态
中间件checkLogin 保护路由未登录跳转登录页
记账本注册 / 登录 / 按 userid 查账多用户账单站
安全XSS / CSRF / Session 固定的成因与防护可上线的安全配置

知识脉络(Mermaid)

HTTP 无状态

会话方案

Cookie 客户端存

Session 服务端存 + Cookie 带 ID

Token JWT 自包含

Express + express-session

注册登录

checkLogin 中间件

账单按 userid 隔离

【代码注释】(知识脉络图)这张流程图表达的是整篇文章的推进逻辑,阅读时应顺着箭头理解「为什么会有下一步」。

  • 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
Expressapp.use 注册中间件,顺序极其重要express.urlencoded() 解析表单 POST
记账本(无登录版)GET/POST /account 直接操作全部账单,没有「这是谁的账单」的概念

本章正是在以上基础上补齐两件事:身份(你是谁)与数据隔离(你只能看你自己的数据)。


一、会话控制介绍

1.1 HTTP 无状态与需求

名词解释:

  • 无状态(Stateless):服务器不保留任何与「上一次请求」相关的上下文;每一次 HTTP 请求/响应都是完全独立的事务。
  • 会话(Session,广义):用户与站点之间一连串相关请求构成的逻辑过程,例如「打开商城 → 浏览 → 加购物车 → 下单」。
  • 会话控制(Session Control):在无状态的 HTTP 之上,人为建立一套「让服务器识别同一用户」的机制。
概念与底层原理

为什么 HTTP 被设计成无状态? 这是规范有意的取舍。RFC 9110(HTTP 语义) 明确:每个请求都应包含服务器理解它所需的全部信息。无状态带来三个关键收益:

  1. 可水平扩展——任意一台服务器都能处理任意一个请求,因为没有「这个用户的上下文只在 3 号机」这种绑定,负载均衡才得以简单实现。
  2. 容错性强——某台机器宕机,请求转发到别的机器即可,不会丢失「会话内存」。
  3. 实现简单——服务器处理完一个请求即可释放资源,不必为每个连接维护长期状态。

代价就是:服务器天生认不出人。注意区分两个层次——TCP 连接可以靠 Keep-Alive 复用,但那只是「传输层的管道复用」;HTTP 协议语义层依然不记得「上一个请求是谁发的」。所以会话控制必须在应用层额外补一层身份标识,这正是 Cookie / Session / Token 的用武之地。

典型需求:

  • 登录后访问个人中心、订单列表
  • 购物车跨页面、跨请求保留
  • 后台管理仅管理员可进
  • 「记住我」7 天免登录
服务器 浏览器 服务器 浏览器 服务器无法判断两次请求是否同一人 每个请求都被当作匿名访客 请求1(无身份信息) 响应 请求2(仍无身份信息) 请求3(仍无身份信息)

【代码注释】(无状态时序图)这张图刻画的是「没有会话控制时」的窘境,是后面所有方案要解决的问题。

  • 每次请求在协议层彼此独立:服务器处理完请求 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 本质是浏览器保存的小文本,由服务器通过响应头下发,之后同源请求自动带回

服务器 浏览器 服务器 浏览器 Set-Cookie: userName=zhangsan 按「域+路径+名字」写入 Cookie Jar Cookie: userName=zhangsan 从 req.cookies 解析出用户标识

【代码注释】(Cookie 时序图)这张图展示 Cookie 的完整生命周期:下发 → 存储 → 回传 → 识别。

  • 第一次响应通过 Set-Cookie 头让浏览器保存键值;之后符合条件的请求会自动带上 Cookie 头。
  • 服务器从 req.headers.cookie(原始字符串)或经 cookie-parser 解析后的 req.cookies 对象中取值。
  • Cookie 存的是完整键值(不像 Session 只存一个 ID),所以敏感数据不宜直接写入 Cookie。
  • 市面应用:几乎所有网站的「主题/语言偏好」「未登录购物车」「广告追踪 ID」都靠这套下发-回传机制实现。

浏览器「要不要带这个 Cookie」的判定算法。 这是 Cookie 机制最值得深挖的底层细节。依据 RFC 6265,浏览器在发出一个请求前,会逐条检查 Cookie Jar 里的每个 Cookie,全部条件满足才携带

  1. 域匹配(Domain Match):请求的主机名等于 Cookie 的 Domain,或是其子域。未显式设置 Domain 时只匹配「下发它的那个确切主机」。
  2. 路径匹配(Path Match):请求路径以 Cookie 的 Path 为前缀。Path=/admin 的 Cookie 不会发往 /user
  3. 是否过期Max-Age / Expires 已到期的 Cookie 会被丢弃,不再携带。
  4. Secure 检查:带 Secure 的 Cookie 只在 HTTPS 请求中携带。
  5. 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 传输防中间人嗅探
sameSiteStrict / 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 才能还原成数组。
  • addressmaxAge: 3600 * 1000(1 小时),是「持久 Cookie」;username 未设过期时间,是「会话级 Cookie」,关闭浏览器可能被清除(取决于浏览器策略)。
  • 验证顺序很重要:必须先访问 /set 再访问 /get,否则 req.cookies.usernameundefined
  • clearCookie('username') 只删 username 这一个键;useridaddress 等其它键仍会随后续请求发送。
  • 市面应用:打开 Chrome DevTools → Application → Cookies,可直观看到键值、过期时间、HttpOnly / SameSite 等属性——这是排查登录问题时最常用的面板。

2.4 Cookie 的缺点与适用场景

缺点:

  1. 容量小——单条约 4KB,每个域名下数量有限(约 50 个),不适合存大量业务数据。
  2. 自动携带带来开销——每次请求都会自动带上该域所有匹配的 Cookie,体积大时会拖慢每个请求(尤其首包)。
  3. 存于客户端,可被篡改——用户能在 DevTools 里随意改 Cookie 值,因此绝不能存敏感业务数据(应只存 ID 或非敏感偏好),也不能把「是否管理员」这种判定依据放进去。
  4. 隐私与合规——第三方追踪 Cookie 受 GDPR 等法规约束,浏览器也在逐步淘汰第三方 Cookie。

经典应用场景:

场景说明
主题 / 语言theme=darklang=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 / Cookieres.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 偷 CookieSecure 限定只在 HTTPS 下传输,防中间人嗅探;SameSiteStrict/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=3600path=/ 表示全站路径都会携带该 Cookie。
  • 无法通过 JS 设置 HttpOnly(这是规范有意的限制),因此 Session ID 这类凭证必须由服务端用 cookie: { httpOnly: true } 下发。
  • 删除写法:写一条同名、max-age=0(或 expires 为过去时间)的 Cookie,且 path 要和设置时一致才能删干净。
  • 市面应用:前端框架的「记住主题」「关闭新手引导后不再弹出」等轻量偏好,常直接用 document.cookielocalStorage;而登录凭证一律走服务端 HttpOnly Cookie,绝不用 document.cookie

三、Session 机制

3.1 概念与底层原理

名词解释:

  • Session(服务端会话):保存在服务器端的、与某一用户会话一一对应的数据对象。
  • Session ID:唯一标识一个 Session 的随机字符串,是浏览器与服务端 Session 之间的「钥匙」。
  • Store(存储后端):Session 数据实际存放的地方——内存、文件、MongoDB、Redis 等。
  • 签名(Signing):用密钥对 Session ID 做 HMAC 摘要,附在 Cookie 里,使客户端无法伪造 / 篡改 ID。
  • req.sessionexpress-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 做了两件事:

  1. 不可猜测:ID 由 uid-safe 库用加密级随机源生成(默认 24 字节),熵足够大,攻击者无法靠遍历猜中别人的 ID。
  2. 不可篡改:ID 写进 Cookie 前会用 secret 做一次 HMAC 签名cookie-signature),Cookie 实际形如 s:原始ID.签名。服务器收到后先验签——签名对不上,直接判定为伪造,当作没有 Session。所以即使攻击者改了一个字符,也通不过验签。

这就解释了 secret 为什么必须保密:secret 一旦泄露,攻击者就能为任意 ID 伪造合法签名,等于能伪造任意人的会话。

Store 接口长什么样? 所有 Session 存储(FileStore、MongoDBStore、RedisStore)都实现同一组方法,理解它就理解了「换存储为什么这么容易」:

  • get(sid, cb):按 ID 取出 Session
  • set(sid, session, cb):保存 / 更新 Session
  • destroy(sid, cb):删除 Session(退出登录时调用)
  • touch(sid, session, cb):仅刷新过期时间(用于「滑动过期」)

正因为接口统一,从内存换成 MongoDB 只需改一行 store 配置,业务代码一行不用动——这是「面向接口编程」的经典范例。

Session 存储 Express 浏览器 Session 存储 Express 浏览器 POST /login(账号密码) 校验通过,生成 Session ID set(sid, { userId, username }) Set-Cookie: sess=s:签名后的ID GET /account + Cookie 验签,取出原始 sid get(sid) { userId, username } 渲染账单页

【代码注释】(Session 时序图)这张图把「登录写入」和「后续读取」两个阶段画在一起,是 Session 全链路的浓缩。

  • POST /login 校验通过后,向 Store 写入 { userId, username } 等字段。
  • 响应头 Set-Cookie: sess=... 中的 sessname: '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.redirectres.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-sessionrolling 设为 true 后,每一次请求都会重置 Cookie 的过期倒计时:只要用户持续活动,登录态就一直续期;连续 maxAge 时长没有任何请求,才真正过期。这正是网银、后台管理系统「闲置一段时间自动登出、但持续操作就不掉线」的实现方式。

并发请求与 Session 竞态。 Session 有一个容易被忽略的坑:同一用户的多个请求几乎同时到达时,它们各自读到一份 Session 副本、各自修改、再各自写回——后写回的会覆盖先写回的(「丢失更新」)。SPA 页面一次性并发拉取多个接口时尤其常见。规避思路有三:把需要并发的写操作合并;对关键写入用 reload + save 收敛;把高频变更的状态移出 Session,改用数据库的行级更新。

3.3 Session 存储:内存 / 文件 / MongoDB

存储npm 包适用场景
默认内存无(内置 MemoryStore)仅开发、单进程;重启即丢、有内存泄漏风险
文件session-file-store单机持久化、演示「重启不掉登录」
MongoDBconnect-mongodb-session生产环境、多实例共享 Session
Redisconnect-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 目录;可配置 pathttl(存活时间)等参数。
  • 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-mongoconnect-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 只应存 useridusername 等非敏感字段。
  • 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 已持久化到磁盘,而非默认的内存存储。

重启后仍在

浏览器 Cookie sess=ID

express-session 中间件

FileStore

sessions/xxx.json

【代码注释】(FileStore 流程图)这张图说明「Cookie 里只有钥匙、数据在文件里」。

  • Cookie 里只有 IDuserNameuserid 等真实数据存在磁盘的 JSON 文件里。
  • 多开浏览器、用无痕窗口访问会生成多个 sessions/*.json,分别对应不同访客。
  • 虚线表示「重启进程数据仍在」——这正是文件存储相对内存存储的价值。
  • 生产环境(§5.7)改用 MongoDBStore,多进程共享、不依赖某台机器的本地文件夹。

【实战要点】

  • 经典应用场景:单机后台系统「记住登录一周」用 FileStore 足矣;一旦上多实例 / 容器编排,必须换 Redis 或 MongoDB Store。
  • 常见坑:① req.sessionundefined——通常是 app.use(session(...)) 没写在路由前面;② 「永远显示未登录」——往往是登录时写的字段名(如 userName)和守卫中间件判断的字段名(如 username)大小写 / 拼写不一致。
  • 性能与最佳实践:Session 里只存 useridusername 等最小必要字段,不要存密码、不要存大对象;需要展示的用户资料在请求时按 userid 现查。

【本章小结】

维度Session 的特性关键配置 / 方法
数据位置服务端 Storereq.session.xxx
客户端持有只有签名后的 Session IDcookie: { httpOnly: true }
防伪造uid-safe 随机 + HMAC 签名secret(务必保密)
存储后端内存 / 文件 / MongoDB / Redisstore 选项
销毁服务端删除整条记录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-sessionresavesaveUninitialized 各是什么意思?为什么常设为 false
A:saveUninitialized: false 表示「session 没被写入过任何数据就不创建、不下发 Cookie」,避免为匿名访客生成大量空会话;resave: false 表示「session 内容没变就不写回 Store」,减少不必要的 IO。两者都设 false 是官方推荐组合,能降低存储压力。文件存储演示里有时用 resave: true,是为了让初学者每次都能看到落盘效果。

3.5 Cookie 与 Session 的区别

对比项CookieSession
存储位置浏览器服务器(内存 / 文件 / 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.sessionStore TTL + Cookie 过期共同决定否(只发 Session ID 这条 Cookie)
Cookie浏览器 Cookie JarJS(非 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.cookielocalStorage.setItemsessionStorage.setItem——API 形态不同,但都只在浏览器端。
  • 验证方法:三种都写一遍 → 关闭当前标签页 → 重新打开本页 → 点「读取全部」。会发现 sessionStorage 已为空,localStorage 和未过期的 Cookie 仍在——这就是「标签页会话」与「持久存储」的分界。
  • 再开一个新标签页打开同一文件:sessionStorage 又是空的(按标签页隔离),localStorage 却共享(按源隔离)。
  • 自始至终,服务器只可能收到 Cookie——另外两个永远不会自动出现在请求里。
  • 市面应用localStorage 常存「主题、草稿、JWT」;sessionStorage 常存「多步表单的临时步骤数据、单次访问的页面状态」;登录态的「钥匙」则交给 HttpOnly Cookie。

四、Token(JWT)概述

名词解释:

  • Token(令牌):登录成功后服务器签发给客户端的一串凭证字符串。
  • JWT(JSON Web Token):最常见的 Token 实现,由 Header.Payload.Signature 三段组成,见 RFC 7519
  • Header(头部):声明签名算法(如 HS256)与类型,Base64URL 编码。
  • Payload(载荷):存放声明(claims),如 userIdexp(过期时间),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 才是安全核心:服务端用 secretBase64URL(Header) + "." + Base64URL(Payload) 计算签名。客户端无法在不知道 secret 的情况下伪造出正确签名。
  • 服务端验证时,用同样的 secret 重新算一遍签名,与 Token 第三段比对——一致才信任 Payload,否则判定为伪造。

为什么说 JWT 是「无状态」的? 因为服务端验证一个 Token 时,只需要本地的 secret 做一次签名运算,不需要查任何数据库 / Store。这与 Session「拿 ID 去 Store 查」形成鲜明对比。无状态带来天然的水平扩展能力——任意一台 API 服务器都能独立验证 Token。

HS256 与 RS256HS256 用同一个 secret 签名和验签(对称),适合「同一个团队的前后端」;RS256 用私钥签名、公钥验签(非对称),适合「签发方和验证方是不同服务」的场景(如统一认证中心签发、各微服务用公钥验证)。

JWT 该存在客户端的哪里?这是个安全权衡。 JWT 签发给客户端后,前端通常有两种存法,各有命门:

  • localStorage:跨域携带方便(手动塞进 Authorization 头),但 localStorage 能被任意 JS 读取——一旦页面有 XSS 漏洞,Token 会被整个偷走。
  • HttpOnly Cookie:JS 读不到,挡住了 XSS 偷 Token,但 Cookie 会被浏览器自动携带,于是又把 CSRF 问题请了回来,需要配合 SameSite

可见「JWT 一定比 Session 安全」是误解——换的只是攻击面。业界较稳的折中是:把短命的 accessToken 放在内存里(刷新页面即丢、XSS 窗口极小),把长命的 refreshToken 放进 HttpOnly + SameSite 的 Cookie。

Refresh Token 轮换(Rotation)。 「短 accessToken + 长 refreshToken」还有一个进阶实践:每次用 refreshToken 换新 accessToken 时,refreshToken 本身也一并换新、旧的立即作废。这样即便某个 refreshToken 被窃取,攻击者和真实用户只要有一方用过它,另一方再用就会失败——服务端借此侦测到泄露并踢掉整条会话链。这是对「JWT 无法主动注销」这一固有缺陷的有力补偿。

API 服务 客户端 API 服务 客户端 POST /login(账号密码) 校验通过,用 secret 签发 JWT 返回 JWT 字符串 存入 localStorage / 内存 GET /api/data + Authorization: Bearer JWT 用 secret 验签 + 检查 exp 是否过期 返回 JSON 数据

【代码注释】(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 对比:

维度SessionJWT
状态有状态(服务端要查 Store)无状态(验签即可)
水平扩展需共享 Session 存储天然适合,任意机器可验
主动注销destroy 立即失效需黑名单或短过期 + Refresh Token
体积Cookie 里只有短 IDToken 较长,每次请求都带
跨域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 / 黑名单
选型前后端分离 / 移动端用 JWTSSR 用 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 路由

注册 MD5 入库

登录写 session

destroy session

account 路由

checkLogin 守卫

按 userid CRUD

【代码注释】(记账本架构图)这张图把「用户域」和「账单域」分成两条线,是整个项目的骨架。

  • 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 字段,这是「数据隔离」的地基。

  • 在原 accounts Schema 上新增 userid: ObjectId,让每条账单都归属一个确定的用户。
  • 写入账单时 useridreq.session.userid(登录时存的 data._id);查询时也用同一字段做过滤条件。
  • 没有 userid 的旧数据,在登录版里不会出现在任何人的列表中——做数据迁移时需要批量补上这个字段。
  • typeaccounttime 等字段与无登录版一致,模板与表单的 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 /loginGET /reg 渲染 EJS 表单页;POST 处理表单提交,依赖 app.use(express.urlencoded()) 才能拿到 req.body
  • 登录逻辑:findOne({ username, userpwd: md5(userpwd) }) 用「用户名 + 密码摘要」同时匹配——这样库里全程没有明文密码参与比对
  • 登录成功的关键两行:req.session.usernamereq.session.userid = data._id,把身份写进 Session,这之后用户才算「已登录」。
  • 注册逻辑:create 时密码先过 md5err 多半是唯一索引冲突,应向用户提示「用户名已存在」。
  • module.exports = router 后在 app.jsapp.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 作为路由的第二个参数,会先于业务回调运行。
  • 四个受保护路由都要挂——列表、添加页、提交添加、删除;漏挂任何一个,都会留下「未登录可访问」的漏洞。
  • 业务回调里是原有的增删查逻辑,仅多了 checkLoginuserid 条件,功能本身不减少。
  • 纯 API 项目可把「未登录」改为返回 401 + JSON;服务端渲染场景用 redirect 跳登录页更符合表单交互习惯。
  • app.js 中间件顺序建议:sessionurlencoded → 静态资源 → /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,应被 checkLogin 302 到登录页——可用无痕窗口验证。
  • 关键坑:不要只 delete req.session.username 而不 destroy——那样 Session ID 仍然有效,存在被复用的风险。
  • 市面应用:「退出登录」在 Session 体系里干净利落(一个 destroy 即时生效),这正是它相对 JWT 的优势之一。

5.6 实施清单(六步)

把整个记账本登录改造拆成可执行的六步:

步骤任务
1app.js 配置 express-session + MongoDBStore
2models/users.jsmodels/accounts.js(accounts 新增 userid
3routes/users.js 实现注册 / 登录 / 退出
4middlewares/checklogin.js 编写登录守卫
5routes/account.js 给受保护路由挂 checkLogin,并按 userid 操作数据
6模板 users/login.ejsusers/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/successurl: '/account' 引导用户进入账单列表(此时已能通过 checkLogin)。

完整登录态请求链:

MongoDB Express 浏览器 用户 MongoDB Express 浏览器 用户 提交 POST /users/login 表单数据 + 已有 Cookie findOne(users) 校验账号密码 写入 mySessions 集合 Set-Cookie: sess + 成功页 点击进入 GET /account 携带 Cookie sess 读 session + find(accounts) by userid 渲染 index.ejs(仅本人账单)

【代码注释】(登录链时序图)这张图把 §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/loginPOST /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.jsPOST /users/loginPOST /users/reg 精确对应;<input>nameusername / 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浏览器对跨站请求也自动带 CookieSameSite;关键操作用 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,两个属性各管一件事,缺一不可

CSRF 攻击

XSS 攻击

httpOnly 阻断

SameSite 阻断

恶意脚本注入页面

在受害者浏览器执行

读取非 HttpOnly Cookie

受害者已在目标站登录

访问攻击者的恶意页

恶意页发请求
浏览器自动带 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浏览器跨站自动带 CookieSameSite + 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 操作速查

操作CookieSession
res.cookie(k, v, opt)req.session.k = v
req.cookiesreq.session
res.clearCookie(k)req.session.destroy(cb)

7.3 express-session 必背配置

选项推荐值含义
secret必填、放环境变量HMAC 签名密钥
saveUninitializedfalse不为匿名访客创建空会话
resavefalsesession 无变化则不写回
cookie.httpOnlytrue防 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 项目可改为返回 401 JSON。
  • 登录成功写入 Session 后,同一浏览器后续请求自动带 Cookie,无需反复输入密码。

7.5 常见坑速查

现象原因处理
req.sessionundefinedapp.use(session) 或注册晚于路由在路由前注册
登录后仍跳转登录页session 未写入,或守卫判断字段名不一致核对 username 字段拼写
看到别人的账单查询未按 userid 过滤改为 find({ userid })
重启服务后掉登录用了默认内存存储FileStore / MongoDBStore
注册总是失败用户名已存在(唯一索引冲突)提示用户换个用户名
HTTPS 才有的 Cookie 本地丢失本地 HTTP 却开了 secure: trueNODE_ENV 切换

7.6 行业应用场景归纳

场景推荐方案
传统 MVC 站点(论坛、后台、CMS)Session + Cookie
移动 App / SPA 调 REST APIJWT
混合架构(官网 + 开放平台)页面 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:典型顺序是 sessionurlencoded / json → 静态资源 → 业务路由(含 checkLogin)。express-session 必须最先跑,它负责验签 Cookie 里的 Session ID、从 Store 取出数据挂到 req.session;之后 body 解析器让 req.body 可用;再之后业务路由里的 checkLogin 才能读 req.session 判断登录态。顺序错了——比如 session 放在路由后面——req.session 就会全程 undefined,整套机制失效。


总结

会话控制

原理

HTTP无状态

Cookie 客户端存

Session 服务端存

JWT 自包含

实战示例

Cookie 增删查

Session FileStore

JWT 结构拆解

记账本 MongoDBStore

安全

httpOnly 防XSS

SameSite 防CSRF

regenerate 防固定

bcrypt 存密码

工程

checkLogin 守卫

userid 数据隔离

中间件顺序

【代码注释】(知识回顾思维导图)这张导图是全文的「知识地图」,复习时对照它做自测。

  • 「原理」是四种会话方案的取舍;「实战示例」是四个动手项目;「安全」是上线红线;「工程」是落地时的关键决策。
  • 记账本是最终集成点——它把 Session + MongoDB + Express 路由守卫 + 数据隔离全部汇于一处。
  • 能对着这张图把每个分支讲清楚,就算真正掌握了会话控制。

本章在 MongoDB 记账本之上完成了会话控制的完整闭环:

  1. HTTP 无状态 → 协议层认不出人,需要 Cookie / Session / Token 在应用层补身份。
  2. Cookie:数据存客户端,cookie-parser 读写,适合主题、语言等轻量非敏感偏好。
  3. Session:数据存服务端 Store,Cookie 只带签名后的 ID,express-session + Store 是核心。
  4. JWT:自包含、无状态的 API 鉴权方案,前后端分离与移动端的首选。
  5. 实战:注册(密码摘要入库)→ 登录(写 Session)→ checkLogin 守卫 → 账单按 userid 隔离 → 退出 destroy
  6. 安全httpOnly 防 XSS、SameSite 防 CSRF、regenerate 防 Session 固定、bcrypt 存密码、secret 走环境变量。

高频面试题速查

  1. HTTP 为什么无状态?无状态的好处与代价?(§1.1)
  2. Cookie、Session、Token 三者的核心区别?(§1.2)
  3. Cookie 是不是每次请求都带上?浏览器如何判定?(§2.1)
  4. Cookie 有哪些安全属性?分别防什么?(§2.4)
  5. Session 的工作流程?Session ID 凭什么不能伪造?(§3.1)
  6. resavesaveUninitialized 是什么意思?为何设 false(§3.4)
  7. Session 和 JWT 怎么选?(§4)
  8. JWT 能放密码吗?JWT 怎么实现退出登录?(§4)
  9. 如何实现「未登录不能访问某页面」?(§5.3)
  10. 多用户系统如何做数据隔离?删除为什么要双条件?(§5.4)
  11. XSS 和 CSRF 的区别?HttpOnly 防得了哪个?(§6)
  12. 为什么密码不能用 MD5 存?应该怎么存?(§6)
  13. 「登录后刷新就掉登录」如何分层排查?(§7)
  14. 会话控制的中间件按什么顺序工作?(§7)

学习建议

  • 先跑通再深究:依次跑通 Cookie 增删查 → Session FileStore → 记账本登录三个项目,再回头读「概念与底层原理」,体会会更深。
  • 善用 DevTools:Application 面板看 Cookie、Network 面板看 Set-Cookie / Cookie 头,是排查登录问题最快的手段。
  • 往后延伸:掌握本章后,可继续学习 JWT 中间件鉴权(jsonwebtokenOAuth 2.0 第三方登录单点登录 SSO,以及把 Session 存储换成 Redis 的高并发实践。

延伸阅读:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值