Session 和 Cookie 深度实践
May 23, 2019
前言
很多人都已经看过 Session 和 Cookie 相关的入门文章,却只限于纸上谈兵,不懂得实际运用,本文从最小项目入手,结合前端跨域、HTTP 等知识点,做一次深入实践。
业务场景
在用户访问网站时,我们经常需要记录一些信息,比如
- 会话状态管理(如用户登录状态、购物车信息)
- 个性化设置(如用户自定义设置、主题等)
- 浏览器行为跟踪(如跟踪分析用户行为,淘宝的商品推荐)
- 用户身份(如权限较高的页面,普通用户无法访问)
这时候我们可以借助 Cookie,以下来自 MDN 的官方解释
HTTP Cookie(也叫 Web Cookie 或浏览器 Cookie)是服务器发送到用户浏览器并保存在本地的一小块数据,它会在浏览器下次向同一服务器再发起请求时被携带并发送到服务器上。通常,它用于告知服务端两个请求是否来自同一浏览器,如保持用户的登录状态。Cookie 使基于无状态的 HTTP 协议记录稳定的状态信息成为了可能。
Session 和 Cookie 的联系
Session 中文意思名为“会话”,是一种解决方案,代表客户端和服务端的一次通信过程,在这个过程中如果客户端需要记录数据,服务端会暂时把数据挂载到 session 对象上,当请求结束响应时,将 session 中挂载的数据持久化到客户端的 cookie 上,清空 session,关闭会话。
Cookie 可以看做一个信息容器,借助浏览器的环境对服务端的数据进行持久化存储,随后每次都会在 HTTP 请求头中携带并发送至服务端,这样服务端就可以辨识请求的来源。
创建 Node 服务
下面,我们借助 Koa 框架搭建后端服务,来走一遍具体流程,新建一个 koa-demo
项目。
bash
$ mkdir koa-demo && cd koa-demo$ npm init -y$ npm i koa$ touch app.js index.html
bash
$ mkdir koa-demo && cd koa-demo$ npm init -y$ npm i koa$ touch app.js index.html
在 app.js
中写入以下代码:
js
const Koa = require("koa");const app = new Koa();app.use(ctx => {ctx.body = "hello, Koa";});const PORT = "8080";app.listen(PORT, () => {console.log(`server is running at http://localhost:${PORT}`);});
js
const Koa = require("koa");const app = new Koa();app.use(ctx => {ctx.body = "hello, Koa";});const PORT = "8080";app.listen(PORT, () => {console.log(`server is running at http://localhost:${PORT}`);});
运行并访问 localhost:8080
,就可以看到访问成功!
编写登录接口
bash
$ npm i koa-router koa-body
bash
$ npm i koa-router koa-body
js
const Koa = require("koa");const Router = require("koa-router"); // 实现Koa的路由机制const koaBody = require("koa-body"); // 对请求体中的数据做格式化处理const app = new Koa();const router = new Router();app.use(router.routes()).use(router.allowedMethods());app.use(koaBody());router.post("/login", ctx => {const { usr } = ctx.request.body;ctx.body = usr;});const PORT = "8080";app.listen(PORT, () => {console.log(`server is running at http://localhost:${PORT}`);});
js
const Koa = require("koa");const Router = require("koa-router"); // 实现Koa的路由机制const koaBody = require("koa-body"); // 对请求体中的数据做格式化处理const app = new Koa();const router = new Router();app.use(router.routes()).use(router.allowedMethods());app.use(koaBody());router.post("/login", ctx => {const { usr } = ctx.request.body;ctx.body = usr;});const PORT = "8080";app.listen(PORT, () => {console.log(`server is running at http://localhost:${PORT}`);});
在 index.html 中添加以下代码:
html
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta http-equiv="X-UA-Compatible" content="ie=edge" /><title>Document</title></head><body><button id="btn">send request</button></body><script>const btn = document.getElementById("btn");const data = {usr: "b2d1",psd: "123",};const request = () => {return fetch("http://localhost:8080/login", {body: JSON.stringify(data),method: "POST",headers: {"Content-Type": "application/json; charset=UTF-8",},});};const sendRequest = async () => {const res = await request(); // 返回一个 ReadableStream 对象return await res.text(); // 由于后端返回一段文本数据,利用text()来获取数据,类似的还有json(),blob()};btn.addEventListener("click", async () => {const msg = await sendRequest();console.log(msg);});</script></html>
html
<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><meta http-equiv="X-UA-Compatible" content="ie=edge" /><title>Document</title></head><body><button id="btn">send request</button></body><script>const btn = document.getElementById("btn");const data = {usr: "b2d1",psd: "123",};const request = () => {return fetch("http://localhost:8080/login", {body: JSON.stringify(data),method: "POST",headers: {"Content-Type": "application/json; charset=UTF-8",},});};const sendRequest = async () => {const res = await request(); // 返回一个 ReadableStream 对象return await res.text(); // 由于后端返回一段文本数据,利用text()来获取数据,类似的还有json(),blob()};btn.addEventListener("click", async () => {const msg = await sendRequest();console.log(msg);});</script></html>
注意,我们采用 Fetch API
替代了 XMLHttpRequest API
,Fetch 方法提供了一种简单,合理的方式来跨网络异步获取资源。Fetch 还提供了单个逻辑位置来定义其他 HTTP 相关概念,例如 CORS 和 HTTP 的扩展。
- 语法简洁,更加语义化
- 基于标准 Promise 实现,支持 async/await
- 原生提供,更加底层,提供的 API 丰富(request, response)
为了最大程度上还原开发时的场景,我们 npm i serve
,它可以使本地静态文件成为在浏览器端口上运行的静态站点。
点击按钮,不出意外,我们遭遇了浏览器的同源策略限制,由于前端端口是 5000,后端端口是 8080,端口不一致,浏览器出于安全,禁止了跨域资源的读取(跨域资源写入是支持的,比如 img
标签的 src
属性,嵌入 script
脚本)。
解决跨域
CORS(跨域资源共享)是一种网络浏览器的技术规范,为 web 服务器跨域访问控制提供了安全的跨域数据传输。
根据控制台的提示,我们需要在服务器的响应头中加入 Access-Control-Allow-Origin:whiteList
,这个 whiteList
可以是 *
或者 http://localhost:5000
,我们可以借助 koa-cors
来快速设置 CORS.
bash
$ npm i koa-cors
bash
$ npm i koa-cors
js
const cors = require("koa-cors");// ...app.use(cors());app.use(koaBody());app.use(router.routes()).use(router.allowedMethods());// ...
js
const cors = require("koa-cors");// ...app.use(cors());app.use(koaBody());app.use(router.routes()).use(router.allowedMethods());// ...
这里 app.use
的顺序十分重要,因为 Koa
本身结构简单,核心代码只有一两百行,包括挂载 Request
和 Response
到 Context
上,Compose
实现中间件(Middleware
)依次调用,即洋葱模型,每个请求都会经过所有中间件的过滤。
所以,我们可以利用丰富的中间件使本身短小精悍的 Koa 应用构建成为大型的 Web 应用。
话不多说,继续点击按钮。
哈哈,我们成功拿到了数据,不过细心的我们发现了,在 Network 面板却发送了两次/login
请求,这是怎么回事呢?
我们接下来看看,第一次是个 OPTIONS 请求,可是我们发送的明明是 POST 请求。
这还是牵涉到了 CORS,在 CORS 模式下,当服务端接收到非简单请求时,会先发出”预检”请求,也就是正常请求之前的 OPTIONS 请求。那么什么是 HTTP 简单请求?
符合以下条件的就是简单请求,反之就是非简单请求。
- 请求方法是以下三种方法之一:
- HEAD
- GET
- POST
- HTTP 的头信息不超出以下几种字段:
- Accept
- Accept-Language
- Content-Language
- Content-Type:只限于三个值 application/x-www-form-urlencoded、multipart/form-data、text/plain
而我们在 fetch 配置中,指定了 Content-Type
,故会发起一次预检请求,来请示服务端是否执行客户端真正的请求。
js
headers: {'Content-Type': 'application/json; charset=UTF-8'}
js
headers: {'Content-Type': 'application/json; charset=UTF-8'}
而 koa-cors 已经为我们考虑周到,在背后做了这几件事:
- 在 OPTIONS 响应体和 POST 响应体中写入以下 Header
- 结束 OPTIONS 响应,并返回 204 No Content,并执行 POST 请求
使用 Session 和 Cookie
bash
$ npm i koa-session
bash
$ npm i koa-session
koa-session 是一个高度封装了 Cookie 和 Session 操作的 NPM 包
js
const session = require('koa-session');app.keys = ['some secret hurr']; // 作为 cookies 签名时的秘钥const CONFIG = {key: 'koa:sess', // cookie 的键名maxAge: 86400000, // 过期时间,这里为一天的期限overwrite: true, // 是否覆盖 cookiehttpOnly: true, // 是否JS无法获取 cookiesigned: true, // 是否生成 cookie 的签名,防止浏览器暴力篡改encode: (json) => JSON.stringify(json), // 自定义 cookie 编码函数decode: (str) => JSON.parse(str) // 自定义 cookie 解码函数};// 再次强调,app.use(fn) 的顺序很重要app.use(session(CONFIG, app));app.use(cors());app.use(koaBody());app.use(router.routes()).use(router.allowedMethods());...
js
const session = require('koa-session');app.keys = ['some secret hurr']; // 作为 cookies 签名时的秘钥const CONFIG = {key: 'koa:sess', // cookie 的键名maxAge: 86400000, // 过期时间,这里为一天的期限overwrite: true, // 是否覆盖 cookiehttpOnly: true, // 是否JS无法获取 cookiesigned: true, // 是否生成 cookie 的签名,防止浏览器暴力篡改encode: (json) => JSON.stringify(json), // 自定义 cookie 编码函数decode: (str) => JSON.parse(str) // 自定义 cookie 解码函数};// 再次强调,app.use(fn) 的顺序很重要app.use(session(CONFIG, app));app.use(cors());app.use(koaBody());app.use(router.routes()).use(router.allowedMethods());...
接下来,改造一下登录接口
js
router.post("/login", ctx => {const { usr } = ctx.request.body;const logged = ctx.session.usr || false;if (!logged) {ctx.session.usr = usr;ctx.body = "welcome, you are first login";} else {ctx.body = `hi, ${ctx.session.usr}, you haved logined`;}});
js
router.post("/login", ctx => {const { usr } = ctx.request.body;const logged = ctx.session.usr || false;if (!logged) {ctx.session.usr = usr;ctx.body = "welcome, you are first login";} else {ctx.body = `hi, ${ctx.session.usr}, you haved logined`;}});
我们满怀期待的点下按钮,成功啦!
点第 2 次,点第 3 次,点 N+1 次,革命尚未成功,cookie 根本没有设置成功。
我们在查阅 Fetch 的 MDN 文档发现
默认情况下,fetch 不会从服务端发送或接收任何 cookies, 如果站点依赖于用户 session,则会导致未经认证的请求(要发送 cookies,必须设置 credentials 选项)
真相大白,我们需要手动设置 credentials
属性的值为 include
,才能在当前域名内自动发送 cookie,回到 index.html,修改 request
函数。
js
return fetch('http://localhost:8080/login', {credentials: 'include',...});
js
return fetch('http://localhost:8080/login', {credentials: 'include',...});
来,再次点击按钮,出现如下错误。
根据控制台的报错信息,我们需在服务端响应体中设置 'Access-Control-Allow-Credentials':'true'
的 Header,而 koa-cors 已经内置了相关 API,只需修改一下 app.use(cors({ credentials: true }));
.
最终我们点击按钮,第一次首次登录,没有问题。
在这个过程中,服务端会在 POST 请求响应体设置 Set-Cookie
的 Header,可能是因为跨域的原因,我在 Chrome 的请求响应体里死活找不到,用了 Firefox 就可以看到了。
这时候浏览器就知道要把数据写入 Cookie 中。
继续点击,服务器已经记住了我们登录状态。
查看后续的请求报文,发现每次都会带上 Cookie,以标识请求身份。
可以在 Application 面板查看 Cookies,可以看到已经写入的信息。
koa:sess.sig 是 koa-session 对该 Cookie 的签名,是对 Cookie 原文进行加密生成的一段字符串,它为了防止 Cookie 在浏览器端被暴力修改,假设我们强制修改了 Cookie 的过期时间,服务端会对修改后的 Cookie 生成新的签名,发现与之前的签名不一致,则会清除 Cookie.
就自动登录而言,大致流程如下图所示。
这里我们顺便介绍一下 Cookie 的常用属性,加深对 Cookie 的理解,我们可以在 koa-cors 的 CONFIG
中快速配置。
Name
Cookie 的名称。
Value
Cookie 的值,常为一段经过 JSON.stringify()
处理后的字符串。
Expires / Max-Age
分别指 Cookie 的一个特定的过期时间和有效期。
- Expires 时间要转成 GMT 形式
- 当 Cookie 的过期时间被设定时,设定的日期和时间只与客户端相关,而不是服务端
- 所有支持 Max-Age 的浏览器会忽略 Expires 的值,只有 IE 另外,IE 会忽略 Max-Age 只支持 Expires
- 当 Max-Age 设置为负数,表示是会话级别的 Cookie,只存储在浏览器内存里,只要关闭浏览器,此 Cookie 就会消失
- 当 Max-Age 设置为 0,表示删除此 Cookie
- 当两者都不设置,则 Cookie 失去了持久化的特性,就成为了会话 Cookie ,关闭浏览器,该 Cookie 就会清除
但是事实并不是如此,我在 Chrome 和 Firefox 中尝试会话 Cookie,先修改 koa-session 的 maxAge
属性为 session
.
js
const CONFIG = {maxAge: "session",};
js
const CONFIG = {maxAge: "session",};
点击按钮,可以看到 Expires
属性被设置为 N/A
.
但我在关闭浏览器后,Cookie 依旧存在,原因可能是浏览器一种防止会话 Cookie 过期的安全机制。
Domain
指定了哪些主机域名可以访问 Cookie,Request Body
中的 Host
字段代表了主机域名,如果设置 Domain = .b2d1.top
,那么 m.b2d1.top、b2d1.top
也包含在 Cookie 的访问范围内,实现多网站共享 Cookie.
Path
指定了主机域名下的哪些路径可以访问 Cookie,如设置 /docs
,则以下 http://Domain/docs、http://Domain/docs/web/、http://Domain/docs/web/HTTP
路径都可访问 Cookie,其他路径获取不到 Cookie.
HttpOnly
如设置为 true,则不能通过 document.cookie
来访问此 Cookie.
js
> document.cookie> ""
js
> document.cookie> ""
Size
Cookie 的大小。
Secure
如设置为 true
,则只应通过被 HTTPS 协议加密过的请求发送给服务端 - 当我们在 http 协议中,试图接受设置 Secure 为 true 的 Cookie 时,服务端会报错,Error: Cannot send secure cookie over unencrypted connection
.
至此,我们的 koa-demo 已经实现了最基本的登录接口,并借助 Seesion 和 Cookie 存储用户登录状态的功能,可谓小而美。
使用 externalKey
经过上述的 demo 演示,其实核心就是一句话。
Session 是一种服务端接受会话信息的解决方案,Cookie 是客户端实现的一个信息容器
那么,我们是否可以把信息存储到其他地方,答案是当然可以,理论上,可以存储到任何媒介(Cookie,数据库,系统文件)。出于安全考虑,我们可以在 Cookie 中保存 session 的 externalKey,将信息主体保存到数据库中,通过 externalKey 来映射数据库中的信息主体。
externalKey 事实上是 session 数据的索引,此时相比于直接把 session 存在 cookie 来说多了一层,cookie 里面存的不是 session 而是找到 session 的钥匙。当然我们保存的时候就要做两个工作,一是将 session 存入数据库,另一个是将 session 对应的 key 即(externalKey)写入到 cookie.
实现自定义 Store
koa-session 为我们提供了 store 接口并提供三个方法:get、set、destroy,来实现自定义的存储机制。
js
// app.jslet store = {storage: {},get(key, maxAge) {return this.storage[key];},set(key, sess, maxAge) {this.storage[key] = sess;},destroy(key) {delete this.storage[key];}};const CONFIG = {...,store};router.post('/login', (ctx) => {...console.log(store.storage);});
js
// app.jslet store = {storage: {},get(key, maxAge) {return this.storage[key];},set(key, sess, maxAge) {this.storage[key] = sess;},destroy(key) {delete this.storage[key];}};const CONFIG = {...,store};router.post('/login', (ctx) => {...console.log(store.storage);});
清除 Cookie,重新发起请求,可以看到此时,Cookie 的 Value 为一段随机生成的 Key.
再次点击按钮,查看 Node 服务的打印记录,我们已经将信息主体存储在我们实现的 store 中,通过 Cookie 的 Key 来获取数据,安全性大大提高。
写在最后
本文涉及的知识点较多,建议自己手把手敲出 koa-demo,针对 koa-session,还有很多值得探讨的地方。
- session 是如何挂载到 Koa 上的,cookie 又是如何挂载到 session 上
- cookie 的初始化、格式化
- cookie 的签名加密,校验手段