從koa-session中間件源碼學習cookie與session

從koa-session中間件學習cookie與session

原文連接

關於cookie和session是什麼網上有不少介紹,可是具體的用法本身事實上一直不是很清楚,經過koa-session中間件的源碼本身也算是對cookie和session大體搞明白了。git

在我瞭解cookie的時候,大多數教程講的是這些:github

function setCookie(name,value) 
{ 
    var Days = 30; 
    var exp = new Date(); 
    exp.setTime(exp.getTime() + Days*24*60*60*1000); 
    document.cookie = name + "="+ escape (value) + ";expires=" + exp.toGMTString(); 
}

它給我一個錯覺:cookie只能在客戶端利用js設置讀取刪除等,但事實上不少的cookie是由服務端在response的headers裏面寫進去的:數據庫

const Koa = require('koa');
const app = new Koa();

app.use((ctx) => {
  ctx.cookies.set('test', 'hello', {httpOnly: false});
  ctx.body = 'hello world';
})

app.listen(3000);

訪問localhost:3000,打開控制檯能夠看到:json

img
img

那麼下次瀏覽器再訪問localhost:3000的時候就會把這些cookie信息經過request的headers帶給服務器。api

瞭解http協議的話能夠常常看到這麼一句話:http是無狀態的協議。什麼意思呢?大體這麼理解一下,就是你請求一個網站的時候,服務器不知道你是誰,好比你第一次訪問了www.google.com,過了三秒鐘你又訪問了www.google.com,雖然這兩次都是你操做的可是服務器事實上是不知道的。不過根據咱們的生活經驗,你登陸了一個網站後,過了三秒你刷新一下,你仍是在登陸態的,這好像與無狀態的http矛盾,其實這是由於有session。 瀏覽器

按照上面的說法,session是用來保存用戶信息的,那他與cookie有什麼關係,事實上按照個人理解session只是一個信息保存的解決方法,實現這個方法能夠有多種途徑。既然cookie能夠保存信息,那麼咱們能夠直接利用cookie來實現session。對應於koa-session中間件,當咱們沒有寫store的時候,默認即利用cookie實現session。 服務器

看一個官方例子:cookie

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 **/
};

app.use(session(CONFIG, app));
// or if you prefer all default config, just use => app.use(session(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);
console.log('listening on port 3000');

每次咱們訪問views都會+1。 session

看一下koa-session是怎麼實現的:app

module.exports = function(opts, app) {
  // session(app[, opts])
  if (opts && typeof opts.use === 'function') {
    [ app, opts ] = [ opts, app ];
  }
  // app required
  if (!app || typeof app.use !== 'function') {
    throw new TypeError('app instance required: `session(opts, app)`');
  }

  opts = formatOpts(opts);
  extendContext(app.context, opts);

  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 {
      await sess.commit();
    }
  };
};

一步一步的來看,formatOpts是用來作一些默認參數處理,extendContext的主要任務是對ctx作一個攔截器,以下:

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;
      },
    },
  });
}

因此走到下面這個代碼時,事實上是新建了一個ContextSession對象sess。這個對象有個屬性爲session(要保存的session對象),有一些方法用來初始化session(如initFromExternal、initFromCookie),具體是什麼下面用到再看。

const sess = ctx[CONTEXT_SESSION]

接着看是執行了以下代碼,也即執行咱們的業務邏輯

await next();

而後就是下面這個了,看樣子應該是相似保存cookie的操做。

await sess.commit();

至此所有流程結束,好像並無看到有什麼初始化session的操做。其實在執行咱們的業務邏輯時,假入咱們操做了session,如例子:

let n = ctx.session.views || 0;

就會觸發ctx的session屬性攔截器,ctx.session其實是sess的get方法返回值(返回值實際上是一個Session對象),代碼以下:

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;
  }

在get裏面執行了session的初始化操做,咱們考慮沒有store的狀況即執行initFromCookie();

initFromCookie() {
    debug('init from cookie');
    const ctx = this.ctx;
    const opts = this.opts;
    const cookie = ctx.cookies.get(opts.key, opts);
    if (!cookie) {
      this.create();
      return;
    }

    let json;
    debug('parse %s', cookie);
    try {
      json = opts.decode(cookie);
    } catch (err) {
      // backwards compatibility:
      // create a new session if parsing fails.
      // new Buffer(string, 'base64') does not seem to crash
      // when `string` is not base64-encoded.
      // but `JSON.parse(string)` will crash.
      debug('decode %j error: %s', cookie, err);
      if (!(err instanceof SyntaxError)) {
        // clean this cookie to ensure next request won't throw again
        ctx.cookies.set(opts.key, '', opts);
        // ctx.onerror will unset all headers, and set those specified in err
        err.headers = {
          'set-cookie': ctx.response.get('set-cookie'),
        };
        throw err;
      }
      this.create();
      return;
    }

    debug('parsed %j', json);

    if (!this.valid(json)) {
      this.create();
      return;
    }

    // support access `ctx.session` before session middleware
    this.create(json);
    this.prevHash = util.hash(this.session.toJSON());
  }
class Session {
  /**
   * Session constructor
   * @param {Context} ctx
   * @param {Object} obj
   * @api private
   */

  constructor(ctx, obj) {
    this._ctx = 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 this[k] = obj[k];
      }
    }
  }

很明瞭的能夠看出來其主要邏輯就是新建一個session,第一次訪問服務器時session.isNew爲true。

當咱們執行完業務邏輯時,最後執行sess.commit()

async commit() {
    const session = this.session;
    const prevHash = this.prevHash;
    const opts = this.opts;
    const ctx = this.ctx;
    // not accessed
    if (undefined === session) return;

    // removed
    if (session === false) {
      await this.remove();
      return;
    }

    // force save session when `session._requireSave` set
    let changed = true;
    if (!session._requireSave) {
      const json = session.toJSON();
      // do nothing if new and not populated
      if (!prevHash && !Object.keys(json).length) return;
      changed = prevHash !== util.hash(json);
      // do nothing if not changed and not in rolling mode
      if (!this.opts.rolling && !changed) return;
    }

    if (typeof opts.beforeSave === 'function') {
      debug('before save');
      opts.beforeSave(ctx, session);
    }
    await this.save(changed);
  }

commit事保存session前的準備工做,好比在咱們沒有強制保存session的時候它會判斷時候保存session

let changed = true;
    if (!session._requireSave) {
      const json = session.toJSON();
      // do nothing if new and not populated
      if (!prevHash && !Object.keys(json).length) return;
      changed = prevHash !== util.hash(json);
      // do nothing if not changed and not in rolling mode
      if (!this.opts.rolling && !changed) return;
    }

還提供了hook給咱們使用

if (typeof opts.beforeSave === 'function') {
      debug('before save');
      opts.beforeSave(ctx, session);
    }

到此開始真正的save session

async save(changed) {
    const opts = this.opts;
    const key = opts.key;
    const externalKey = this.externalKey;
    let json = this.session.toJSON();
    // set expire for check
    const maxAge = opts.maxAge ? opts.maxAge : ONE_DAY;
    if (maxAge === 'session') {
      // do not set _expire in json if maxAge is set to 'session'
      // also delete maxAge from options
      opts.maxAge = undefined;
    } else {
      // set expire for check
      json._expire = maxAge + Date.now();
      json._maxAge = maxAge;
    }

    // save to external store
    if (externalKey) {
      debug('save %j to external key %s', json, externalKey);
      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);
  }

對於咱們討論的這種狀況,能夠看到就是將信息encode以後寫入了cookie,而且包含了兩個字段_expire和_maxAge。

簡單驗證一下,CONFIG添加encode和decode

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 **/
  encode: json => JSON.stringify(json),
  decode: str => JSON.parse(str)
};

第一次訪問時
img

再次訪問

img

_expire用來下次訪問服務器時判斷session是否已過時

valid(json) {
    if (!json) return false;

    if (json._expire && json._expire < Date.now()) {
      debug('expired session');
      return false;
    }

    const valid = this.opts.valid;
    if (typeof valid === 'function' && !valid(this.ctx, json)) {
      // valid session value fail, ignore this session
      debug('invalid session');
      return false;
    }
    return true;
  }

_maxAge用來保存過時時間,ctx.sessionOptions通過攔截器指向的實際上是sess.opts

class Session {
  /**
   * Session constructor
   * @param {Context} ctx
   * @param {Object} obj
   * @api private
   */

  constructor(ctx, obj) {
    this._ctx = 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 this[k] = obj[k];
      }
    }
  }

畫一個簡單的流程圖看一下這整個邏輯時怎樣的

img

一般狀況下,把session保存在cookie有下面兩個缺點:

  • Session is stored on client side unencrypted
  • Browser cookies always have length limits

因此能夠把session保存在數據庫中等,在koa-session中,能夠設置store並提供三個方法:get、set、destroy。

當設置了store的時候,初始化操做是在initFromExternal完成的

async initFromExternal() {
    debug('init from external');
    const ctx = this.ctx;
    const opts = this.opts;

    const externalKey = ctx.cookies.get(opts.key, opts);
    debug('get external key from cookie %s', externalKey);

    if (!externalKey) {
      // create a new `externalKey`
      this.create();
      return;
    }

    const json = await this.store.get(externalKey, opts.maxAge, { rolling: opts.rolling });
    if (!this.valid(json)) {
      // create a new `externalKey`
      this.create();
      return;
    }

    // create with original `externalKey`
    this.create(json, externalKey);
    this.prevHash = util.hash(this.session.toJSON());
  }

externalKey事實上是session數據的索引,此時相比於直接把session存在cookie來講多了一層,cookie裏面存的不是session而是找到session的鑰匙。固然咱們保存的時候就要作兩個工做,一是將session存入數據庫,另外一個是將session對應的key即(externalKey)寫入到cookie,以下:

// save to external store
    if (externalKey) {
      debug('save %j to external key %s', json, externalKey);
      await this.store.set(externalKey, json, maxAge, {
        changed,
        rolling: opts.rolling,
      });
      this.ctx.cookies.set(key, externalKey, opts);
      return;
    }

咱們能夠測試一下,事實上咱們能夠把session存在任意的媒介,不必定非要是數據庫(主要是電腦沒裝數據庫),只要store提供了三個接口便可:

const session = require('koa-session');
const Koa = require('koa');
const app = new Koa();
const path = require('path');
const fs = require('fs');

app.keys = ['some secret hurr'];

const store = {
  get(key) {
    const sessionDir = path.resolve(__dirname, './session');
    const files = fs.readdirSync(sessionDir);

    for (let i = 0; i < files.length; i++) {
      if (files[i].startsWith(key)) {
        const filepath = path.resolve(sessionDir, files[i]);
        delete require.cache[require.resolve(filepath)];
        const result = require(filepath);
        return result;
      }
    }
  },
  set(key, session) {
    const filePath = path.resolve(__dirname, './session', `${key}.js`);
    const content = `module.exports = ${JSON.stringify(session)};`;
    
    fs.writeFileSync(filePath, content);
  },

  destroy(key){
    const filePath = path.resolve(__dirname, './session', `${key}.js`);
    fs.unlinkSync(filePath);
  }
}

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 **/
  store
};

app.use(session(CONFIG, app));
// or if you prefer all default config, just use => app.use(session(app));

app.use(ctx => {
  // ignore favicon
  if (ctx.path === '/favicon.ico') return;
  let n = ctx.session.views || 0;
  ctx.session.views = ++n;
  if (n >=5 ) ctx.session = null;
  ctx.body = n + ' views';
});

app.listen(3000);
console.log('listening on port 3000');

瀏覽器輸入localhost:3000,刷新五次則views從新開始計數。

全文完。

相關文章
相關標籤/搜索