「这是我参与11月更文挑战的第17天,活动详情查看:2021最后一次更文挑战」
本文为 koa 依赖系列文章,前几篇也可以在站内查看:
- koa 中依赖的库 parseurl
- Koa 依赖的库 type-is 和 content-disposition
- Koa 依赖的库 accepts、content-type 和 cache-content-type
- Koa 依赖的库 encodeurl 和 escape-html
- Koa 中依赖的库 statuses
在使用 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 的属性信息:maxAge、expires、path、 domain、 secure、 httpOnly 、sameSite 、signed 、overwrite 等,其中对于设置了 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 很方便,但是有时也会带来限制和安全问题,因此实际使用时需要结合具体的场景。