Egg 的 Cookie 與 Session

2020-02-06 14:11 更新

Cookie

HTTP 請(qǐng)求都是無(wú)狀態(tài)的,但是我們的 Web 應(yīng)用通常都需要知道發(fā)起請(qǐng)求的人是誰(shuí)。為了解決這個(gè)問(wèn)題,HTTP 協(xié)議設(shè)計(jì)了一個(gè)特殊的請(qǐng)求頭:Cookie。服務(wù)端可以通過(guò)響應(yīng)頭(set-cookie)將少量數(shù)據(jù)響應(yīng)給客戶(hù)端,瀏覽器會(huì)遵循協(xié)議將數(shù)據(jù)保存,并在下次請(qǐng)求同一個(gè)服務(wù)的時(shí)候帶上(瀏覽器也會(huì)遵循協(xié)議,只在訪問(wèn)符合 Cookie 指定規(guī)則的網(wǎng)站時(shí)帶上對(duì)應(yīng)的 Cookie 來(lái)保證安全性)。

通過(guò) ctx.cookies,我們可以在 controller 中便捷、安全的設(shè)置和讀取 Cookie。

class HomeController extends Controller {
async add() {
const ctx = this.ctx;
let count = ctx.cookies.get('count');
count = count ? Number(count) : 0;
ctx.cookies.set('count', ++count);
ctx.body = count;
}
async remove() {
const ctx = this.ctx;
ctx.cookies.set('count', null);
ctx.status = 204;
}
}

ctx.cookies.set(key, value, options)

設(shè)置 Cookie 其實(shí)是通過(guò)在 HTTP 響應(yīng)中設(shè)置 set-cookie 頭完成的,每一個(gè) set-cookie 都會(huì)讓瀏覽器在 Cookie 中存一個(gè)鍵值對(duì)。在設(shè)置 Cookie 值的同時(shí),協(xié)議還支持許多參數(shù)來(lái)配置這個(gè) Cookie 的傳輸、存儲(chǔ)和權(quán)限。

  • {Number} maxAge: 設(shè)置這個(gè)鍵值對(duì)在瀏覽器的最長(zhǎng)保存時(shí)間。是一個(gè)從服務(wù)器當(dāng)前時(shí)刻開(kāi)始的毫秒數(shù)。
  • {Date} expires: 設(shè)置這個(gè)鍵值對(duì)的失效時(shí)間,如果設(shè)置了 maxAge,expires 將會(huì)被覆蓋。如果 maxAge 和 expires 都沒(méi)設(shè)置,Cookie 將會(huì)在瀏覽器的會(huì)話(huà)失效(一般是關(guān)閉瀏覽器時(shí))的時(shí)候失效。
  • {String} path: 設(shè)置鍵值對(duì)生效的 URL 路徑,默認(rèn)設(shè)置在根路徑上(/),也就是當(dāng)前域名下的所有 URL 都可以訪問(wèn)這個(gè) Cookie。
  • {String} domain: 設(shè)置鍵值對(duì)生效的域名,默認(rèn)沒(méi)有配置,可以配置成只在指定域名才能訪問(wèn)。
  • {Boolean} httpOnly: 設(shè)置鍵值對(duì)是否可以被 js 訪問(wèn),默認(rèn)為 true,不允許被 js 訪問(wèn)。
  • {Boolean} secure: 設(shè)置鍵值對(duì)只在 HTTPS 連接上傳輸,框架會(huì)幫我們判斷當(dāng)前是否在 HTTPS 連接上自動(dòng)設(shè)置 secure 的值。

除了這些屬性之外,框架另外擴(kuò)展了 3 個(gè)參數(shù)的支持:

  • {Boolean} overwrite:設(shè)置 key 相同的鍵值對(duì)如何處理,如果設(shè)置為 true,則后設(shè)置的值會(huì)覆蓋前面設(shè)置的,否則將會(huì)發(fā)送兩個(gè) set-cookie 響應(yīng)頭。
  • {Boolean} signed:設(shè)置是否對(duì) Cookie 進(jìn)行簽名,如果設(shè)置為 true,則設(shè)置鍵值對(duì)的時(shí)候會(huì)同時(shí)對(duì)這個(gè)鍵值對(duì)的值進(jìn)行簽名,后面取的時(shí)候做校驗(yàn),可以防止前端對(duì)這個(gè)值進(jìn)行篡改。默認(rèn)為 true。
  • {Boolean} encrypt:設(shè)置是否對(duì) Cookie 進(jìn)行加密,如果設(shè)置為 true,則在發(fā)送 Cookie 前會(huì)對(duì)這個(gè)鍵值對(duì)的值進(jìn)行加密,客戶(hù)端無(wú)法讀取到 Cookie 的明文值。默認(rèn)為 false。

在設(shè)置 Cookie 時(shí)我們需要思考清楚這個(gè) Cookie 的作用,它需要被瀏覽器保存多久?是否可以被 js 獲取到?是否可以被前端修改?

默認(rèn)的配置下,Cookie 是加簽不加密的,瀏覽器可以看到明文,js 不能訪問(wèn),不能被客戶(hù)端(手工)篡改。

  • 如果想要 Cookie 在瀏覽器端可以被 js 訪問(wèn)并修改:
ctx.cookies.set(key, value, {
httpOnly: false,
signed: false,
});
  • 如果想要 Cookie 在瀏覽器端不能被修改,不能看到明文:
ctx.cookies.set(key, value, {
httpOnly: true, // 默認(rèn)就是 true
encrypt: true, // 加密傳輸
});

注意:

  1. 由于瀏覽器和其他客戶(hù)端實(shí)現(xiàn)的不確定性,為了保證 Cookie 可以寫(xiě)入成功,建議 value 通過(guò) base64 編碼或者其他形式 encode 之后再寫(xiě)入。
  2. 由于瀏覽器對(duì) Cookie 有長(zhǎng)度限制限制,所以盡量不要設(shè)置太長(zhǎng)的 Cookie。一般來(lái)說(shuō)不要超過(guò) 4093 bytes。當(dāng)設(shè)置的 Cookie value 大于這個(gè)值時(shí),框架會(huì)打印一條警告日志。

ctx.cookies.get(key, options)

由于 HTTP 請(qǐng)求中的 Cookie 是在一個(gè) header 中傳輸過(guò)來(lái)的,通過(guò)框架提供的這個(gè)方法可以快速的從整段 Cookie 中獲取對(duì)應(yīng)的鍵值對(duì)的值。上面在設(shè)置 Cookie 的時(shí)候,我們可以設(shè)置 options.signed 和 options.encrypt 來(lái)對(duì) Cookie 進(jìn)行簽名或加密,因此對(duì)應(yīng)的在獲取 Cookie 的時(shí)候也要傳相匹配的選項(xiàng)。

  • 如果設(shè)置的時(shí)候指定為 signed,獲取時(shí)未指定,則不會(huì)在獲取時(shí)對(duì)取到的值做驗(yàn)簽,導(dǎo)致可能被客戶(hù)端篡改。
  • 如果設(shè)置的時(shí)候指定為 encrypt,獲取時(shí)未指定,則無(wú)法獲取到真實(shí)的值,而是加密過(guò)后的密文。

如果要獲取前端或者其他系統(tǒng)設(shè)置的 cookie,需要指定參數(shù) signed 為 false,避免對(duì)它做驗(yàn)簽導(dǎo)致獲取不到 cookie 的值。

ctx.cookies.get('frontend-cookie', {
signed: false,
});

Cookie 秘鑰

由于我們?cè)?Cookie 中需要用到加解密和驗(yàn)簽,所以需要配置一個(gè)秘鑰供加密使用。在 config/config.default.js 中

module.exports = {
keys: 'key1,key2',
};

keys 配置成一個(gè)字符串,可以按照逗號(hào)分隔配置多個(gè) key。Cookie 在使用這個(gè)配置進(jìn)行加解密時(shí):

  • 加密和加簽時(shí)只會(huì)使用第一個(gè)秘鑰。
  • 解密和驗(yàn)簽時(shí)會(huì)遍歷 keys 進(jìn)行解密。

如果我們想要更新 Cookie 的秘鑰,但是又不希望之前設(shè)置到用戶(hù)瀏覽器上的 Cookie 失效,可以將新的秘鑰配置到 keys 最前面,等過(guò)一段時(shí)間之后再刪去不需要的秘鑰即可。

Session

Cookie 在 Web 應(yīng)用中經(jīng)常承擔(dān)標(biāo)識(shí)請(qǐng)求方身份的功能,所以 Web 應(yīng)用在 Cookie 的基礎(chǔ)上封裝了 Session 的概念,專(zhuān)門(mén)用做用戶(hù)身份識(shí)別。

框架內(nèi)置了 Session 插件,給我們提供了 ctx.session 來(lái)訪問(wèn)或者修改當(dāng)前用戶(hù) Session 。

class HomeController extends Controller {
async fetchPosts() {
const ctx = this.ctx;
// 獲取 Session 上的內(nèi)容
const userId = ctx.session.userId;
const posts = await ctx.service.post.fetch(userId);
// 修改 Session 的值
ctx.session.visited = ctx.session.visited ? (ctx.session.visited + 1) : 1;
ctx.body = {
success: true,
posts,
};
}
}

Session 的使用方法非常直觀,直接讀取它或者修改它就可以了,如果要?jiǎng)h除它,直接將它賦值為 null:

ctx.session = null;

需要 特別注意 的是:設(shè)置 session 屬性時(shí)需要避免以下幾種情況(會(huì)造成字段丟失,詳見(jiàn) koa-session 源碼)

  • 不要以 _ 開(kāi)頭
  • 不能為 isNew
// ? 錯(cuò)誤的用法
ctx.session._visited = 1; // --> 該字段會(huì)在下一次請(qǐng)求時(shí)丟失
ctx.session.isNew = 'HeHe'; // --> 為內(nèi)部關(guān)鍵字, 不應(yīng)該去更改

// ?? 正確的用法
ctx.session.visited = 1; // --> 此處沒(méi)有問(wèn)題

Session 的實(shí)現(xiàn)是基于 Cookie 的,默認(rèn)配置下,用戶(hù) Session 的內(nèi)容加密后直接存儲(chǔ)在 Cookie 中的一個(gè)字段中,用戶(hù)每次請(qǐng)求我們網(wǎng)站的時(shí)候都會(huì)帶上這個(gè) Cookie,我們?cè)诜?wù)端解密后使用。Session 的默認(rèn)配置如下:

exports.session = {
key: 'EGG_SESS',
maxAge: 24 * 3600 * 1000, // 1 天
httpOnly: true,
encrypt: true,
};

可以看到這些參數(shù)除了 key 都是 Cookie 的參數(shù),key 代表了存儲(chǔ) Session 的 Cookie 鍵值對(duì)的 key 是什么。在默認(rèn)的配置下,存放 Session 的 Cookie 將會(huì)加密存儲(chǔ)、不可被前端 js 訪問(wèn),這樣可以保證用戶(hù)的 Session 是安全的。

擴(kuò)展存儲(chǔ)

Session 默認(rèn)存放在 Cookie 中,但是如果我們的 Session 對(duì)象過(guò)于龐大,就會(huì)帶來(lái)一些額外的問(wèn)題:

  • 前面提到,瀏覽器通常都有限制最大的 Cookie 長(zhǎng)度,當(dāng)設(shè)置的 Session 過(guò)大時(shí),瀏覽器可能拒絕保存。
  • Cookie 在每次請(qǐng)求時(shí)都會(huì)帶上,當(dāng) Session 過(guò)大時(shí),每次請(qǐng)求都要額外帶上龐大的 Cookie 信息。

框架提供了將 Session 存儲(chǔ)到除了 Cookie 之外的其他存儲(chǔ)的擴(kuò)展方案,我們只需要設(shè)置 app.sessionStore 即可將 Session 存儲(chǔ)到指定的存儲(chǔ)中。

// app.js
module.exports = app => {
app.sessionStore = {
// support promise / async
async get (key) {
// return value;
},
async set (key, value, maxAge) {
// set key to store
},
async destroy (key) {
// destroy key
},
};
};

sessionStore 的實(shí)現(xiàn)我們也可以封裝到插件中,例如 egg-session-redis 就提供了將 Session 存儲(chǔ)到 redis 中的能力,在應(yīng)用層,我們只需要引入 egg-redis 和 egg-session-redis 插件即可。

// plugin.js
exports.redis = {
enable: true,
package: 'egg-redis',
};
exports.sessionRedis = {
enable: true,
package: 'egg-session-redis',
};

注意:一旦選擇了將 Session 存入到外部存儲(chǔ)中,就意味著系統(tǒng)將強(qiáng)依賴(lài)于這個(gè)外部存儲(chǔ),當(dāng)它掛了的時(shí)候,我們就完全無(wú)法使用 Session 相關(guān)的功能了。因此我們更推薦大家只將必要的信息存儲(chǔ)在 Session 中,保持 Session 的精簡(jiǎn)并使用默認(rèn)的 Cookie 存儲(chǔ),用戶(hù)級(jí)別的緩存不要存儲(chǔ)在 Session 中。

Session 實(shí)踐

修改用戶(hù) Session 失效時(shí)間

雖然在 Session 的配置中有一項(xiàng)是 maxAge,但是它只能全局設(shè)置 Session 的有效期,我們經(jīng)??梢栽谝恍┚W(wǎng)站的登陸頁(yè)上看到有 記住我 的選項(xiàng)框,勾選之后可以讓登陸用戶(hù)的 Session 有效期更長(zhǎng)。這種針對(duì)特定用戶(hù)的 Session 有效時(shí)間設(shè)置我們可以通過(guò) ctx.session.maxAge= 來(lái)實(shí)現(xiàn)。

const ms = require('ms');
class UserController extends Controller {
async login() {
const ctx = this.ctx;
const { username, password, rememberMe } = ctx.request.body;
const user = await ctx.loginAndGetUser(username, password);

// 設(shè)置 Session
ctx.session.user = user;
// 如果用戶(hù)勾選了 `記住我`,設(shè)置 30 天的過(guò)期時(shí)間
if (rememberMe) ctx.session.maxAge = ms('30d');
}
}

延長(zhǎng)用戶(hù) Session 有效期

默認(rèn)情況下,當(dāng)用戶(hù)請(qǐng)求沒(méi)有導(dǎo)致 Session 被修改時(shí),框架都不會(huì)延長(zhǎng) Session 的有效期,但是在有些場(chǎng)景下,我們希望用戶(hù)如果長(zhǎng)時(shí)間都在訪問(wèn)我們的站點(diǎn),則延長(zhǎng)他們的 Session 有效期,不讓用戶(hù)退出登錄態(tài)??蚣芴峁┝艘粋€(gè) renew 配置項(xiàng)用于實(shí)現(xiàn)此功能,它會(huì)在發(fā)現(xiàn)當(dāng)用戶(hù) Session 的有效期僅剩下最大有效期一半的時(shí)候,重置 Session 的有效期。

// config/config.default.js
module.exports = {
session: {
renew: true,
},
};


以上內(nèi)容是否對(duì)您有幫助:
在線(xiàn)筆記
App下載
App下載

掃描二維碼

下載編程獅App

公眾號(hào)
微信公眾號(hào)

編程獅公眾號(hào)