Cookie

使用场景

HTTP 请求都是无状态的,但是我们的 Web 应用通常都需要知道发起请求的人是谁。

为了解决这个问题,HTTP 协议设计了一个特殊的请求头:Cookie。

服务端可以通过响应头将少量数据响应给客户端,浏览器会遵循协议将数据保存,并在下次请求同一个服务的时候带上对应的数据。

Cookie 主要用于:

  • 会话状态管理(如用户登录状态、购物车、游戏分数或其它需要记录的信息)
  • 个性化设置(如用户自定义设置、主题等)
  • 浏览器行为跟踪(如跟踪分析用户行为等)

服务器使用 Set-Cookie 响应头部向用户浏览器发送 Cookie 信息:

HTTP/1.0 200 OK
Content-type: text/html
Set-Cookie: uid=123456
Set-Cookie: user=tz

后续对该服务发起的每一次新请求,浏览器都会将之前保存的信息通过 Cookie 请求头回传:

GET /user HTTP/1.1
Host: www.example.org
Cookie: uid=123456; user=tz

框架内置了 egg-cookies 插件,提供了 ctx.cookies,用于便捷、安全的读写 Cookie。

// app/controller/home.js
class HomeController extends Controller {
  async add() {
    const { ctx } = this;
    let count = ctx.cookies.get('count');
    count = count ? Number(count) : 0;
    ctx.cookies.set('count', ++count);
    ctx.body = count;
  }
  async remove() {
    const { ctx } = this;
    ctx.cookies.set('count', null);
    ctx.status = 204;
  }
}

友情提示

在使用 Cookie 时我们需要思考清楚它的场景:

  • 需要被浏览器保存多久?
  • 是否可以被 js 获取到?
  • 是否可以被前端修改?

框架默认配置下, Cookie 是加签不加密的,浏览器可以看到明文,js 不能访问,不能被客户端(手工)篡改。

术语解释

过期时间

Expires 和 Max-Age 用于定义 Cookie 对应的键值对的持久化时间。

Expires 优先级低于 Max-Age,如果两者都没设置,则将会在关闭浏览器时失效。

作用域

Domain 和 Path 标识定义了 Cookie 的作用域:即 Cookie 应该发送给哪些 URL。

安全

  • Secure:Cookie 只有在 HTTPS 协议下才会发送给服务端。
  • HttpOnly:Cookie 将无法被 JavaScript 访问,从而避免 XSS 攻击。

加签 && 加密

  • 加签:对 Cookie 进行签名,避免前端篡改。不会修改原键值,而是新增一个 ${key}.sig 的键值。
  • 加密:对 Cookie 进行加密,避免 Cookie 明文写入,泄露给恶意用户。

API 说明

set(key, value, options)

框架提供了 ctx.set(key, value, options) 来向用户发送 Cookie 信息。

其中,key 和 value 称之为一个 键值对。配置参数 options 见下文。

get(key, options)

Cookie 是通过同一个 Header 中传输过来的,因此需要通过该方法解析并获取对应的值。

值得注意的是,获取时的 options.signed 和 options.encrypt 要和 set() 的时候保持一致。

options

与 术语 一一对应,支持以下参数配置:

  • maxAge: {Number} 在浏览器的最长保存时间。
  • expires: {Date} 失效时间。优先级低于 maxAge。如果两者都没设置,则将会在关闭浏览器时失效。
  • path: {String} 生效的 URL 路径,默认为 /,即当前域名下均可访问这个 Cookie。
  • domain: {String} 对生效的域名,默认没有配置,可以配置成只在指定域名才能访问。
  • httpOnly: {Boolean} 是否可以被 js 访问,默认为 true,不允许被 js 访问。
  • secure: {Boolean} 框架会自动判断当前请求是否为 HTTPS,从而自动赋值。
  • signed: {Boolean}:是否加签,默认为 true。
  • encrypt: {Boolean} 是否加密,默认为 false。

此外,还扩展了:

  • overwrite {Boolean}:相同的 Key 的处理逻辑,为 true,则后设置的值会覆盖前面设置的,否则将会发送两个 Set-Cookie 响应头。

配置秘钥

由于我们在 Cookie 中需要用到加解密和验签,所以需要配置一个秘钥供加密使用。

// config/config.default.js
module.exports = {
  keys: 'key1,key2',
};

如果你没配置该属性,则在访问时会报错:

ERROR 17996 [-/::1/-/7ms GET /] nodejs.Error: Please set config.keysfirst

keys 配置成一个字符串,可以按照逗号分隔配置多个 key。

Cookie 在使用这个配置进行加解密时:

  • 加密和加签时只会使用第一个秘钥。
  • 解密和验签时会遍历 keys 进行解密。

如果我们想要更新 Cookie 的秘钥,但是又不希望之前设置到用户浏览器上的 Cookie 失效,可以将新的秘钥配置到 keys 最前面,等过一段时间之后再删去不需要的秘钥即可。

如果要获取前端或者其他系统设置的 Cookie,需要指定参数 signed 为 false,避免对它做验签导致获取不到 Cookie 的值。

ctx.cookies.get('frontend-cookie', {
  signed: false,
});

如果想要 Cookie 在浏览器端可以被 js 访问并修改:

ctx.cookies.set(key, value, {
  httpOnly: false,
  signed: false,
});

不允许浏览器看到明文内容

如果想要 Cookie 在浏览器端不能被修改,不能看到明文:

ctx.cookies.set(key, value, {
  httpOnly: true, // 默认就是 true
  encrypt: true, // 加密传输
});
ctx.cookies.set(key, null);

编写测试

类似 Controller 的测试。

需注意的是:模拟 Cookies 可能需要加上对应的 sig 加签信息。

// test/controller/cookies.test.js
const { app, mock, assert } = require('egg-mock');

describe('test/controller/cookies.test.js', () => {
  it('should GET /', () => {
    return app.httpRequest()
      .get('/cookies')
      .set('cookie', [ 'name=tz; path=/; httponly,name.sig=KdTywxAfCA4vHc1fmNipTZ9zPhBatn1br5tXWomvO14; path=/; httponly' ])
      .expect('set-cookie', /uid=123;/)
      .expect(200);
  });
});

具体的单元测试运行方式,参见 研发流程 - 单元测试 文档。

注意事项

  1. 由于浏览器和其他客户端实现的不确定性,为了保证 Cookie 可以写入成功,建议 value 通过 base64 编码或者其他形式 encode 之后再写入。
  2. 由于浏览器对 Cookie 有长度限制限制,所以尽量不要设置太长的 Cookie。一般来说不要超过 4093 bytes。当设置的 Cookie value 大于这个值时,框架会打印一条警告日志。
  3. 尽可能少写入数据到 Cookie。