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)。
原文鏈接: