koa-session源码解读

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源码解读

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章