在 Node.js 中創建安全 REST API
已發表: 2022-03-11應用程序編程接口 (API) 無處不在。 它們使軟件能夠與其他軟件(內部或外部)一致地進行通信,這是可伸縮性的關鍵因素,更不用說可重用性了。
如今,在線服務擁有面向公眾的 API 非常普遍。 這些使其他開發人員能夠輕鬆集成社交媒體登錄、信用卡支付和行為跟踪等功能。 他們為此使用的事實上的標準稱為 REpresentational State Transfer (REST)。
雖然可以使用多種平台和編程語言來完成任務,例如 ASP.NET Core、Laravel (PHP) 或 Bottle (Python),但在本教程中,我們將使用以下方法構建一個基本但安全的 REST API 後端以下堆棧:
- Node.js,讀者應該已經熟悉了
- Express,它極大地簡化了在 Node.js 下構建常見 Web 服務器任務,並且是構建 REST API 後端的標準費用
- Mongoose,它將我們的後端連接到 MongoDB 數據庫
遵循本教程的開發人員也應該熟悉終端(或命令提示符)。
注意:我們不會在這裡介紹前端代碼庫,但我們的後端是用 JavaScript 編寫的,這使得在整個堆棧中共享代碼(例如對像模型)變得很方便。
REST API 剖析
REST API 用於使用一組通用的無狀態操作來訪問和操作數據。 這些操作是 HTTP 協議的組成部分,代表了基本的創建、讀取、更新和刪除 (CRUD) 功能,儘管不是以一種干淨的一對一方式:
-
POST
(創建資源或一般提供數據) -
GET
(檢索資源索引或單個資源) -
PUT
(創建或替換資源) -
PATCH
(更新/修改資源) -
DELETE
(刪除資源)
使用這些 HTTP 操作和資源名稱作為地址,我們可以通過為每個操作創建端點來構建 REST API。 通過實現該模式,我們將擁有一個穩定且易於理解的基礎,使我們能夠快速發展代碼並在之後進行維護。 如前所述,將使用相同的基礎來集成第三方功能,其中大部分功能同樣使用 REST API,從而使這種集成更快。
現在,讓我們開始使用 Node.js 創建我們的安全 REST API!
在本教程中,我們將為名為users
的資源創建一個非常常見(且非常實用)的 REST API。
我們的資源將具有以下基本結構:
-
id
(自動生成的 UUID) -
firstName
-
lastName
-
email
-
password
-
permissionLevel
(允許此用戶做什麼?)
我們將為該資源創建以下操作:
- 在端點
/users
POST
發布(創建一個新用戶) - 在端點
/users
上GET
(列出所有用戶) - 在端點
/users/:userId
上GET
(獲取特定用戶) - 端點
/users/:userId
上的PATCH
(更新特定用戶的數據) - 在端點
/users/:userId
上DELETE
(刪除特定用戶)
我們還將使用 JSON Web 令牌 (JWT) 作為訪問令牌。 為此,我們將創建另一個名為auth
的資源,它需要用戶的電子郵件和密碼,並且作為回報,將生成用於某些操作的身份驗證的令牌。 (Dejan Milosevic 關於 JWT 在 Java 中的安全 REST 應用程序的精彩文章對此進行了更詳細的介紹;原理是相同的。)
REST API 教程設置
首先,確保您安裝了最新的 Node.js 版本。 對於本文,我將使用 14.9.0 版本; 它也可能適用於舊版本。
接下來,確保您已安裝 MongoDB。 我們不會解釋這裡使用的 Mongoose 和 MongoDB 的具體細節,但要讓基礎知識運行起來,只需以交互模式(即,從命令行作為mongo
啟動服務器)而不是作為服務啟動服務器。 這是因為,在本教程的某一時刻,我們需要直接與 MongoDB 交互,而不是通過我們的 Node.js 代碼。
注意:使用 MongoDB,不需要像在某些 RDBMS 場景中那樣創建特定的數據庫。 我們的 Node.js 代碼中的第一個插入調用將自動觸發它的創建。
本教程不包含工作項目所需的所有代碼。 相反,您可以克隆伴隨的存儲庫,並在閱讀時簡單地跟隨重點 - 但如果您願意,您也可以根據需要從存儲庫中復制特定文件和片段。
導航到終端中生成的rest-api-tutorial/
文件夾。 您會看到我們的項目包含三個模塊文件夾:
-
common
(處理所有共享服務,以及用戶模塊之間共享的信息) -
users
(關於用戶的一切) -
auth
(處理 JWT 生成和登錄流程)
現在,運行npm install
(或yarn
,如果你有的話。)
恭喜,您現在擁有運行我們簡單的 REST API 後端所需的所有依賴項和設置。
創建用戶模塊
我們將使用 Mongoose,一個用於 MongoDB 的對像數據建模 (ODM) 庫,在用戶模式中創建用戶模型。
首先,我們需要在/users/models/users.model.js
中創建 Mongoose 模式:
const userSchema = new Schema({ firstName: String, lastName: String, email: String, password: String, permissionLevel: Number });
一旦我們定義了模式,我們就可以輕鬆地將模式附加到用戶模型。
const userModel = mongoose.model('Users', userSchema);
之後,我們可以使用此模型在 Express 端點中實現我們想要的所有 CRUD 操作。
讓我們從“創建用戶”操作開始,在users/routes.config.js
中定義路由:
app.post('/users', [ UsersController.insert ]);
這被拉入我們的 Express 應用程序的主index.js
文件中。 UsersController
對像是從我們的控制器導入的,我們在其中適當地散列密碼,在/users/controllers/users.controller.js
中定義:
exports.insert = (req, res) => { let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512',salt) .update(req.body.password) .digest("base64"); req.body.password = salt + "$" + hash; req.body.permissionLevel = 1; UserModel.createUser(req.body) .then((result) => { res.status(201).send({id: result._id}); }); };
此時,我們可以通過運行服務器( npm start
)並向/users
發送帶有一些 JSON 數據的POST
請求來測試我們的 Mongoose 模型:
{ "firstName" : "Marcos", "lastName" : "Silva", "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd" }
為此,您可以使用多種工具。 Insomnia(如下所述)和 Postman 是流行的 GUI 工具,而curl
是常見的 CLI 選擇。 您甚至可以只使用 JavaScript,例如,從瀏覽器的內置開發工具控制台:
fetch('http://localhost:3600/users', { method: 'POST', headers: { "Content-type": "application/json" }, body: JSON.stringify({ "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "s3cr3tp4sswo4rd" }) }) .then(function(response) { return response.json(); }) .then(function(data) { console.log('Request succeeded with JSON response', data); }) .catch(function(error) { console.log('Request failed', error); });
此時,有效帖子的結果將只是來自創建用戶的 id: { "id": "5b02c5c84817bf28049e58a3" }
。 我們還需要在users/models/users.model.js
中的模型中添加createUser
方法:
exports.createUser = (userData) => { const user = new User(userData); return user.save(); };
一切就緒,現在我們需要查看用戶是否存在。 為此,我們將為以下端點實現“按 id 獲取用戶”功能: users/:userId
。
首先,我們在/users/routes/config.js
中創建一個路由:
app.get('/users/:userId', [ UsersController.getById ]);
然後,我們在/users/controllers/users.controller.js
中創建控制器:
exports.getById = (req, res) => { UserModel.findById(req.params.userId).then((result) => { res.status(200).send(result); }); };
最後,將findById
方法添加到/users/models/users.model.js
中的模型中:
exports.findById = (id) => { return User.findById(id).then((result) => { result = result.toJSON(); delete result._id; delete result.__v; return result; }); };
響應將是這樣的:
{ "firstName": "Marcos", "lastName": "Silva", "email": "[email protected]", "password": "Y+XZEaR7J8xAQCc37nf1rw==$p8b5ykUx6xpC6k8MryDaRmXDxncLumU9mEVabyLdpotO66Qjh0igVOVerdqAh+CUQ4n/E0z48mp8SDTpX2ivuQ==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }
請注意,我們可以看到散列密碼。 對於本教程,我們將顯示密碼,但明顯的最佳做法是永遠不要洩露密碼,即使它已經過哈希處理。 我們可以看到的另一件事是permissionLevel
,我們稍後將使用它來處理用戶權限。
重複上面列出的模式,我們現在可以添加更新用戶的功能。 我們將使用PATCH
操作,因為它使我們能夠只發送我們想要更改的字段。 因此,路由將PATCH
到/users/:userid
,我們將發送我們想要更改的任何字段。 我們還需要實施一些額外的驗證,因為更改應僅限於相關用戶或管理員,並且只有管理員才能更改permissionLevel
。 我們現在將跳過它,一旦我們實現了 auth 模塊,我們就會回到它。 現在,我們的控制器看起來像這樣:
exports.patchById = (req, res) => { if (req.body.password){ let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); req.body.password = salt + "$" + hash; } UserModel.patchUser(req.params.userId, req.body).then((result) => { res.status(204).send({}); }); };
默認情況下,我們將發送一個沒有響應正文的 HTTP 代碼 204 以指示請求成功。
我們需要將patchUser
方法添加到模型中:
exports.patchUser = (id, userData) => { return User.findOneAndUpdate({ _id: id }, userData); };
用戶列表將通過以下控制器在/users/
處實現為GET
:
exports.list = (req, res) => { let limit = req.query.limit && req.query.limit <= 100 ? parseInt(req.query.limit) : 10; let page = 0; if (req.query) { if (req.query.page) { req.query.page = parseInt(req.query.page); page = Number.isInteger(req.query.page) ? req.query.page : 0; } } UserModel.list(limit, page).then((result) => { res.status(200).send(result); }) };
相應的模型方法將是:
exports.list = (perPage, page) => { return new Promise((resolve, reject) => { User.find() .limit(perPage) .skip(perPage * page) .exec(function (err, users) { if (err) { reject(err); } else { resolve(users); } }) }); };
生成的列表響應將具有以下結構:
[ { "firstName": "Marco", "lastName": "Silva", "email": "[email protected]", "password": "z4tS/DtiH+0Gb4J6QN1K3w==$al6sGxKBKqxRQkDmhnhQpEB6+DQgDRH2qr47BZcqLm4/fphZ7+a9U+HhxsNaSnGB2l05Oem/BLIOkbtOuw1tXA==", "permissionLevel": 1, "id": "5b02c5c84817bf28049e58a3" }, { "firstName": "Paulo", "lastName": "Silva", "email": "[email protected]", "password": "wTsqO1kHuVisfDIcgl5YmQ==$cw7RntNrNBNw3MO2qLbx959xDvvrDu4xjpYfYgYMxRVDcxUUEgulTlNSBJjiDtJ1C85YimkMlYruU59rx2zbCw==", "permissionLevel": 1, "id": "5b02d038b653603d1ca69729" } ]
最後要實現的部分是/users/:userId
的DELETE
。
我們的刪除控制器將是:
exports.removeById = (req, res) => { UserModel.removeById(req.params.userId) .then((result)=>{ res.status(204).send({}); }); };
和以前一樣,控制器將返回 HTTP 代碼 204 並且沒有內容正文作為確認。

對應的模型方法應該是這樣的:
exports.removeById = (userId) => { return new Promise((resolve, reject) => { User.deleteMany({_id: userId}, (err) => { if (err) { reject(err); } else { resolve(err); } }); }); };
現在我們已經完成了操作用戶資源的所有必要操作,並且我們完成了用戶控制器。 這段代碼的主要思想是為您提供使用 REST 模式的核心概念。 我們需要返回這段代碼來實現一些驗證和權限,但首先,我們需要開始構建我們的安全性。 讓我們創建 auth 模塊。
創建身份驗證模塊
在我們通過實現權限和驗證中間件來保護users
模塊之前,我們需要能夠為當前用戶生成一個有效的令牌。 我們將生成一個 JWT 以響應用戶提供有效的電子郵件和密碼。 JWT 是一個卓越的 JSON Web 令牌,您可以使用它讓用戶安全地發出多個請求,而無需重複驗證。 它通常有一個過期時間,每隔幾分鐘就會重新創建一個新令牌以保持通信安全。 但是,對於本教程,我們將放棄刷新令牌,並保持簡單,每次登錄只需一個令牌。
首先,我們將為對/auth
資源的POST
請求創建一個端點。 請求正文將包含用戶電子郵件和密碼:
{ "email" : "[email protected]", "password" : "s3cr3tp4sswo4rd2" }
在我們使用控制器之前,我們應該在/authorization/middlewares/verify.user.middleware.js
中驗證用戶:
exports.isPasswordAndUserMatch = (req, res, next) => { UserModel.findByEmail(req.body.email) .then((user)=>{ if(!user[0]){ res.status(404).send({}); }else{ let passwordFields = user[0].password.split('$'); let salt = passwordFields[0]; let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64"); if (hash === passwordFields[1]) { req.body = { userId: user[0]._id, email: user[0].email, permissionLevel: user[0].permissionLevel, provider: 'email', name: user[0].firstName + ' ' + user[0].lastName, }; return next(); } else { return res.status(400).send({errors: ['Invalid email or password']}); } } }); };
完成後,我們可以繼續使用控制器並生成 JWT:
exports.login = (req, res) => { try { let refreshId = req.body.userId + jwtSecret; let salt = crypto.randomBytes(16).toString('base64'); let hash = crypto.createHmac('sha512', salt).update(refreshId).digest("base64"); req.body.refreshKey = salt; let token = jwt.sign(req.body, jwtSecret); let b = Buffer.from(hash); let refresh_token = b.toString('base64'); res.status(201).send({accessToken: token, refreshToken: refresh_token}); } catch (err) { res.status(500).send({errors: err}); } };
儘管我們不會在本教程中刷新令牌,但控制器已設置為啟用此類生成,以便在後續開發中更輕鬆地實現它。
我們現在需要的是創建路由並在/authorization/routes.config.js
中調用適當的中間件:
app.post('/auth', [ VerifyUserMiddleware.hasAuthValidFields, VerifyUserMiddleware.isPasswordAndUserMatch, AuthorizationController.login ]);
響應將在 accessToken 字段中包含生成的 JWT:
{ "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YjAyYzVjODQ4MTdiZjI4MDQ5ZTU4YTMiLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicGVybWlzc2lvbkxldmVsIjoxLCJwcm92aWRlciI6ImVtYWlsIiwibmFtZSI6Ik1hcmNvIFNpbHZhIiwicmVmcmVzaF9rZXkiOiJiclhZUHFsbUlBcE1PakZIRG1FeENRPT0iLCJpYXQiOjE1MjY5MjMzMDl9.mmNg-i44VQlUEWP3YIAYXVO-74803v1mu-y9QPUQ5VY", "refreshToken": "U3BDQXBWS3kyaHNDaGJNanlJTlFkSXhLMmFHMzA2NzRsUy9Sd2J0YVNDTmUva0pIQ0NwbTJqOU5YZHgxeE12NXVlOUhnMzBWMGNyWmdOTUhSaTdyOGc9PQ==" }
創建令牌後,我們可以使用表單Bearer ACCESS_TOKEN
在Authorization
標頭中使用它。
創建權限和驗證中間件
我們應該定義的第一件事是誰可以使用users
資源。 這些是我們需要處理的場景:
- 公共用於創建用戶(註冊過程)。 我們不會在這種情況下使用 JWT。
- 專用於登錄用戶和管理員更新該用戶。
- 管理員專用,僅用於刪除用戶帳戶。
確定了這些場景後,我們首先需要一個中間件,該中間件始終驗證用戶是否使用有效的 JWT。 /common/middlewares/auth.validation.middleware.js
中的中間件可以很簡單:
exports.validJWTNeeded = (req, res, next) => { if (req.headers['authorization']) { try { let authorization = req.headers['authorization'].split(' '); if (authorization[0] !== 'Bearer') { return res.status(401).send(); } else { req.jwt = jwt.verify(authorization[1], secret); return next(); } } catch (err) { return res.status(403).send(); } } else { return res.status(401).send(); } };
我們將使用 HTTP 錯誤代碼來處理請求錯誤:
- 無效請求的 HTTP 401
- 帶有無效令牌的有效請求或帶有無效權限的有效令牌的 HTTP 403
我們可以使用按位與運算符(位掩碼)來控制權限。 如果我們將每個所需權限設置為 2 的冪,我們可以將 32 位整數的每一位視為單個權限。 然後,管理員可以通過將其權限值設置為 2147483647 來獲得所有權限。然後該用戶可以訪問任何路由。 作為另一個示例,權限值設置為 7 的用戶將具有對用值 1、2 和 4 位標記的角色的權限(2 的 0、1 和 2 次方)。
中間件看起來像這樣:
exports.minimumPermissionLevelRequired = (required_permission_level) => { return (req, res, next) => { let user_permission_level = parseInt(req.jwt.permission_level); let user_id = req.jwt.user_id; if (user_permission_level & required_permission_level) { return next(); } else { return res.status(403).send(); } }; };
中間件是通用的。 如果用戶權限級別和所需權限級別至少有一位一致,則結果將大於零,我們可以讓操作繼續進行; 否則,將返回 HTTP 代碼 403。
現在,我們需要將身份驗證中間件添加到/users/routes.config.js
中的用戶模塊路由:
app.post('/users', [ UsersController.insert ]); app.get('/users', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(PAID), UsersController.list ]); app.get('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.getById ]); app.patch('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(FREE), PermissionMiddleware.onlySameUserOrAdminCanDoThisAction, UsersController.patchById ]); app.delete('/users/:userId', [ ValidationMiddleware.validJWTNeeded, PermissionMiddleware.minimumPermissionLevelRequired(ADMIN), UsersController.removeById ]);
我們REST API的基本開發到此結束。 剩下要做的就是全部測試。
失眠症的運行和測試
Insomnia 是一個不錯的 REST 客戶端,有一個很好的免費版本。 最佳實踐當然是在項目中包含代碼測試並實現適當的錯誤報告,但第三方 REST 客戶端非常適合在錯誤報告和調試服務不可用時測試和實施第三方解決方案。 我們將在這裡使用它來扮演應用程序的角色,並深入了解我們的 API 發生了什麼。
要創建用戶,我們只需要將必填字段POST
到適當的端點並存儲生成的 ID 以供後續使用。
API 將使用用戶 ID 進行響應:
我們現在可以使用/auth/
端點生成 JWT:
我們應該得到一個令牌作為我們的響應:
獲取accessToken
,為其添加前綴Bearer
(記住空格),並將其添加到Authorization
下的請求標頭中:
如果我們現在已經實現了權限中間件,那麼如果我們現在不這樣做,那麼除了註冊之外的每個請求都將返回 HTTP 代碼 401。但是,有了有效的令牌,我們會從/users/:userId
獲得以下響應:
此外,如前所述,出於教育目的和簡單起見,我們展示了所有字段。 密碼(散列或其他)不應該在響應中可見。
讓我們嘗試獲取用戶列表:
驚喜! 我們收到 403 響應。
我們的用戶沒有訪問此端點的權限。 我們需要將用戶的permissionLevel
級別從 1 更改為 7(甚至 5 也可以,因為我們的免費和付費權限級別分別表示為 1 和 4。)我們可以在 MongoDB 的交互式提示下手動執行此操作,像這樣(將 ID 更改為您的本地結果):
db.users.update({"_id" : ObjectId("5b02c5c84817bf28049e58a3")},{$set:{"permissionLevel":5}})
然後,我們需要生成一個新的 JWT。
完成後,我們得到正確的響應:
接下來,讓我們通過向/users/:userId
端點發送帶有一些字段的PATCH
請求來測試更新功能:
我們期望 204 響應作為成功操作的確認,但我們可以再次請求用戶進行驗證。
最後,我們需要刪除用戶。 我們需要如上所述創建一個新用戶(不要忘記記下用戶 ID),並確保我們擁有適合管理員用戶的 JWT。 新用戶需要將他們的權限設置為 2053(即 2048- ADMIN
加上我們之前的 5)才能執行刪除操作。 完成此操作並生成新的 JWT,我們將不得不更新Authorization
請求標頭:
向/users/:userId
發送DELETE
請求,我們應該得到 204 響應作為確認。 我們可以再次通過請求/users/
列出所有現有用戶來進行驗證。
REST API 的後續步驟
使用本教程中介紹的工具和方法,您現在應該能夠在 Node.js 上創建簡單且安全的 REST API。 跳過了許多對該過程不重要的最佳實踐,所以不要忘記:
- 實施適當的驗證(例如,確保用戶電子郵件是唯一的)
- 實施單元測試和錯誤報告
- 防止用戶更改自己的權限級別
- 防止管理員刪除自己
- 防止洩露敏感信息(例如散列密碼)
- 將 JWT 機密從
common/config/env.config.js
到非回購、非基於環境的機密分發機制
讀者的最後一個練習可以是將代碼庫從使用 JavaScript Promise 轉換為 async/await 技術。
對於那些可能感興趣的人,現在還提供了該項目的 TypeScript 版本。