koa-session是koa的session管理中间件,最近在写登陆注册模块的时候学习了一下这部分的代码,感受还比较容易看明白,让本身对于session的理解也更加深刻了,这里总结一下。javascript
这部分算是基础知识,熟悉的朋友能够跳过。java
咱们都知道http协议自己是无状态的,所以协议自己是不支持“登陆状态”这样的概念的,必须由项目本身来实现。咱们经常说到session这个概念,可是可能有人并非很是清楚咱们讨论的session具体指代什么。我以为这个概念比较容易混淆,不一样的上下文会有不一样的含义:node
当咱们讨论session的实现方式的时候,都是寻找一种方式从而使得屡次请求之间可以共享一些信息。不论选择哪一种方式,都是须要由服务本身来实现的,http协议并不提供原生的支持。git
实现session的一种方式就是在每一个请求的参数或者数据中带上相关信息,这种方式的好处是不受cookie可用性的限制。咱们在登陆某些网站的时候会发现url里有长长的一串不规则字符,每每就是编码了用户的session信息。可是这种方式也会受到请求长度的限制,使用起来也不方便,并且还有安全性上的隐患。github
最多见的方式仍是使用cookie来存储session信息。如上所述,这里的信息能够是整个session的具体数据,也能够只是session的标识。这样服务端经过set-cookie的方式把信息返回给客户端,客户端下次请求的时候会自动带上符合条件的cookie,服务端再解析cookie就可以获取到session信息了。koa-session
也是采用cookie来实现session,默认状况下只使用一个cookie字段来存储session信息。数据库
在进入koa-session的讨论以前,简单聊聊token。session和token都经常用来做为用户鉴权的机制。json
大部分状况下,当咱们提到session鉴权的时候,指的是这样一个流程segmentfault
token的典型流程为:跨域
两种方式的区别在于:浏览器
不少状况下,session和token两种方式都会一块儿来使用。
最简单的代码以下所示
const session = require('koa-session'); const Koa = require('koa'); const app = new Koa(); app.keys = ['some secret hurr']; const CONFIG = { key: 'koa:sess', /** (string) cookie key (default is koa:sess) */ /** (number || 'session') maxAge in ms (default is 1 days) */ /** 'session' will result in a cookie that expires when session/browser is closed */ /** Warning: If a session cookie is stolen, this cookie will never expire */ maxAge: 86400000, overwrite: true, /** (boolean) can overwrite or not (default true) */ httpOnly: true, /** (boolean) httpOnly or not (default true) */ signed: true, /** (boolean) signed or not (default true) */ rolling: false, /** (boolean) Force a session identifier cookie to be set on every response. The expiration is reset to the original maxAge, resetting the expiration countdown. (default is false) */ renew: false, /** (boolean) renew session when session is nearly expired, so we can always keep user logged in. (default is false)*/ }; app.use(session(CONFIG, app)); app.use(ctx => { // ignore favicon if (ctx.path === '/favicon.ico') return; let n = ctx.session.views || 0; ctx.session.views = ++n; ctx.body = n + ' views'; }); app.listen(3000);
咱们看到这个在这个回话状态中,session中保存了页面访问次数,每次请求的时候,会增长计数再把结果返回给用户。
koa-session的代码结构很简单
index.js // 定义主流程和扩展context \- context.js // 定义SessionContext类,定义了对session的主要操做 \- session.js // 定义session类,只有一些简单的util \- util.js // 对session进行编码解码的util
在使用koa-session的时候用户能够传一个自定义的config,包括:
咱们能够先直接看看koa-session的代码入口,我加了一些简单的注释
// https://github.com/koajs/session/blob/master/index.js module.exports = function(opts, app) { // ... 省略部分代码 opts = formatOpts(opts); extendContext(app.context, opts); return async function session(ctx, next) { const sess = ctx[CONTEXT_SESSION]; // 获取当前的session,这里设置了一个getter,首次访问时会建立一个新的ContextSession if (sess.store) await sess.initFromExternal(); // 若是设置了使用外部存储,就从外部存储初始化 try { await next(); } catch (err) { throw err; } finally { await sess.commit(); } }; };
能够看到koa-session的基本流程很是简单
对于session的存储方式,koa-session同时支持cookie和外部存储。
默认配置下,会使用cookie来存储session信息,也就是实现了一个"cookie session"。这种方式对服务端是比较轻松的,不须要额外记录任何session信息,可是也有很多限制,好比大小的限制以及安全性上的顾虑。用cookie保存时,实现上很是简单,就是对session(包括过时时间)序列化后作一个简单的base64编码。其结果相似 koa:sess=eyJwYXNzcG9ydCI6eyJ1c2VyIjozMDM0MDg1MTQ4OTcwfSwiX2V4cGlyZSI6MTUxNzI3NDE0MTI5MiwiX21heEFnZSI6ODY0MDAwMDB9;
在实际项目中,会话相关信息每每须要再服务端持久化,所以通常都会使用外部存储来记录session信息。外部存储能够是任何的存储系统,能够是内存数据结构,也能够是本地的文件,也能够是远程的数据库。可是这不意味着咱们不须要cookie了,因为http协议的无状态特性,咱们依然须要经过cookie来获取session的标识(这里叫externalKey)。koa-session里的external key默认是一个时间戳加上一个随机串,所以cookie的内容相似koa:sess=1517188075739-wnRru1LrIv0UFDODDKo8trbmFubnVmMU;
要实现一个外置的存储,用户须要自定义get(), set()和destroy()函数,分别用于获取、更新和删除session。一个最简单的实现,咱们就采用一个object来存储session,那么能够这么来配置
let store = { storage: {}, get (key, maxAge) { return this.storage[key] }, set (key, sess, maxAge) { this.storage[key] = sess }, destroy (key) { delete this.storage[key] } } app.use(session({store}, app))
了解了session的存储方式,就很容易了解session的初始化过程了。
在上面的koa-session主要流程中, 能够看到调用了extendContext(app.context, opts)
,其做用是给context扩充了一些内容,代码以下
// https://github.com/koajs/session/blob/master/index.js function extendContext(context, opts) { Object.defineProperties(context, { [CONTEXT_SESSION]: { get() { if (this[_CONTEXT_SESSION]) return this[_CONTEXT_SESSION]; this[_CONTEXT_SESSION] = new ContextSession(this, opts); return this[_CONTEXT_SESSION]; }, }, session: { get() { return this[CONTEXT_SESSION].get(); }, set(val) { this[CONTEXT_SESSION].set(val); }, configurable: true, }, sessionOptions: { get() { return this[CONTEXT_SESSION].opts; }, }, }); }
_CONTEXT_SESSION字段是一个ContextSession,这是对真正的session的一个holder。这里定义了一个getter,用于在首次调用时新建一个ContextSession对象。
session字段就是用于读写ContextSession里的session字段。这里有一点奇怪的是,从cookie初始化是在首次调用ContextSession.get()
的时候才进行,而从外部存储初始化则是在主流程中就调用了。
ContextSession类定义在koa-session库的context.js文件中,其get()函数代码以下
// https://github.com/koajs/session/blob/master/lib/context.js get() { const session = this.session; // already retrieved if (session) return session; // unset if (session === false) return null; // cookie session store if (!this.store) this.initFromCookie(); return this.session; }
initFromCookie()就是从cookie的初始化过程,代码很简单,我加了一点注释,最须要注意的就是生成一个prevHash来标记当前状态
// https://github.com/koajs/session/blob/master/lib/context.js initFromCookie() { debug('init from cookie'); const ctx = this.ctx; const opts = this.opts; // FK: 获取cookie,若是不存在就调用create()新建一个空的session const cookie = ctx.cookies.get(opts.key, opts); if (!cookie) { this.create(); return; } let json; debug('parse %s', cookie); try { // FK: 解析base64编码的cookie内容 json = opts.decode(cookie); } catch (err) { // FK: 省略错误处理内容 } debug('parsed %j', json); // FK: 对于session检查有效性,若是失败(好比已通过期)就新建一个session if (!this.valid(json)) { this.create(); return; } // support access `ctx.session` before session middleware // FK: 根据cookie的内容来建立session this.create(json); // FK: *** 记录当前session的hash值,用于在业务流程完成判断是否有更新 *** this.prevHash = util.hash(this.session.toJSON()); }
initFromExternal()就是从外部存储初始化session,和cookie初始化相似
async initFromExternal() { debug('init from external'); const ctx = this.ctx; const opts = this.opts; // FK: 对于外部存储,cookie中的内容就是external key const externalKey = ctx.cookies.get(opts.key, opts); debug('get external key from cookie %s', externalKey); // FK: 若是external key不存在,就新建一个 if (!externalKey) { // create a new `externalKey` this.create(); return; } // FK: 若是在外部存储中找不到相应的session,就新建一个 const json = await this.store.get(externalKey, opts.maxAge, { rolling: opts.rolling }); if (!this.valid(json, externalKey)) { // create a new `externalKey` this.create(); return; } // create with original `externalKey` // FK: 根据外部存储的内容来建立session this.create(json, externalKey); // FK: *** 记录当前session的hash值,用于在业务流程完成判断是否有更新 *** this.prevHash = util.hash(this.session.toJSON()); }
在主流程咱们已经看到,在业务逻辑处理以后,会调用sess.commit()
来提交修改后的session。根据session的存储方式,提交的session会保存到cookie中或者是外部存储中。
async commit() { const session = this.session; const opts = this.opts; const ctx = this.ctx; // not accessed if (undefined === session) return; // removed if (session === false) { await this.remove(); return; } const reason = this._shouldSaveSession(); debug('should save session: %s', reason); if (!reason) return; if (typeof opts.beforeSave === 'function') { debug('before save'); opts.beforeSave(ctx, session); } const changed = reason === 'changed'; await this.save(changed); }
commit()的过程就是判断是否要保存/删除cookie,删除的条件比较简单,保存cookie的条件又调用了_shouldSaveSession(),代码以下
_shouldSaveSession() { // 省略部分代码。。。 // save if session changed const changed = prevHash !== util.hash(json); if (changed) return 'changed'; // save if opts.rolling set if (this.opts.rolling) return 'rolling'; // save if opts.renew and session will expired if (this.opts.renew) { const expire = session._expire; const maxAge = session.maxAge; // renew when session will expired in maxAge / 2 if (expire && maxAge && expire - Date.now() < maxAge / 2) return 'renew'; } return ''; }
可见保存session的状况包括
一旦知足任何一个条件,就会调用save()操做来保存cookie
async save(changed) { // 省略部分代码。。。 // save to external store if (externalKey) { debug('save %j to external key %s', json, externalKey); if (typeof maxAge === 'number') { // ensure store expired after cookie maxAge += 10000; } await this.store.set(externalKey, json, maxAge, { changed, rolling: opts.rolling, }); this.ctx.cookies.set(key, externalKey, opts); return; } // save to cookie debug('save %j to cookie', json); json = opts.encode(json); debug('save %s', json); this.ctx.cookies.set(key, json, opts); }
和初始化相似,save()操做也是分为cookie存储和外部存储两种方式分别操做。
至此,对于session的基本操做流程应该都已经清楚了。
若是session采用外部存储的方式,安全性是比较容易保证的,由于cookie中保存的只是session的external key,默认实现是一个时间戳加随机字符串,所以不用担忧被恶意篡改或者暴露信息。固然若是cookie自己被窃取,那么在过时以前仍是能够被用来访问session信息(固然咱们能够在标识中加入更多的信息,好比ip地址,设备id等信息,从而增长更多校验来减小风险)。
若是session彻底保存在cookie中,就须要额外注意安全性的问题。在session的默认实现中,咱们注意到对cookie的编码只是简单的base64,所以理论上客户端很容易解析和修改。
所以在koa-session的config中有一个httpOnly的选项,就是不容许浏览器中的js代码来获取cookie,避免遭到一些恶意代码的攻击。
可是假如cookie被窃取,攻击者仍是能够很容易的修改cookie,好比把maxAge设为无限就能够一直使用cookie了,这种状况如何处理呢?实际上是koa的cookie自己带了安全机制,也就是config里的signed设为true的时候,会自动给cookie加上一个sha256的签名,相似koa:sess.sig=pjadZtLAVtiO6-Haw1vnZZWrRm8
,从而防止cookie被篡改。
最后,如何处理session的信息被泄露的问题呢?其实koa-session容许用户在config中配置本身的编码和解码函数,所以彻底可使用自定义的加密解密函数对session进行编解码,相似
encode: json => CryptoJS.AES.encrypt(json, "Secret Passphrase"), decode: encrypted => CryptoJS.AES.decrypt(encrypted, "Secret Passphrase");