koa-session學習筆記

koa-session是koa的session管理中間件,最近在寫登陸註冊模塊的時候學習了一下這部分的代碼,感受還比較容易看明白,讓本身對於session的理解也更加深刻了,這裏總結一下。javascript

session基礎知識

這部分算是基礎知識,熟悉的朋友能夠跳過。java

咱們都知道http協議自己是無狀態的,所以協議自己是不支持「登陸狀態」這樣的概念的,必須由項目本身來實現。咱們經常說到session這個概念,可是可能有人並非很是清楚咱們討論的session具體指代什麼。我以爲這個概念比較容易混淆,不一樣的上下文會有不一樣的含義:node

  • session首先是一個抽象的概念,指代多個有關聯的http請求所構成的一個會話。
  • session經常用來指代爲了實現一個會話,須要在客戶端和服務端之間傳輸的信息。這些信息能夠是會話所需的全部內容(包括用戶身份、相關數據等),也能夠只是一個id,讓服務端可能從後臺檢索到相關數據,這也是實際系統中最經常使用的方式。

當咱們討論session的實現方式的時候,都是尋找一種方式從而使得屢次請求之間可以共享一些信息。不論選擇哪一種方式,都是須要由服務本身來實現的,http協議並不提供原生的支持。git

實現session的一種方式就是在每一個請求的參數或者數據中帶上相關信息,這種方式的好處是不受cookie可用性的限制。咱們在登陸某些網站的時候會發現url裏有長長的一串不規則字符,每每就是編碼了用戶的session信息。可是這種方式也會受到請求長度的限制,使用起來也不方便,並且還有安全性上的隱患github

最多見的方式仍是使用cookie來存儲session信息。如上所述,這裏的信息能夠是整個session的具體數據,也能夠只是session的標識。這樣服務端經過set-cookie的方式把信息返回給客戶端,客戶端下次請求的時候會自動帶上符合條件的cookie,服務端再解析cookie就可以獲取到session信息了。koa-session也是採用cookie來實現session,默認狀況下只使用一個cookie字段來存儲session信息。數據庫

session vs token

在進入koa-session的討論以前,簡單聊聊token。session和token都經常用來做爲用戶鑑權的機制。json

大部分狀況下,當咱們提到session鑑權的時候,指的是這樣一個流程segmentfault

  • 用戶登陸的時候,服務端生成一個會話和一個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,包括:

  1. maxAge,這個是肯定cookie的有效期,默認是一天。
  2. rolling, renew,這兩個都是涉及到cookie有效期的更新策略
  3. httpOnly,表示是否能夠經過javascript來修改,設成true會更加安全
  4. signed,這個涉及到cookie的安全性,下面再討論
  5. 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的基本流程很是簡單

  1. 根據cookie或者外部存儲初始化cookie。
  2. 調用next()執行後面的業務邏輯,其中能夠讀取和寫入新的session內容。
  3. 調用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的狀況包括

  1. 若是session有變更
  2. 在config裏設置了rolling爲true,也就是每次都更新session
  3. 在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/11... 寫到一半的時候才發現這篇文章,對於session總體流程也講的挺清楚的,能夠對着一塊兒看
  • 由於koa-session的代碼比較簡單,有時間的話對着源碼調試一下很容易搞懂
  • 初學js和node,可能不少地方會有錯漏,請你們指正。
相關文章
相關標籤/搜索