Koa 使用小技巧

cookie的安全保護javascript

基於cookie來驗證用戶狀態的系統中,如何提升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頭中以下所示:java

`Set-Cookie: jt=abcd; path=/; httponly`
`Set-Cookie: jt.sig=gpDbdxr25sarDhE_1yMSAnIn_bU; path=/; httponly`
複製代碼

在後續的請求中,獲取 jt 這個cookie時,則會根據 jt.sig 的值判斷是否合法,安全性上又明顯提高。node

那麼 app.keys 爲何是設計爲數組呢?先來考慮如下的一種場景,當但願更換密鑰的時候,原有的的cookie都將由於密鑰更新而致使校驗失敗,則用戶的登陸狀態失效。一次還好,若是須要常常須要更新密鑰(我通常一個月更換一次),那怎麼處理好?這就是 app.keys 爲配置爲數組的使用邏輯了。git

當生成cookie時,使用keys中的第一個元素來生成,而校驗的時候,是從第一個至最後一個,一個個的校驗,直到經過爲止,因此在更新密鑰的時候,只須要把新的密鑰加到數組第一位則能夠。我通常再保留兩組密鑰,由於更新是一個月一次,所以若是客戶的cookie是三個月前生成的,那就會失效了。github

cookie的校驗是基於 keygrip 來處理的,你們也可使用它來作本身的一些數據校驗,如驗證碼之類。數組

異常處理安全

在使用koa時,通常出錯都是使用 ctx.throw 來拋出一個error,中斷處理流程,接口響應出錯,處理邏輯以下所未:bash

`app.on(``'error'``, (err, ctx) => {`
`// 記錄異常日誌`
`console.error(err);`
`});`
`app.use((ctx) => {`
`ctx.``throw``(400,` `'參數錯誤'``);`
`});`
複製代碼

此處只利用了koa自帶的異常出錯,過於簡單,咱們但願能針對主動拋出的異常與程序異常能加以區分,所以須要自定義異常處理的中間件,以下:cookie

`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` `{`
`// 非主動拋出異常,則觸發error事件,記錄異常日誌`
`ctx.app.emit(``"error"``, err, ctx);`
`}`
`ctx.status = status;`
`ctx.body = {`
`message,`
`};`
`}`
`})`
`app.use((ctx) => {`
`// 代碼異常`
`// ctx.i.j = 0;`
`// 主動拋出異常`
`ctx.``throw``(400,` `'參數錯誤'``);`
`});`
複製代碼

經過此調整後,將邏輯主動拋出異常與程序異常區分開,定時去查看異常日誌,減小程序異常。此例子只是簡單的使用了http-errors來建立主動拋出的異常,在實際使用中,能夠根據本身的場景建立自定義的Error類,定製相應的異常信息。

當前正在處理請求數

得益於nodejs的IO處理,koa在高併發的場景下的CPU、內存都佔用並不高,可是也由於這樣,若是隻經過CPU、內存來監控程序運行狀態並不全面,所以須要增長當前處理請求數的監控,代碼以下:

`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--;`
`}`
`});`
歡迎加入全棧開發交流划水交流圈:582735936
面向划水1-3年前端人員
幫助突破划水瓶頸,提高思惟能力

`app.use(async (ctx) => {`
`// 延時一秒`
`await` `new` `Promise(resolve => setTimeout(resolve, 1000));`
`ctx.body = {`
`account:` `'vicanso'``,`
`};`
`});`
 |
複製代碼

此中間件在接收到請求時,將處理請求數加一,在處理完成後減一。最大的處理請求數根據系統的性能與用戶數量選擇合理的值。若是接口處理慢或者忽然併發請求暴漲的時,能夠儘早得知異常狀況,儘早排查。

延時響應

接口的處理通常而言都是但願越快越好,但有些場景咱們不但願接口響應的太快(如註冊),避免惡意者迅速嘗試功能,所以須要一個延時響應的中間件,代碼以下:

`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'``,`
`};`
`});`
複製代碼

經過此中間件,能夠限制某些功能的響應時長(保證每次處理時間都大於指望值),須要注意的是,延時響應的不要超過全局的超時配置。

接口性能統計

系統是否穩定,性能是否須要優化等都依賴於統計,爲了能及時反應出系統狀態,並方便添加告警指標,我將相關的統計數據寫入influxdb,主要指標以下:

tags:

  • method,請求類型
  • type,根據響應狀態碼分組,1xx -> 1, 2xx -> 2
  • spdy,根據自定義的響應時間劃分區間,方便將接口響應時間分組
  • route,接口路由

fields:

  • connecting,處理請求數
  • use,處理時長
  • bytes,響應數字長度
  • code,響應狀態碼
  • url,請求地址
  • ip,用戶IP

在influxdb中,tags可用於對數據分組,根據 type 將接口請求分組,將 4 與 5 的單獨監控,能夠簡單快速的把當前接口出錯彙總。統計中間件代碼以下:

`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) {`
`// 出錯時狀態碼從error中獲取`
`status = err.status;`
`throw` `err;`
`} finally {`
`// 若是非出錯,則從ctx中取狀態碼`
`if` `(!status) {`
`status = ctx.status;`
`}`
`const use = Date.now() - start;`
`connecting--;`
`tags.route = ctx._matchedRoute;`
`tags.type = `${status / 100 | 0}``
`let spdy = 0;`
`// 確認處理時長所在區間`
`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;`
`// 統計數據寫入統計系統(如influxdb)`
`console.info(tags);`
`console.info(fields);`
`}`
歡迎加入全棧開發交流划水交流圈:582735936
面向划水1-3年前端人員
幫助突破划水瓶頸,提高思惟能力

`};`
`}`
`app.use(stats());`
`router.post(``'/users/v1/:type'``, async (ctx) => {`
`await` `new` `Promise(resolve => setTimeout(resolve, 100))`
`ctx.body = {`
`account:` `'vicanso'``,`
`};`
`});`
複製代碼

接口全日誌記錄

爲了方便排查問題,須要將接口的相關信息輸出至日誌中,中間件的實現以下:

`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 的實現,將一些隱私數據作***處理。須要注意的是,因爲部分接口的body響應體部分較大,是否須要將全部數據都輸出至日誌最好根據實際狀況衡量。如可根據HTTP Method過濾,或者根據url規則等。

參數校驗

因爲javascript的弱類型,接口參數校驗一直是要求最嚴格的一點,而在瞭解過 joi 以後,我就一直使用它來作參數校驗,如註冊功能,帳號、密碼爲必選參數,而郵箱爲可選,接口校驗的代碼以下:

`function` `validate(data, schema) {`
`const result = Joi.validate(data, schema);`
`if` `(result.error) {`
`// 出錯可建立自定義的校驗出錯類型`
`throw` `result.error;`
`}`
`return` `result.value;`
`}`
歡迎加入全棧開發交流划水交流圈:582735936
面向划水1-3年前端人員
幫助突破划水瓶頸,提高思惟能力

`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簡單快捷實現了參數的校驗,不過在實際使用中,有部分的參數校驗規則是通用的,如帳號、密碼這些的校驗規則在註冊和登陸中都經過,可是有些接口是可選,有一些是必須,怎麼才能更通用一些呢?代碼調整以下:

`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,`
`};`
`});`
複製代碼

經此調整後,將用戶參數校驗的基本規則都定義在 userSchema 中,每一個接口在各自的場景下選擇不一樣的參數以及增長規則,提升代碼複用率以及校驗準確性。


相關文章
相關標籤/搜索