1 基础概念
1.1 Cookie
Cookie,有时也用其复数形式 Cookies。类型为“小型文本文件”,是某些网站为了辨别用户身份,进行 Session 跟踪而储存在用户本地终端上的数据(通常经过加密),由用户客户端计算机暂时或永久保存的信息 。
在浏览器首次访问服务器的时候,服务器将通过 Set-Cookie 给浏览器种个 cookie(标识),客户端发送 HTTP 请求时,会自动把 Cookie 附加到 HTTP 的 Header 中发送到服务器端,过程如下:
交互流程如下:
1.2 session
session 的定义
在计算机中,尤其是在网络应用中,称为“会话控制”。Session 对象存储特定用户会话所需的属性及配置信息。这样,当用户在应用程序的 Web 页之间跳转时,存储在 Session 对象中的变量将不会丢失,而是在整个用户会话中一直存在下去。当用户请求来自应用程序的 Web 页时,如果该用户还没有会话,则 Web 服务器将自动创建一个 Session 对象。当会话过期或被放弃后,服务器将终止该会话。
session 与 cookie 交互过程
koa-session 分为内存、外部存储,如果设置 CONGIG={store:new store()},为外部存储,否则为内存存储,对于外部存储服务器端生成唯一的标识 externalKey,在服务器端开辟 externalKey 的数据存储空间,externalKey 作为全局唯一标识符通过 cookie 发送给客户端,客户端再次访问服务器时会把 externalKey 通过请求头中的 cookie 发送给服务器,服务器将通过 externalKey 把此标识符在服务器端的 session 数据取出。交互过程如下图所示:
2 koa-session 源码解读
下面带着大家看下源码解析。Git 地址为:
https://github.com/koajs/session
2.1 代码结构
├── index.js // 入口
├── lib
│ ├── context.js // 主要逻辑的文件,针对session的不同存储方式获取还有设置,
│ ├── session.js // session的初始化
│ └── util.js // 公用的函数库
└── package.json
复制代码
2.2 代码示例
var session = require('./');
var Koa = require('koa');
var app = new Koa();
const keys = ["key"]; // 这个是配合signed属性的签名key
const CONGIG = {
key: 'koa:sess', /** cookie的key。 (默认是 koa:sess) */
maxAge: 4000, /** session 过期时间,以毫秒ms为单位计算 。*/
autoCommit: true, /** 自动提交到响应头。(默认是 true) */
overwrite: true, /** 是否允许重写 。(默认是 true) */
httpOnly: true, /** 是否设置HttpOnly,如果在Cookie中设置了"HttpOnly"属性,那么通过程序(JS脚本、Applet等)将无法读取到Cookie信息,这样能有效的防止XSS攻击。 (默认 true) */
signed: true, /** 是否签名。(默认是 true) */
rolling: true, /** 是否每次响应时刷新Session的有效期。(默认是 false) */
renew: false, /** 是否在Session快过期时刷新Session的有效期。(默认是 false) */
};
app.keys = keys;
app.use(session(CONGIG,app));
app.use((ctx,next)=>{
if ('/favicon.ico' == ctx.path) return;
var n = ctx.session.views || 0;
ctx.session.views = ++n;
ctx.body = n + ' views';
});
app.listen(3000);
console.log('listening on port 3000');
复制代码
2.3 源码方法解析
koa-session 分为内存、外部存储,如果设置 CONGIG={store:new store()},为外部存储,否则为内存存储,初始化 app.use(session(CONFIG,app))执行中间件,会执行一系列的初始化操作,初始化参数配置、向外暴露 session 中的 get()、set(),在服务器开辟 session 的存储空间,如果为外部存储我们会初始化生成一个 externalKey,当我们执行完中间件,通过 commit()保存,如果是外部存储会存储到 store 中,否则我们存储到内存中。
具体流程如下:
首先,初始化中间件
app.use(session(CONFIG,app))
接着,初始化下面的函数,我们传入了 app 实例,CONFIG 的参数配置,返回 session 的中间件:
function (opts, app) {}
初始化默认参数配置,如果传入 CONFIG,会覆盖默认参数
formatOpts(opts)
接下来执行下面的函数,主要对 ctx 的一个拦截:
extendContext(app.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、CONTEXT_SESSION,通过 symbol 生成。代码如下:
const CONTEXT_SESSION = Symbol("context#contextSession");
const _CONTEXT_SESSION = Symbol("context#_contextSession");
复制代码
外界无法访问它,extendContext()向外界暴露了 session 对象,对应的有 get(),set()方法,get()、set()方法对应执行 ContextSession 实例的 get(),set()方法,接着查看暴露 session 的中间件。代码如下:
return async function session(ctx, next) {
const sess = ctx[CONTEXT_SESSION];
if (sess.store) await sess.initFromExternal();
try {
await next();
} catch (err) {
throw err;
} finally {
if (opts.autoCommit) {
await sess.commit();
}
}
};
复制代码
sess.store 主要是一个外部存储,需要我们从 CONFIG.store 传入,如果是外部存储,执行 initFromExternal()。代码如下:
async initFromExternal() {
if (!externalKey) {
// create a new `externalKey`
this.create();
return;
}
if (!this.valid(json, externalKey)) {
// create a new `externalKey`
this.create();
return;
}
......
const json = await this.store.get(externalKey, opts.maxAge, {
rolling: opts.rolling,
});
....
this.create(json, externalKey);
this.prevHash = util.hash(this.session.toJSON());
}
复制代码
对于首次初始化的时候,不存在 externalKey,判断 externalKey 是否存在,如果不存在,执行 this.create()方法,会重新生成一个 externalKey,下次访问的时候,如果存在 externalKey,判断 externalKey 是否有效,无效执行 this.create()方法,有效的话更新 session 数据,prevHash 生成一个校验码,在_shouldSaveSession(),用于判断是否更新数据,在稍后给出解析。代码如下:
create(val, externalKey) {
if (this.store)
this.externalKey =
externalKey || (this.opts.genid && this.opts.genid(this.ctx));
this.session = new Session(this, val);
}
复制代码
接着看下 Session 实例,可以看出对 session 实例挂载了_ctx,_sessCtx 属性,如果当前没有 obj 数据,赋值 isNew = true,存在的话,遍历 obj,分别给 this._ctx.sessionOptions 属性赋值,数据的存储。代码如下:
constructor(sessionContext, obj) {
this._sessCtx = sessionContext;
this._ctx = sessionContext.ctx;
if (!obj) {
this.isNew = true;
} else {
for (const k in obj) {
// restore maxAge from store
if (k === '_maxAge') this._ctx.sessionOptions.maxAge = obj._maxAge;
else if (k === '_session') this._ctx.sessionOptions.maxAge = 'session';
else this[k] = obj[k];
}
}
复制代码
接着我们再回到 session 中间件, 执行 await next()。执行下一个的中间件,执行业务代码 code:this.session.view,触发 ContextSession 里的 get()方法。代码如下:
get() {
const session = this.session;
......
this.store ? this.create() : this.initFromCookie();
return this.session;
}
复制代码
该方法有一处判断,当前是否是外部存储,如果是外部存储,执行 this.create(),初始化 session,否则执行 this.initFromCookie()。代码如下:
initFromCookie() {
.....
const cookie = ctx.cookies.get(opts.key, opts);
if (!cookie) {
this.create();
return;
}
let json;
debug('parse %s', cookie);
try {
json = opts.decode(cookie);
}
.....
if (!this.valid(json)) {
this.create();
return;
}
.....
this.create(json);
this.prevHash = util.hash(this.session.toJSON());
}
复制代码
对于首次访问的时候,还没有保存 cookie,执行 this.create(),生成校验码 prevHash,下次访问的时候,如果存在 cookie,判断 cookie 是否有效,无效执行 this.create(),生成校验码 prevHash,有效更新存储数据,生成校验码 prevHash,当我们执行 code:this.session.view++,同样触发 ContextSession.get() 一番操作,之后我们还是要回到 session 中间件,开始我们要提交数据执行 sess.commit()。代码如下:
async commit() {
...
const reason = this._shouldSaveSession();
...
const changed = reason === "changed";
await this.save(changed);
}
复制代码
接着执行 this._shouldSaveSession(),判断当前数据是否需要改变:
如果定义了_requireSave 为 true,返回 force
如果之前的检验码和现在更新生成的校验码不相等,返回 change
配置参数 rolling 为 true ,返回 rolling
如果配置参数 renew 为 true,当 expire,maxAge 同时存在,且 expire-Date.now()<maxAge/2 返回 renew
代码实现如下:
_shouldSaveSession() {
const prevHash = this.prevHash;
const session = this.session;
if (session._requireSave) return "force";
const json = session.toJSON();
......
const changed = prevHash !== util.hash(json);
if (changed) return "changed";
if (this.opts.rolling) return "rolling";
if (this.opts.renew) {
const expire = session._expire;
const maxAge = session.maxAge;
if (expire && maxAge && expire - Date.now() < maxAge / 2) return "renew";
}
return "";
}
复制代码
执行 save(),如果是该实例生成的 externalKey 存在,为外部存储, this.store.set()数据更新,同时配置参,CONFIG.externalKey 存在,需要更新 opts.externalKey.set(this.ctx, externalKey),否则要给 cookie 更 externalKey,如果是 cookie 存储,只需要重新设置 cookie 存储数据。代码如下:
async save(changed) {
if (externalKey) {
......
await this.store.set(externalKey, json, maxAge, {
changed,
rolling: opts.rolling,
});
if (opts.externalKey) {
opts.externalKey.set(this.ctx, externalKey);
} else {
this.ctx.cookies.set(key, externalKey, opts);
}
return;
}
json = opts.encode(json);
this.ctx.cookies.set(key, json, opts);
}
复制代码
总结一下,koa-session 源码解析流程为:
原图见:https://www.processon.com/view/link/5f292454e0b34d54dadf57e5
3 外部存储 session 的实现
下面给出代码实现的例子:
3.1 基于 redis 的实现方式
var session = require("koa-session");
var Koa = require("koa");
var redisStore = require("koa-redis");
var Redis = require("ioredis");
var app = new Koa();
var redisClient = new Redis({
host: '127.0.0.1',
port: 6379,
});
const sessStore = redisStore({ client: redisClient });
app.keys = ['key','keys'];
let CONGIG = {
key:'session',
prefix:'session',
store: sessStore,
};
app.use(session(CONGIG, app));
app.use( (ctx,next) =>{
if ("/favicon.ico" == ctx.path) return;
var n = ctx.session.views || 0;
ctx.session.views++;
ctx.body = n + " views";
});
app.listen(3000);
console.log("listening on port 3000");
复制代码
启动 redis,运行结果:
127.0.0.1:6379> keys *
1) "sessionfb731226-8abd-4412-bd8b-c8688f2920ea"
127.0.0.1:6379> get sessionfb731226-8abd-4412-bd8b-c8688f2920ea
"{\"views\":5,\"_expire\":1596251432691,\"_maxAge\":86400000}"
127.0.0.1:6379> keys session
(empty list or set)
127.0.0.1:6379>
复制代码
3.2 基于 mysql 的实现方式
我们使用的 session 库 koa-session-minimal,因为 koa-session 不支持 mysql 存储。查看源码 koa-mysql-session 数据的获取、存储向外暴露的 function *(){ yield }方式,koa-session-minimal 对于外部数据的存储封装了一层 co 库, koa-session 没有,不支持。
const session = require('koa-session-minimal')
var Koa = require('koa');
var app = new Koa();
var MysqlStore = require("koa-mysql-session");
var app = new Koa();
var config={
user: "root",
password: "981010",
database: "sys",
host: "127.0.0.1",
port: 3306,
}
app.keys = ['some secret hurr'];
const THIRTY_MINTUES = 30 * 60 * 1000;
const CONFIG={
key: 'USER_SID',
store: new MysqlStore(config),
}
app.use(session(CONFIG,app));
app.use( (ctx,next)=>{
if ('/favicon.ico' == this.path) return;
var n = ctx.session.views || 0;
ctx.session.views = ++n;
ctx.body = n + ' views';
next();
});
app.use(function (ctx,next){
if ('/favicon.ico' == this.path) return;
});
app.listen(3000);
复制代码
在里面会自动生成_mysql_session_store 表,data 存放我们的数据。
4 总结
session 仅仅是一个对象信息,可以存到 cookie ,也可以存到任何地方(如内存,数据库),存到哪,可以开发者自己决定,只要实现一个 store 对象。
与 cookie 有关的安全问题总结:
cookie 的默认采用编码使用了 base64。
在 koa-session 的 CONFIG 中的有一个 httponly 的选项,防止恶意串改代码。
koa 的 cookie 本身带了安全机制,也就是 CONFIG.signed=true 的时候,会自动给 cookie 加上一个签名,从而防止 cookie 被篡改。
session 保存方案比较:
如果存在数据库操作数据库消耗性能,cookie 则容易将用户的信息暴露,加解密同样也消耗了性能,但一般用 redis 存储,存取速度快,数据持久化、不易丢失。
本文转载自公众号贝壳产品技术(ID:beikeTC)。
原文链接:
koa-session源码解读
评论