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,包括:
- maxAge,這個是確定cookie的有效期,默認(rèn)是一天。
- rolling, renew,這兩個都是涉及到cookie有效期的更新策略
- httpOnly,表示是否可以通過javascript來修改,設(shè)成true會更加安全
- signed,這個涉及到cookie的安全性,下面再討論
- 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的基本流程非常簡單
- 根據(jù)cookie或者外部存儲初始化cookie。
- 調(diào)用next()執(zhí)行后面的業(yè)務(wù)邏輯,其中可以讀取和寫入新的session內(nèi)容。
- 調(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的情況包括
- 如果session有變動
- 在config里設(shè)置了rolling為true,也就是每次都更新session
- 在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,可能很多地方會有錯漏,請大家指正。