Koa 使用小技巧(小結)
cookie的安全保護
基于cookie來驗證用戶狀態(tài)的系統(tǒng)中,如何提高cookie的安全級別是首要因素,最簡單直接的方式就生成的cookie值隨機而且復雜。一般使用uuid來生成cookie,生成的隨機串在復雜度上已滿足需求,但是如果真被攻擊者嘗試到一個可用的值,那怎么防范呢?使用signed的cookie設置,如下所示:
app.keys = ["token"];
...
ctx.cookies.set("jt", "abcd", {
signed: true,
});
在設置 jt 這個cookie的時候,koa會以 jt 的值 abcd 加上設置的密鑰,生成校驗值,并寫入至 jt.sig 這個cookie中,所以能看到響應的HTTP頭中如下所示:
Set-Cookie: jt=abcd; path=/; httponly Set-Cookie: jt.sig=gpDbdxr25sarDhE_1yMSAnIn_bU; path=/; httponly
在后續(xù)的請求中,獲取 jt 這個cookie時,則會根據 jt.sig 的值判斷是否合法,安全性上又明顯提升。
那么 app.keys 為什么是設計為數組呢?先來考慮以下的一種場景,當希望更換密鑰的時候,原有的的cookie都將因為密鑰更新而導致校驗失敗,則用戶的登錄狀態(tài)失效。一次還好,如果需要經常需要更新密鑰(我一般一個月更換一次),那怎么處理好?這就是 app.keys 為配置為數組的使用邏輯了。
當生成cookie時,使用keys中的第一個元素來生成,而校驗的時候,是從第一個至最后一個,一個個的校驗,直到通過為止,所以在更新密鑰的時候,只需要把新的密鑰加到數組第一位則可以。我一般再保留兩組密鑰,因為更新是一個月一次,因此如果客戶的cookie是三個月前生成的,那就會失效了。
cookie的校驗是基于 keygrip 來處理的,大家也可以使用它來做自己的一些數據校驗,如驗證碼之類。
異常處理
在使用koa時,一般出錯都是使用 ctx.throw 來拋出一個error,中斷處理流程,接口響應出錯,處理邏輯如下所未:
app.on('error', (err, ctx) => {
// 記錄異常日志
console.error(err);
});
app.use((ctx) => {
ctx.throw(400, '參數錯誤');
});
此處只利用了koa自帶的異常出錯,過于簡單,我們希望能針對主動拋出的異常與程序異常能加以區(qū)分,因此需要自定義異常處理的中間件,如下:
app.on('error', (err, ctx) => {
// 記錄異常日志
console.error(err);
});
app.use(async(ctx, next) => {
try {
await next()
} catch (err) {
let status = 500;
const message = err.message;
// koa的throw使用http-errors來生成error
// 此處只判斷是否有status,有則認為是http-errors
if (err.status) {
status = err.status
} else {
// 非主動拋出異常,則觸發(fā)error事件,記錄異常日志
ctx.app.emit("error", err, ctx);
}
ctx.status = status;
ctx.body = {
message,
};
}
})
app.use((ctx) => {
// 代碼異常
// ctx.i.j = 0;
// 主動拋出異常
ctx.throw(400, '參數錯誤');
});
通過此調整后,將邏輯主動拋出異常與程序異常區(qū)分開,定時去查看異常日志,減少程序異常。此例子只是簡單的使用了http-errors來創(chuàng)建主動拋出的異常,在實際使用中,可以根據自己的場景創(chuàng)建自定義的Error類,定制相應的異常信息。
當前正在處理請求數
得益于nodejs的IO處理,koa在高并發(fā)的場景下的CPU、內存都占用并不高,但是也因為這樣,如果只通過CPU、內存來監(jiān)控程序運行狀態(tài)并不全面,因此需要增加當前處理請求數的監(jiān)控,代碼如下:
let processingCount = 0;
const maxProcessingCount = 1000;
app.use(async (ctx, next) => {
processingCount++;
if (processingCount > maxProcessingCount) {
// 如果需要也可以直接在處理請求超時時,直接出錯
console.error("processing request over limit");
}
try {
await next();
} catch (err) {
throw err;
} finally {
processingCount--;
}
});
app.use(async (ctx) => {
// 延時一秒
await new Promise(resolve => setTimeout(resolve, 1000));
ctx.body = {
account: 'vicanso',
};
});
此中間件在接收到請求時,將處理請求數加一,在處理完成后減一。最大的處理請求數根據系統(tǒng)的性能與用戶數量選擇合理的值。如果接口處理慢或者突然并發(fā)請求暴漲的時,可以盡早得知異常情況,盡早排查。
延時響應
接口的處理一般而言都是希望越快越好,但有些場景我們不希望接口響應的太快(如注冊),避免惡意者迅速嘗試功能,因此需要一個延時響應的中間件,代碼如下:
function delayResponse(delayMs) {
const delay = (t) => {
const d = delayMs - (Date.now() - t);
// 如果處理時長已超過delayMs,無需等待
if (d <= 0) {
return Promise.resolve();
}
return new Promise(resolve => setTimeout(resolve, d));
}
return async(ctx, next) => {
const startedAt = Date.now();
try {
await next();
// 成功處理時等待
await delay(startedAt);
} catch (err) {
// 失敗時也等待
await delay(startedAt);
throw err;
}
}
}
router.post('/users/v1/register', delayResponse(1000), (ctx) => {
ctx.body = {
account: 'vicanso',
};
});
通過此中間件,可以限制某些功能的響應時長(保證每次處理時間都大于期望值),需要注意的是,延時響應的不要超過全局的超時配置。
接口性能統(tǒng)計
系統(tǒng)是否穩(wěn)定,性能是否需要優(yōu)化等都依賴于統(tǒng)計,為了能及時反應出系統(tǒng)狀態(tài),并方便添加告警指標,我將相關的統(tǒng)計數據寫入influxdb,主要指標如下:
tags:
- method,請求類型
- type,根據響應狀態(tài)碼分組,1xx -> 1, 2xx -> 2
- spdy,根據自定義的響應時間劃分區(qū)間,方便將接口響應時間分組
- route,接口路由
fields:
- connecting,處理請求數
- use,處理時長
- bytes,響應數字長度
- code,響應狀態(tài)碼
- url,請求地址
- ip,用戶IP
在influxdb中,tags可用于對數據分組,根據 type 將接口請求分組,將 4 與 5 的單獨監(jiān)控,可以簡單快速的把當前接口出錯匯總。統(tǒng)計中間件代碼如下:
function stats() {
let connecting = 0;
const spdyList = [
100,
300,
1000,
3000,
];
return async (ctx, next) => {
const start = Date.now();
const tags = {
method: ctx.method,
};
connecting++;
const fields = {
connecting,
url: ctx.url,
}
let status = 0;
try {
await next();
} catch (err) {
// 出錯時狀態(tài)碼從error中獲取
status = err.status;
throw err;
} finally {
// 如果非出錯,則從ctx中取狀態(tài)碼
if (!status) {
status = ctx.status;
}
const use = Date.now() - start;
connecting--;
tags.route = ctx._matchedRoute;
tags.type = `${status / 100 | 0}`
let spdy = 0;
// 確認處理時長所在區(qū)間
spdyList.forEach((v, i) => {
if (use > v) {
spdy = i + 1;
}
});
tags.spdy = `${spdy}`;
fields.use = use;
fields.bytes = ctx.length || 0;
fields.code = status;
fields.ip = ctx.ip;
// 統(tǒng)計數據寫入統(tǒng)計系統(tǒng)(如influxdb)
console.info(tags);
console.info(fields);
}
};
}
app.use(stats());
router.post('/users/v1/:type', async (ctx) => {
await new Promise(resolve => setTimeout(resolve, 100))
ctx.body = {
account: 'vicanso',
};
});
接口全日志記錄
為了方便排查問題,需要將接口的相關信息輸出至日志中,中間件的實現(xiàn)如下:
function tracker() {
const stringify = (data) => JSON.stringify(data, (key, value) => {
// 對于隱私數據做***處理
if (/password/.test(key)) {
return '***';
}
return value;
});
return async (ctx, next) => {
const trackerInfo = {
url: ctx.url,
form: ctx.request.body,
};
try {
await next();
} catch (err) {
trackerInfo.error = err.message;
throw err;
} finally {
trackerInfo.params = ctx.params;
if (!trackerInfo.error) {
trackerInfo.body = ctx.body;
}
console.info(stringify(trackerInfo))
}
};
}
app.use(bodyParser());
app.use(tracker());
router.post('/users/v1/:type', async (ctx) => {
// ctx.throw(400, '密碼出錯');
await new Promise(resolve => setTimeout(resolve, 100))
ctx.body = {
account: 'vicanso',
};
});
使用此中間件之后,可以將所有接口的參數、正常響應數據或出錯信息都全部輸出至日志中,可根據需要調整 stringify 的實現(xiàn),將一些隱私數據做***處理。需要注意的是,由于部分接口的body響應體部分較大,是否需要將所有數據都輸出至日志最好根據實際情況衡量。如可根據HTTP Method過濾,或者根據url規(guī)則等。
參數校驗
由于javascript的弱類型,接口參數校驗一直是要求最嚴格的一點,而在了解過 joi 之后,我就一直使用它來做參數校驗,如注冊功能,賬號、密碼為必選參數,而郵箱為可選,接口校驗的代碼如下:
function validate(data, schema) {
const result = Joi.validate(data, schema);
if (result.error) {
// 出錯可創(chuàng)建自定義的校驗出錯類型
throw result.error;
}
return result.value;
}
router.post('/users/v1/register', async (ctx) => {
const data = validate(ctx.request.body, Joi.object({
// 賬號限制長度為3-20個字符串
account: Joi.string().min(3).max(20).required(),
// 密碼限制長度為6-30,而且只允許字母與數字
password: Joi.string().regex(/^[a-zA-Z0-9]{6,30}$/).required(),
email: Joi.string().email().optional(),
}));
ctx.body = {
account: data.account,
};
});
通過joi簡單快捷實現(xiàn)了參數的校驗,不過在實際使用中,有部分的參數校驗規(guī)則是通用的,如賬號、密碼這些的校驗規(guī)則在注冊和登錄中都通過,但是有些接口是可選,有一些是必須,怎么才能更通用一些呢?代碼調整如下:
const userSchema = {
// 賬號限制長度為3-20個字符串
account: () => Joi.string().min(3).max(20),
// 密碼限制長度為6-30,而且只允許字母與數字
password: () => Joi.string().regex(/^[a-zA-Z0-9]{6,30}$/),
email: () => Joi.string().email(),
}
router.post('/users/v1/register', async (ctx) => {
const data = validate(ctx.request.body, Joi.object({
account: userSchema.account().required(),
password: userSchema.password().required(),
email: userSchema.email().optional(),
}));
ctx.body = {
account: data.account,
};
});
經此調整后,將用戶參數校驗的基本規(guī)則都定義在 userSchema 中,每個接口在各自的場景下選擇不同的參數以及增加規(guī)則,提高代碼復用率以及校驗準確性。
以上就是本文的全部內容,希望對大家的學習有所幫助,也希望大家多多支持腳本之家。
相關文章
nvm報錯Now?using?node?v版本號(64-bit)圖文解決方法
這篇文章主要給大家介紹了關于nvm報錯Now?using?node?v版本號(64-bit)的解決方法,文中將解決的辦法介紹的非常詳細,對遇到這個問題的朋友具有一定的參考借鑒價值,需要的朋友可以參考下2023-11-11
nodejs發(fā)布靜態(tài)https服務器的方法
這篇文章主要介紹了nodejs發(fā)布靜態(tài)https服務器的方法,本文通過實例代碼給大家介紹的非常詳細,對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-09-09
node如何實現(xiàn)cmd彈窗交互之inquirer
這篇文章主要介紹了node如何實現(xiàn)cmd彈窗交互之inquirer問題,具有很好的參考價值,希望對大家有所幫助,如有錯誤或未考慮完全的地方,望不吝賜教2023-10-10

