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源碼解讀

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