Koa 依赖的库 cookies

1,375 阅读3分钟

「这是我参与11月更文挑战的第17天,活动详情查看:2021最后一次更文挑战

本文为 koa 依赖系列文章,前几篇也可以在站内查看:

在使用 koa 时,我们可以使用 context 下面的 cookies 来进行 cookie 管理,在源码可以看到,context 下面的 cookies 实际上就是一个 Cookies 的实例,它是由 cookies 库提供的。

我们操作 cookie 最常用的方法是 get 和 set,服务器可以在 response header 中通过 Set-Cookie 来给浏览器添加 cookie,浏览器收到 Set-Cookie header 后会将 cookie 内容保存,当用户使用浏览器再次请求同域资源时,浏览器会自动在 request header 中添加 cookie 信息。因此 cookies 库中的 get 和 set 的实现原理就是控制这两个 header。

常规 cookie get 和 set 逻辑并不复杂,和其他 header 的处理方式一样,get 时使用正则表达式匹配 cookie header,在 set 时把参数处理成字符串设置到 Set-Cookie header 上,由于 Cookie 的结构比较复杂,在 cookies 内部定义了一个 Cookie 类型来封装 Cookie 格式数据:

function Cookie(name, value, attrs) {
  if (!fieldContentRegExp.test(name)) {
    throw new TypeError('argument name is invalid');
  }

  if (value && !fieldContentRegExp.test(value)) {
    throw new TypeError('argument value is invalid');
  }

  this.name = name
  this.value = value || ""

  for (var name in attrs) {
    this[name] = attrs[name]
  }

  if (!this.value) {
    this.expires = new Date(0)
    this.maxAge = null
  }

  if (this.path && !fieldContentRegExp.test(this.path)) {
    throw new TypeError('option path is invalid');
  }

  if (this.domain && !fieldContentRegExp.test(this.domain)) {
    throw new TypeError('option domain is invalid');
  }

  if (this.sameSite && this.sameSite !== true && !SAME_SITE_REGEXP.test(this.sameSite)) {
    throw new TypeError('option sameSite is invalid')
  }
}

Cookie.prototype.path = "/";
Cookie.prototype.expires = undefined;
Cookie.prototype.domain = undefined;
Cookie.prototype.httpOnly = true;
Cookie.prototype.sameSite = false;
Cookie.prototype.secure = false;
Cookie.prototype.overwrite = false;

在上面我们可以看到设置 cookie 时我们可以添加的属性信息,这些内容会创建一个 Cookie 对象,最终通过 toHeader 方法来把对象转为字符串格式:

Cookie.prototype.toHeader = function() {
  var header = this.toString()

  if (this.maxAge) this.expires = new Date(Date.now() + this.maxAge);

  if (this.path     ) header += "; path=" + this.path
  if (this.expires  ) header += "; expires=" + this.expires.toUTCString()
  if (this.domain   ) header += "; domain=" + this.domain
  if (this.sameSite ) header += "; samesite=" + (this.sameSite === true ? 'strict' : this.sameSite.toLowerCase())
  if (this.secure   ) header += "; secure"
  if (this.httpOnly ) header += "; httponly"

  return header
};

由于 cookie 常用于保存用户身份信息,因此对 cookie 有很高的安全要求,cookies 库提供了签名相关逻辑,这里是使用 keygrip 库实现的。在 get 方法中传入参数 { signed: true } 可以获取签名 cookie,签名 cookie 的 key 为原始名后面加 .sig 后缀,匹配签名 cookie 有几种情况:

  • 如果签名 cookie 哈希与第一个 key 匹配,则返回原始 cookie 值。
  • 如果签名 cookie 哈希与任何其他 key 匹配,则返回原始 cookie 值并将签名cookie 的值更新为第一个 key 的哈希。
  • 如果签名 cookie 哈希与任何 key 都不匹配,则不返回任何内容并删除 cookie。
Cookies.prototype.get = function(name, opts) {
  var sigName = name + ".sig"
    , header, match, value, remote, data, index
    , signed = opts && opts.signed !== undefined ? opts.signed : !!this.keys

  header = this.request.headers["cookie"]
  if (!header) return

  match = header.match(getPattern(name))
  if (!match) return

  value = match[1]
  if (!opts || !signed) return value

  remote = this.get(sigName)
  if (!remote) return

  data = name + "=" + value
  if (!this.keys) throw new Error('.keys required for signed cookies');
  index = this.keys.index(data, remote)

  if (index < 0) {
    this.set(sigName, null, {path: "/", signed: false })
  } else {
    index && this.set(sigName, this.keys.sign(data), { signed: false })
    return value
  }
};

在 set 方法的 opts 参数中我们可以设置 cookie 的属性信息:maxAgeexpirespathdomainsecurehttpOnlysameSitesignedoverwrite 等,其中对于设置了 secure 为 true 的 cookie 需要检查是否为安全环境,非安全环境不可以发送:

req.protocol === 'https' || req.connection.encrypted

对于设置 signed 为 true 的 cookie,在设置 cookie 前会添加签名,即 key 后面添加 .sig 后缀,值使用 keygrip 签名处理。

处理好的 cookie 会调用 pushCookie 方法,使用 toHeader 把 Cookie 对象转为字符串信息保存,如果 overwrite 为 true 会删除同名的 cookie。

最后调用 response 上的 setHeader 方法添加 Set-Cookie 来为浏览器设置 cookie。这里有一个应该是版本兼容的判断,如果 response 上面有 set 方法此时应该没有 setHeader 方法,这种情况调用的是 http.OutgoingMessage.prototype 的 setHeader 方法,OutgoingMessage 是 response 的父类,相关方法可以在 node 文档中查到。

var setHeader = res.set ? http.OutgoingMessage.prototype.setHeader : res.setHeader
setHeader.call(res, 'Set-Cookie', headers)

至此 cookies 库内部的处理就完成了,在实际开发中使用 cookie 很方便,但是有时也会带来限制和安全问题,因此实际使用时需要结合具体的场景。