Cookie
HTTP 請求都是無狀態(tài)的,但是我們的 Web 應(yīng)用通常都需要知道發(fā)起請求的人是誰。為了解決這個問題,HTTP 協(xié)議設(shè)計了一個特殊的請求頭:Cookie。服務(wù)端可以通過響應(yīng)頭(set-cookie)將少量數(shù)據(jù)響應(yīng)給客戶端,瀏覽器會遵循協(xié)議將數(shù)據(jù)保存,并在下次請求同一個服務(wù)的時候帶上(瀏覽器也會遵循協(xié)議,只在訪問符合 Cookie 指定規(guī)則的網(wǎng)站時帶上對應(yīng)的 Cookie 來保證安全性)。
通過 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 其實是通過在 HTTP 響應(yīng)中設(shè)置 set-cookie 頭完成的,每一個 set-cookie 都會讓瀏覽器在 Cookie 中存一個鍵值對。在設(shè)置 Cookie 值的同時,協(xié)議還支持許多參數(shù)來配置這個 Cookie 的傳輸、存儲和權(quán)限。
- {Number} maxAge: 設(shè)置這個鍵值對在瀏覽器的最長保存時間。是一個從服務(wù)器當(dāng)前時刻開始的毫秒數(shù)。
- {Date} expires: 設(shè)置這個鍵值對的失效時間,如果設(shè)置了 maxAge,expires 將會被覆蓋。如果 maxAge 和 expires 都沒設(shè)置,Cookie 將會在瀏覽器的會話失效(一般是關(guān)閉瀏覽器時)的時候失效。
- {String} path: 設(shè)置鍵值對生效的 URL 路徑,默認設(shè)置在根路徑上(/),也就是當(dāng)前域名下的所有 URL 都可以訪問這個 Cookie。
- {String} domain: 設(shè)置鍵值對生效的域名,默認沒有配置,可以配置成只在指定域名才能訪問。
- {Boolean} httpOnly: 設(shè)置鍵值對是否可以被 js 訪問,默認為 true,不允許被 js 訪問。
- {Boolean} secure: 設(shè)置鍵值對只在 HTTPS 連接上傳輸,框架會幫我們判斷當(dāng)前是否在 HTTPS 連接上自動設(shè)置 secure 的值。
除了這些屬性之外,框架另外擴展了 3 個參數(shù)的支持:
- {Boolean} overwrite:設(shè)置 key 相同的鍵值對如何處理,如果設(shè)置為 true,則后設(shè)置的值會覆蓋前面設(shè)置的,否則將會發(fā)送兩個 set-cookie 響應(yīng)頭。
- {Boolean} signed:設(shè)置是否對 Cookie 進行簽名,如果設(shè)置為 true,則設(shè)置鍵值對的時候會同時對這個鍵值對的值進行簽名,后面取的時候做校驗,可以防止前端對這個值進行篡改。默認為 true。
- {Boolean} encrypt:設(shè)置是否對 Cookie 進行加密,如果設(shè)置為 true,則在發(fā)送 Cookie 前會對這個鍵值對的值進行加密,客戶端無法讀取到 Cookie 的明文值。默認為 false。
在設(shè)置 Cookie 時我們需要思考清楚這個 Cookie 的作用,它需要被瀏覽器保存多久?是否可以被 js 獲取到?是否可以被前端修改?
默認的配置下,Cookie 是加簽不加密的,瀏覽器可以看到明文,js 不能訪問,不能被客戶端(手工)篡改。
- 如果想要 Cookie 在瀏覽器端可以被 js 訪問并修改:
ctx.cookies.set(key, value, { httpOnly: false, signed: false, });
|
- 如果想要 Cookie 在瀏覽器端不能被修改,不能看到明文:
ctx.cookies.set(key, value, { httpOnly: true, // 默認就是 true encrypt: true, // 加密傳輸 });
|
注意:
- 由于瀏覽器和其他客戶端實現(xiàn)的不確定性,為了保證 Cookie 可以寫入成功,建議 value 通過 base64 編碼或者其他形式 encode 之后再寫入。
- 由于瀏覽器對 Cookie 有長度限制限制,所以盡量不要設(shè)置太長的 Cookie。一般來說不要超過 4093 bytes。當(dāng)設(shè)置的 Cookie value 大于這個值時,框架會打印一條警告日志。
ctx.cookies.get(key, options)
由于 HTTP 請求中的 Cookie 是在一個 header 中傳輸過來的,通過框架提供的這個方法可以快速的從整段 Cookie 中獲取對應(yīng)的鍵值對的值。上面在設(shè)置 Cookie 的時候,我們可以設(shè)置 options.signed 和 options.encrypt 來對 Cookie 進行簽名或加密,因此對應(yīng)的在獲取 Cookie 的時候也要傳相匹配的選項。
- 如果設(shè)置的時候指定為 signed,獲取時未指定,則不會在獲取時對取到的值做驗簽,導(dǎo)致可能被客戶端篡改。
- 如果設(shè)置的時候指定為 encrypt,獲取時未指定,則無法獲取到真實的值,而是加密過后的密文。
如果要獲取前端或者其他系統(tǒng)設(shè)置的 cookie,需要指定參數(shù) signed 為 false,避免對它做驗簽導(dǎo)致獲取不到 cookie 的值。
ctx.cookies.get('frontend-cookie', { signed: false, });
|
Cookie 秘鑰
由于我們在 Cookie 中需要用到加解密和驗簽,所以需要配置一個秘鑰供加密使用。在 config/config.default.js 中
module.exports = { keys: 'key1,key2', };
|
keys 配置成一個字符串,可以按照逗號分隔配置多個 key。Cookie 在使用這個配置進行加解密時:
- 加密和加簽時只會使用第一個秘鑰。
- 解密和驗簽時會遍歷 keys 進行解密。
如果我們想要更新 Cookie 的秘鑰,但是又不希望之前設(shè)置到用戶瀏覽器上的 Cookie 失效,可以將新的秘鑰配置到 keys 最前面,等過一段時間之后再刪去不需要的秘鑰即可。
Session
Cookie 在 Web 應(yīng)用中經(jīng)常承擔(dān)標(biāo)識請求方身份的功能,所以 Web 應(yīng)用在 Cookie 的基礎(chǔ)上封裝了 Session 的概念,專門用做用戶身份識別。
框架內(nèi)置了 Session 插件,給我們提供了 ctx.session 來訪問或者修改當(dāng)前用戶 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 的使用方法非常直觀,直接讀取它或者修改它就可以了,如果要刪除它,直接將它賦值為 null:
需要 特別注意 的是:設(shè)置 session 屬性時需要避免以下幾種情況(會造成字段丟失,詳見 koa-session 源碼)
// ? 錯誤的用法 ctx.session._visited = 1; // --> 該字段會在下一次請求時丟失 ctx.session.isNew = 'HeHe'; // --> 為內(nèi)部關(guān)鍵字, 不應(yīng)該去更改
// ?? 正確的用法 ctx.session.visited = 1; // --> 此處沒有問題
|
Session 的實現(xiàn)是基于 Cookie 的,默認配置下,用戶 Session 的內(nèi)容加密后直接存儲在 Cookie 中的一個字段中,用戶每次請求我們網(wǎng)站的時候都會帶上這個 Cookie,我們在服務(wù)端解密后使用。Session 的默認配置如下:
exports.session = { key: 'EGG_SESS', maxAge: 24 * 3600 * 1000, // 1 天 httpOnly: true, encrypt: true, };
|
可以看到這些參數(shù)除了 key 都是 Cookie 的參數(shù),key 代表了存儲 Session 的 Cookie 鍵值對的 key 是什么。在默認的配置下,存放 Session 的 Cookie 將會加密存儲、不可被前端 js 訪問,這樣可以保證用戶的 Session 是安全的。
擴展存儲
Session 默認存放在 Cookie 中,但是如果我們的 Session 對象過于龐大,就會帶來一些額外的問題:
- 前面提到,瀏覽器通常都有限制最大的 Cookie 長度,當(dāng)設(shè)置的 Session 過大時,瀏覽器可能拒絕保存。
- Cookie 在每次請求時都會帶上,當(dāng) Session 過大時,每次請求都要額外帶上龐大的 Cookie 信息。
框架提供了將 Session 存儲到除了 Cookie 之外的其他存儲的擴展方案,我們只需要設(shè)置 app.sessionStore 即可將 Session 存儲到指定的存儲中。
// 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 的實現(xiàn)我們也可以封裝到插件中,例如 egg-session-redis 就提供了將 Session 存儲到 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 存入到外部存儲中,就意味著系統(tǒng)將強依賴于這個外部存儲,當(dāng)它掛了的時候,我們就完全無法使用 Session 相關(guān)的功能了。因此我們更推薦大家只將必要的信息存儲在 Session 中,保持 Session 的精簡并使用默認的 Cookie 存儲,用戶級別的緩存不要存儲在 Session 中。
Session 實踐
修改用戶 Session 失效時間
雖然在 Session 的配置中有一項是 maxAge,但是它只能全局設(shè)置 Session 的有效期,我們經(jīng)常可以在一些網(wǎng)站的登陸頁上看到有 記住我 的選項框,勾選之后可以讓登陸用戶的 Session 有效期更長。這種針對特定用戶的 Session 有效時間設(shè)置我們可以通過 ctx.session.maxAge= 來實現(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; // 如果用戶勾選了 `記住我`,設(shè)置 30 天的過期時間 if (rememberMe) ctx.session.maxAge = ms('30d'); } }
|
延長用戶 Session 有效期
默認情況下,當(dāng)用戶請求沒有導(dǎo)致 Session 被修改時,框架都不會延長 Session 的有效期,但是在有些場景下,我們希望用戶如果長時間都在訪問我們的站點,則延長他們的 Session 有效期,不讓用戶退出登錄態(tài)??蚣芴峁┝艘粋€ renew 配置項用于實現(xiàn)此功能,它會在發(fā)現(xiàn)當(dāng)用戶 Session 的有效期僅剩下最大有效期一半的時候,重置 Session 的有效期。
// config/config.default.js module.exports = { session: { renew: true, }, }; |
更多建議: