Session 和 Cookie,可别光说不练!

4,957 阅读11分钟

前言

很多人都已经看过 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项目

mkdir koa-demo && cd koa-demo
npm init -y
cnpm i koa --save
touch app.js index.html

写入以下代码

// app.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,就可以看到访问成功!

编写登录接口

cnpm i koa-router koa-body --save
// app.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中添加以下代码

<!-- index.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)

为了最大程度上还原开发时的场景,我们cnpm i serve --save,它可以使本地静态文件成为在浏览器端口上运行的静态站点

点击按钮,不出意外,我们遭遇了浏览器的同源策略限制,由于前端端口是5000,后端端口是8080,端口不一致,浏览器出于安全,禁止了跨域资源的读取(跨域资源写入是支持的,比如img标签的src属性,嵌入script脚本)

解决跨域

CORS(跨域资源共享)是一种网络浏览器的技术规范,为web服务器跨域访问控制提供了安全的跨域数据传输。

根据控制台的提示,我们需要在服务器的响应头中加入Access-Control-Allow-Origin:whiteList,这个whiteList可以是*或者http://localhost:5000,我们可以借助koa-cors来快速设置CORS

cnpm i koa-cors
// app.js
const cors = require('koa-cors');
// ...
app.use(cors());
app.use(koaBody());
app.use(router.routes()).use(router.allowedMethods());
// ...

这里 app.use 的顺序十分重要,因为 Koa 本身结构简单,核心代码只有一两百行,包括挂载 RequestResponseContext 上,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,故会发起一次预检请求,来请示服务端是否执行客户端真正的请求。

headers: {
    'Content-Type': 'application/json; charset=UTF-8'
}

而 koa-cors 已经为我们考虑周到,在背后做了这几件事:

  • 在 OPTIONS 响应体和 POST 响应体中写入以下 Header
  • 结束 OPTIONS 响应,并返回 204 No Content,并执行 POST 请求

使用 Session 和 Cookie

cnpm i koa-session --save 

koa-session 是一个高度封装了 Cookie 和 Session 操作的 NPM 包

const session = require('koa-session');
app.keys = ['some secret hurr'];          // 作为cookies签名时的秘钥
const CONFIG = {
  key: 'koa:sess',                        // cookie的键名
  maxAge: 86400000,                       // 过期时间,这里为一天的期限
  overwrite: true,                        // 是否覆盖cookie
  httpOnly: true,                         // 是否JS无法获取cookie
  signed: 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());
...

接下来,改造一下登录接口

// app.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函数

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

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

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,来实现自定义的存储机制

// app.js
let 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 的签名加密,校验手段

我会专门写一篇 koa-session 源码解析的文章,提高对 Koa 框架的理解,JS 编程思想的提高,如何从底层处理 Session 和 Cookie

比如 Cookie 的处理,大家可以先睹为快