koa-session是koa的session管理中间件,最近在写登录注册模块的时候学习了一下这部分的代码,感觉还比较容易看明白,让自己对于session的理解也更加深入了,这里总结一下。
session基础知识
这部分算是基础知识,熟悉的朋友可以跳过。
我们都知道http协议本身是无状态的,因此协议本身是不支持“登录状态”这样的概念的,必须由项目自己来实现。我们常常说到session这个概念,但是可能有人并不是非常清楚我们讨论的session具体指代什么。我觉得这个概念比较容易混淆,不同的上下文会有不同的含义:
- session首先是一个抽象的概念,指代多个有关联的http请求所构成的一个会话。
- session常常用来指代为了实现一个会话,需要在客户端和服务端之间传输的信息。这些信息可以是会话所需的所有内容(包括用户身份、相关数据等),也可以只是一个id,让服务端可能从后台检索到相关数据,这也是实际系统中最常用的方式。
当我们讨论session的实现方式的时候,都是寻找一种方式从而使得多次请求之间能够共享一些信息。不论选择哪种方式,都是需要由服务自己来实现的,http协议并不提供原生的支持。
实现session的一种方式就是在每个请求的参数或者数据中带上相关信息,这种方式的好处是不受cookie可用性的限制。我们在登录某些网站的时候会发现url里有长长的一串不规则字符,往往就是编码了用户的session信息。但是这种方式也会受到请求长度的限制,使用起来也不方便,而且还有安全性上的隐患。
最常见的方式还是使用cookie来存储session信息。如上所述,这里的信息可以是整个session的具体数据,也可以只是session的标识。这样服务端通过set-cookie的方式把信息返回给客户端,客户端下次请求的时候会自动带上符合条件的cookie,服务端再解析cookie就能够获取到session信息了。koa-session
也是采用cookie来实现session,默认情况下只使用一个cookie字段来存储session信息。
session vs token
在进入koa-session的讨论之前,简单聊聊token。session和token都常常用来作为用户鉴权的机制。
大部分情况下,当我们提到session鉴权的时候,指的是这样一个流程
- 用户登录的时候,服务端生成一个会话和一个id标识
- 会话id在客户端和服务端之间通过cookie进行传输
- 服务端通过会话id可以获取到会话相关的信息,然后对客户端的请求进行响应;如果找不到有效的会话,那么认为用户是未登陆状态
- 会话会有过期时间,也可以通过一些操作(比如登出)来主动删除
token的典型流程为:
- 用户登录的时候,服务端生成一个token返回给客户端
- 客户端后续的请求都带上这个token
- 服务端解析token获取用户信息,并响应用户的请求
- token会有过期时间,客户端登出的时候也会废弃token,但是服务端不需要任何操作
两种方式的区别在于:
- session要求服务端存储信息,并且根据id能够检索,而token不需要。在大规模系统中,对每个请求都检索会话信息可能是一个复杂和耗时的过程。但另外一方面服务端要通过token来解析用户身份也需要定义好相应的协议。
- session一般通过cookie来交互,而token方式更加灵活,可以是cookie,也可以是其他header,也可以放在请求的内容中。不使用cookie可以带来跨域上的便利性。
- token的生成方式更加多样化,可以由第三方服务来提供
很多情况下,session和token两种方式都会一起来使用。
koa-session使用方式
最简单的代码如下所示
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,包括:
- maxAge,这个是确定cookie的有效期,默认是一天。
- rolling, renew,这两个都是涉及到cookie有效期的更新策略
- httpOnly,表示是否可以通过javascript来修改,设成true会更加安全
- signed,这个涉及到cookie的安全性,下面再讨论
- store,可以传入一个用于session的外部存储
koa-session主要流程
我们可以先直接看看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的基本流程非常简单
- 根据cookie或者外部存储初始化cookie。
- 调用next()执行后面的业务逻辑,其中可以读取和写入新的session内容。
- 调用commit()把更新后的session保存下来。
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的存储方式,就很容易了解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());
}
session提交
在主流程我们已经看到,在业务逻辑处理之后,会调用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的情况包括
- 如果session有变动
- 在config里设置了rolling为true,也就是每次都更新session
- 在config里设置了renew为true,且有效期已经过了一半,需要更新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");
尾记
- https://segmentfault.com/a/1190000012412299 写到一半的时候才发现这篇文章,对于session整体流程也讲的挺清楚的,可以对着一起看
- 因为koa-session的代码比较简单,有时间的话对着源码调试一下很容易搞懂
- 初学js和node,可能很多地方会有错漏,请大家指正。