koa-session學(xué)習(xí)筆記

koa-session是koa的session管理中間件,最近在寫登錄注冊模塊的時候?qū)W習(xí)了一下這部分的代碼,感覺還比較容易看明白,讓自己對于session的理解也更加深入了,這里總結(jié)一下。

session基礎(chǔ)知識

這部分算是基礎(chǔ)知識,熟悉的朋友可以跳過。

我們都知道http協(xié)議本身是無狀態(tài)的,因此協(xié)議本身是不支持“登錄狀態(tài)”這樣的概念的,必須由項目自己來實(shí)現(xiàn)。我們常常說到session這個概念,但是可能有人并不是非常清楚我們討論的session具體指代什么。我覺得這個概念比較容易混淆,不同的上下文會有不同的含義:

  • session首先是一個抽象的概念,指代多個有關(guān)聯(lián)的http請求所構(gòu)成的一個會話。
  • session常常用來指代為了實(shí)現(xiàn)一個會話,需要在客戶端和服務(wù)端之間傳輸?shù)男畔ⅰ_@些信息可以是會話所需的所有內(nèi)容(包括用戶身份、相關(guān)數(shù)據(jù)等),也可以只是一個id,讓服務(wù)端可能從后臺檢索到相關(guān)數(shù)據(jù),這也是實(shí)際系統(tǒng)中最常用的方式。

當(dāng)我們討論session的實(shí)現(xiàn)方式的時候,都是尋找一種方式從而使得多次請求之間能夠共享一些信息。不論選擇哪種方式,都是需要由服務(wù)自己來實(shí)現(xiàn)的,http協(xié)議并不提供原生的支持。

實(shí)現(xiàn)session的一種方式就是在每個請求的參數(shù)或者數(shù)據(jù)中帶上相關(guān)信息,這種方式的好處是不受cookie可用性的限制。我們在登錄某些網(wǎng)站的時候會發(fā)現(xiàn)url里有長長的一串不規(guī)則字符,往往就是編碼了用戶的session信息。但是這種方式也會受到請求長度的限制,使用起來也不方便,而且還有安全性上的隱患。

最常見的方式還是使用cookie來存儲session信息。如上所述,這里的信息可以是整個session的具體數(shù)據(jù),也可以只是session的標(biāo)識。這樣服務(wù)端通過set-cookie的方式把信息返回給客戶端,客戶端下次請求的時候會自動帶上符合條件的cookie,服務(wù)端再解析cookie就能夠獲取到session信息了。koa-session也是采用cookie來實(shí)現(xiàn)session,默認(rèn)情況下只使用一個cookie字段來存儲session信息。

session vs token

在進(jìn)入koa-session的討論之前,簡單聊聊token。session和token都常常用來作為用戶鑒權(quán)的機(jī)制。

大部分情況下,當(dāng)我們提到session鑒權(quán)的時候,指的是這樣一個流程

  • 用戶登錄的時候,服務(wù)端生成一個會話和一個id標(biāo)識
  • 會話id在客戶端和服務(wù)端之間通過cookie進(jìn)行傳輸
  • 服務(wù)端通過會話id可以獲取到會話相關(guān)的信息,然后對客戶端的請求進(jìn)行響應(yīng);如果找不到有效的會話,那么認(rèn)為用戶是未登陸狀態(tài)
  • 會話會有過期時間,也可以通過一些操作(比如登出)來主動刪除

token的典型流程為:

  • 用戶登錄的時候,服務(wù)端生成一個token返回給客戶端
  • 客戶端后續(xù)的請求都帶上這個token
  • 服務(wù)端解析token獲取用戶信息,并響應(yīng)用戶的請求
  • token會有過期時間,客戶端登出的時候也會廢棄token,但是服務(wù)端不需要任何操作

兩種方式的區(qū)別在于:

  • session要求服務(wù)端存儲信息,并且根據(jù)id能夠檢索,而token不需要。在大規(guī)模系統(tǒng)中,對每個請求都檢索會話信息可能是一個復(fù)雜和耗時的過程。但另外一方面服務(wù)端要通過token來解析用戶身份也需要定義好相應(yīng)的協(xié)議。
  • session一般通過cookie來交互,而token方式更加靈活,可以是cookie,也可以是其他header,也可以放在請求的內(nèi)容中。不使用cookie可以帶來跨域上的便利性。
  • token的生成方式更加多樣化,可以由第三方服務(wù)來提供

很多情況下,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);

我們看到這個在這個回話狀態(tài)中,session中保存了頁面訪問次數(shù),每次請求的時候,會增加計數(shù)再把結(jié)果返回給用戶。

koa-session的代碼結(jié)構(gòu)很簡單

index.js          // 定義主流程和擴(kuò)展context
 \- context.js    // 定義SessionContext類,定義了對session的主要操作
 \- session.js    // 定義session類,只有一些簡單的util
 \- util.js       // 對session進(jìn)行編碼解碼的util

在使用koa-session的時候用戶可以傳一個自定義的config,包括:

  1. maxAge,這個是確定cookie的有效期,默認(rèn)是一天。
  2. rolling, renew,這兩個都是涉及到cookie有效期的更新策略
  3. httpOnly,表示是否可以通過javascript來修改,設(shè)成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];  // 獲取當(dāng)前的session,這里設(shè)置了一個getter,首次訪問時會創(chuàng)建一個新的ContextSession
    if (sess.store) await sess.initFromExternal(); // 如果設(shè)置了使用外部存儲,就從外部存儲初始化
    try {
      await next();
    } catch (err) {
      throw err;
    } finally {
      await sess.commit();
    }
  };
};

可以看到koa-session的基本流程非常簡單

  1. 根據(jù)cookie或者外部存儲初始化cookie。
  2. 調(diào)用next()執(zhí)行后面的業(yè)務(wù)邏輯,其中可以讀取和寫入新的session內(nèi)容。
  3. 調(diào)用commit()把更新后的session保存下來。

session存儲

對于session的存儲方式,koa-session同時支持cookie和外部存儲。

默認(rèn)配置下,會使用cookie來存儲session信息,也就是實(shí)現(xiàn)了一個"cookie session"。這種方式對服務(wù)端是比較輕松的,不需要額外記錄任何session信息,但是也有不少限制,比如大小的限制以及安全性上的顧慮。用cookie保存時,實(shí)現(xiàn)上非常簡單,就是對session(包括過期時間)序列化后做一個簡單的base64編碼。其結(jié)果類似
koa:sess=eyJwYXNzcG9ydCI6eyJ1c2VyIjozMDM0MDg1MTQ4OTcwfSwiX2V4cGlyZSI6MTUxNzI3NDE0MTI5MiwiX21heEFnZSI6ODY0MDAwMDB9;

在實(shí)際項目中,會話相關(guān)信息往往需要再服務(wù)端持久化,因此一般都會使用外部存儲來記錄session信息。外部存儲可以是任何的存儲系統(tǒng),可以是內(nèi)存數(shù)據(jù)結(jié)構(gòu),也可以是本地的文件,也可以是遠(yuǎn)程的數(shù)據(jù)庫。但是這不意味著我們不需要cookie了,由于http協(xié)議的無狀態(tài)特性,我們依然需要通過cookie來獲取session的標(biāo)識(這里叫externalKey)。koa-session里的external key默認(rèn)是一個時間戳加上一個隨機(jī)串,因此cookie的內(nèi)容類似
koa:sess=1517188075739-wnRru1LrIv0UFDODDKo8trbmFubnVmMU;

要實(shí)現(xiàn)一個外置的存儲,用戶需要自定義get(), set()和destroy()函數(shù),分別用于獲取、更新和刪除session。一個最簡單的實(shí)現(xiàn),我們就采用一個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主要流程中, 可以看到調(diào)用了extendContext(app.context, opts),其作用是給context擴(kuò)充了一些內(nèi)容,代碼如下

// 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,用于在首次調(diào)用時新建一個ContextSession對象。

session字段就是用于讀寫ContextSession里的session字段。這里有一點(diǎn)奇怪的是,從cookie初始化是在首次調(diào)用ContextSession.get()的時候才進(jìn)行,而從外部存儲初始化則是在主流程中就調(diào)用了。

ContextSession類定義在koa-session庫的context.js文件中,其get()函數(shù)代碼如下

// 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的初始化過程,代碼很簡單,我加了一點(diǎn)注釋,最需要注意的就是生成一個prevHash來標(biāo)記當(dāng)前狀態(tài)

// 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,如果不存在就調(diào)用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內(nèi)容
      json = opts.decode(cookie);
    } catch (err) {
      // FK: 省略錯誤處理內(nèi)容
    }

    debug('parsed %j', json);
    
    // FK: 對于session檢查有效性,如果失?。ū热缫呀?jīng)過期)就新建一個session
    if (!this.valid(json)) {
      this.create();
      return;
    }

    // support access `ctx.session` before session middleware
    // FK: 根據(jù)cookie的內(nèi)容來創(chuàng)建session
    this.create(json);
    // FK: *** 記錄當(dāng)前session的hash值,用于在業(yè)務(wù)流程完成判斷是否有更新 ***
    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中的內(nèi)容就是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: 如果在外部存儲中找不到相應(yīng)的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: 根據(jù)外部存儲的內(nèi)容來創(chuàng)建session
    this.create(json, externalKey);
    // FK: *** 記錄當(dāng)前session的hash值,用于在業(yè)務(wù)流程完成判斷是否有更新 ***
    this.prevHash = util.hash(this.session.toJSON());
  }

session提交

主流程我們已經(jīng)看到,在業(yè)務(wù)邏輯處理之后,會調(diào)用sess.commit()來提交修改后的session。根據(jù)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的條件又調(diào)用了_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里設(shè)置了rolling為true,也就是每次都更新session
  3. 在config里設(shè)置了renew為true,且有效期已經(jīng)過了一半,需要更新session

一旦滿足任何一個條件,就會調(diào)用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的基本操作流程應(yīng)該都已經(jīng)清楚了。

安全性

如果session采用外部存儲的方式,安全性是比較容易保證的,因?yàn)閏ookie中保存的只是session的external key,默認(rèn)實(shí)現(xiàn)是一個時間戳加隨機(jī)字符串,因此不用擔(dān)心被惡意篡改或者暴露信息。當(dāng)然如果cookie本身被竊取,那么在過期之前還是可以被用來訪問session信息(當(dāng)然我們可以在標(biāo)識中加入更多的信息,比如ip地址,設(shè)備id等信息,從而增加更多校驗(yàn)來減少風(fēng)險)。

如果session完全保存在cookie中,就需要額外注意安全性的問題。在session的默認(rèn)實(shí)現(xiàn)中,我們注意到對cookie的編碼只是簡單的base64,因此理論上客戶端很容易解析和修改。

因此在koa-session的config中有一個httpOnly的選項,就是不允許瀏覽器中的js代碼來獲取cookie,避免遭到一些惡意代碼的攻擊。

但是假如cookie被竊取,攻擊者還是可以很容易的修改cookie,比如把maxAge設(shè)為無限就可以一直使用cookie了,這種情況如何處理呢?其實(shí)是koa的cookie本身帶了安全機(jī)制,也就是config里的signed設(shè)為true的時候,會自動給cookie加上一個sha256的簽名,類似koa:sess.sig=pjadZtLAVtiO6-Haw1vnZZWrRm8,從而防止cookie被篡改。

最后,如何處理session的信息被泄露的問題呢?其實(shí)koa-session允許用戶在config中配置自己的編碼和解碼函數(shù),因此完全可以使用自定義的加密解密函數(shù)對session進(jìn)行編解碼,類似

  encode: json => CryptoJS.AES.encrypt(json, "Secret Passphrase"),
  decode: encrypted => CryptoJS.AES.decrypt(encrypted, "Secret Passphrase");

尾記

  • https://segmentfault.com/a/1190000012412299 寫到一半的時候才發(fā)現(xiàn)這篇文章,對于session整體流程也講的挺清楚的,可以對著一起看
  • 因?yàn)閗oa-session的代碼比較簡單,有時間的話對著源碼調(diào)試一下很容易搞懂
  • 初學(xué)js和node,可能很多地方會有錯漏,請大家指正。
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容