
使用 Axios 在 React 中創建集中式 API 客戶端文件
我們將為每個API路由創建單獨的文件,因此這里我們使用glob
來查找所有這些文件,以便為每個路由創建一個新路由。在設置身份驗證策略時,我們需要提供一個密鑰以供使用,該密鑰將與JWT中提供的密鑰進行驗證。此密鑰設置在文件中,以便我們可以在其他位置共享它。我們還指定了應使用的算法是HS256
,但當然也可以使用其他算法。最后,服務器啟動后,我們通過mongoose
連接到數據庫,并在此過程中查找錯誤。
我們需要在config.js
中設置一個密鑰。對于生產應用程序,這應該是一個長且難以猜測的字符串,但現在我們將只使用一個簡單的字符串。
// config.js
const key = 'secretkey';
module.exports = key;
我們此API的目標是利用Hapi生態系統提供的一些工具,例如用于表單驗證的Joi。我們還使用Mongoose,這意味著我們需要為我們的數據資源設置一個模式(模型)。為了保持整潔,我們將資源拆分為幾個不同的文件:
-- route
|-- model
|-- routes
|-- schemas
|-- util
我們將Mongoose模型保存在model
目錄中,并將任何驗證模式保存在schemas
中。我們還有一個目錄用于任何特定于路由的實用程序函數。
我們應該處理的第一個路由是用于創建新用戶的路由。此端點將接受用戶名、電子郵件和密碼,然后將用戶保存在數據庫中。當然,我們希望對密碼進行加鹽和哈希處理,以便安全存儲,并且可以使用bcrypt來實現這一點。
首先,讓我們為資源設置Mongoose模型。
// api/users/model/User.js
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const userModel = new Schema({
email: { type: String, required: true, index: { unique: true } },
username: { type: String, required: true, index: { unique: true } },
password: { type: String, required: true },
admin: { type: Boolean, required: true }
});
module.exports = mongoose.model('User', userModel);
此模型描述了應如何塑造資源,并為我們進行了一些驗證。正如我們將在下面看到的,我們將使用 Joi 獲得更好的驗證。
'use strict';
const bcrypt = require('bcrypt');
const Boom = require('boom');
const User = require('../model/User');
const createUserSchema = require('../schemas/createUser');
const verifyUniqueUser = require('../util/userFunctions').verifyUniqueUser;
const createToken = require('../util/token');
function hashPassword(password, cb) {
// Generate a salt at level 10 strength
bcrypt.genSalt(10, (err, salt) => {
bcrypt.hash(password, salt, (err, hash) => {
return cb(err, hash);
});
});
}
module.exports = {
method: 'POST',
path: '/api/users',
config: {
// Before the route handler runs, verify that the user is unique
pre: [
{ method: verifyUniqueUser }
],
handler: (req, res) => {
let user = new User();
user.email = req.payload.email;
user.username = req.payload.username;
user.admin = false;
hashPassword(req.payload.password, (err, hash) => {
if (err) {
throw Boom.badRequest(err);
}
user.password = hash;
user.save((err, user) => {
if (err) {
throw Boom.badRequest(err);
}
// If the user is saved successfully, issue a JWT
res({ id_token: createToken(user) }).code(201);
});
});
},
// Validate the payload against the Joi schema
validate: {
payload: createUserSchema
}
}
}
Hapi路由需要有一個路由和方法,以及一個處理程序(handler),如果要使其有用的話。在這里配置這些細節是不言而喻的,但有幾件事可能不熟悉。在底部,我們有一個用于驗證輸入的位置,在這種情況下,我們要驗證傳入的payload
。如果我們接受來自用戶的參數,那么我們可以在鍵上指定驗證。此驗證來自子目錄中的schemas
。
// api/users/schemas/createUser.js
'use strict';
const Joi = require('joi');
const createUserSchema = Joi.object({
username: Joi.string().alphanum().min(2).max(30).required(),
email: Joi.string().email().required(),
password: Joi.string().required()
});
module.exports = createUserSchema;
該模式相當易讀——我們希望確保每個項都是字符串,并且我們表示它們都是必需的。不過,我們可以超越這一點,就像我們對username
和email
所做的那樣。Joi模式有很多選項,您可以在此處查看完整的API文檔。使用Joi設置驗證非常棒,因為它會自動拒絕與模式內容不匹配的任何輸入,并提供合理的錯誤消息,而無需進行任何配置。
路由中另一個可能不熟悉的項目是對象內的pre
數組。在Hapi中,我們可以定義任意數量的先決條件函數,這些函數將在到達路由處理程序之前運行。如果我們需要對傳入的數據負載進行一些處理,這非常有用,并且是驗證提供給端點的username
和email
是否唯一以及是否已存在具有這些詳細信息的用戶的完美位置。我們將方法指向userFunctions.js
文件中的preverifyUniqueUser
。
我們可以使用pre
方法做很多事情,并且由于它們完全支持異步和并行化,因此我們可以使用許多很好的可能性來抽象路由邏輯的部分。這樣,我們的處理程序就變得非常小且更易于維護。
// api/users/util/userFunctions.js
'use strict';
const Boom = require('boom');
const User = require('../model/User');
function verifyUniqueUser(req, res) {
// Find an entry from the database that
// matches either the email or username
User.findOne({
$or: [
{ email: req.payload.email },
{ username: req.payload.username }
]
}, (err, user) => {
// Check whether the username or email
// is already taken and error out if so
if (user) {
if (user.username === req.payload.username) {
res(Boom.badRequest('Username taken'));
}
if (user.email === req.payload.email) {
res(Boom.badRequest('Email taken'));
}
}
// If everything checks out, send the payload through
// to the route handler
res(req.payload);
});
}
module.exports = {
verifyUniqueUser: verifyUniqueUser
}
此函數在數據庫中查找具有與負載中傳遞的相同用戶名或電子郵件地址的用戶,如果找到,則返回相應的錯誤消息。如果一切正常,則發送負載以供處理程序使用。
在上述路由中,當用戶成功創建賬戶時,他們的JWT會被發送回給他們。我們需要一個函數來實際簽發JWT。
// api/users/util/token.js
'use strict';
const jwt = require('jsonwebtoken');
const secret = require('../../../config');
function createToken(user) {
let scopes;
// Check if the user object passed in
// has admin set to true, and if so, set
// scopes to admin
if (user.admin) {
scopes = 'admin';
}
// Sign the JWT
return jwt.sign({ id: user._id, username: user.username, scope: scopes }, secret, { algorithm: 'HS256', expiresIn: "1h" } );
}
module.exports = createToken;
你可能已經注意到,我們在上面的路由處理程序中默認為 to。當我們對 JWT 進行簽名時,我們首先檢查用戶是否是管理員,如果是,我們會附加適當的范圍。我們還在此處指定要用作算法,并讓 JWT 在 1 小時后過期。
注意:在您自己的應用程序中實現將 user 范圍附加到新創建的用戶的方式可能與我們在此處執行的操作不同,但我們可以通過這種方法快速了解它。
現在,當用戶成功注冊時,將返回其 JWT。
如果我們再次嘗試保存同一個用戶,我們可以看到該函數正在工作。
稍后我們會看到如何保護不同的路由,但首先,讓我們添加一個路由,允許用戶在注冊后進行自我認證。我們需要一些邏輯來檢查用戶傳入的密碼是否與數據庫中存儲的哈希密碼匹配。如果兩者匹配,那么我們就可以向用戶頒發JWT。這是另一個可以使用某種方法的地方,我們將在此方法上附加一個新的函數,我們稱之為preverifyCredentials
// api/users/util/userFunctions.js
...
function verifyCredentials(req, res) {
const password = req.payload.password;
// Find an entry from the database that
// matches either the email or username
User.findOne({
$or: [
{ email: req.payload.email },
{ username: req.payload.username }
]
}, (err, user) => {
if (user) {
bcrypt.compare(password, user.password, (err, isValid) => {
if (isValid) {
res(user);
}
else {
res(Boom.badRequest('Incorrect password!'));
}
});
} else {
res(Boom.badRequest('Incorrect username or email!'));
}
});
}
module.exports = {
verifyUniqueUser: verifyUniqueUser,
verifyCredentials: verifyCredentials
}
這個函數使用bcrypt
來檢查有效載荷中發送的密碼是否與數據庫中的用戶條目匹配,如果有效,則用戶對象會被發送到處理器。我們使用boom
來響應錯誤情況,如果遇到錯誤,它們會冒泡到處理器。
現在我們的路由設置可以非常簡單。
// api/users/routes/authenticateUser.js
'use strict';
const Boom = require('boom');
const User = require('../model/User');
const authenticateUserSchema = require('../schemas/authenticateUser');
const verifyCredentials = require('../util/userFunctions').verifyCredentials;
const createToken = require('../util/token');
module.exports = {
method: 'POST',
path: '/api/users/authenticate',
config: {
// Check the user's password against the DB
pre: [
{ method: verifyCredentials, assign: 'user' }
],
handler: (req, res) => {
// If the user's password is correct, we can issue a token.
// If it was incorrect, the error will bubble up from the pre method
res({ id_token: createToken(req.pre.user) }).code(201);
},
validate: {
payload: authenticateUserSchema
}
}
}
接下來,我們需要設置驗證規則,以便為此路由進行Joi驗證,但這次它的工作方式會略有不同。用戶注冊時需要提供用戶名和電子郵件,但當他們進行認證時,只需要提供其中之一即可。為此,我們可以使用Joi的.alternatives
方法。
// api/users/schema/authenticateUser.js
'use strict';
const Joi = require('joi');
const authenticateUserSchema = Joi.alternatives().try(
Joi.object({
username: Joi.string().alphanum().min(2).max(30).required(),
password: Joi.string().required()
}),
Joi.object({
email: Joi.string().email().required(),
password: Joi.string().required()
})
);
module.exports = authenticateUserSchema;
.alternatives
方法接受我們希望嘗試的驗證替代方案的參數。這些可以是像Joi.string()
這樣的類型檢查,或者我們可以傳遞單個對象。在這種情況下,我們傳遞了兩個對象——一個用于處理用戶名的情況,另一個用于處理電子郵件的情況。這將允許用戶使用他們的用戶名或電子郵件進行認證。
對于這個簡單的API,我們假設只有管理員能夠獲取數據庫中所有用戶的列表。在Hapi應用程序中使用帶作用域的JWT認證可以輕松地實現細粒度的用戶訪問控制,但目前我們僅設置兩個級別:管理員和其他用戶。請記住,我們為設置新用戶的作用域編寫了路由,默認設置為非管理員。我們可以在處理器中臨時設置此值以獲取具有管理員訪問權限的用戶,或者我們只需在數據庫中更改此值。請參閱createUseradminfalsetruerepo
,這是一個響應請求的端點,允許管理員更改其他用戶的作用域。
在為我們的一個用戶設置后,讓我們看看如何限制顯示所有用戶列表的終端節點的 API 訪問。
// api/users/routes/getUsers.js
'use strict';
const User = require('../model/User');
const Boom = require('boom');
module.exports = {
method: 'GET',
path: '/api/users',
config: {
handler: (req, res) => {
User
.find()
// Deselect the password and version fields
.select('-password -__v')
.exec((err, users) => {
if (err) {
throw Boom.badRequest(err);
}
if (!users.length) {
throw Boom.notFound('No users found!');
}
res(users);
})
},
// Add authentication to this route
// The user must have a scope of admin
auth: {
strategy: 'jwt',
scope: ['admin']
}
}
}
在為我們的一個用戶設置管理員權限后,讓我們看看如何限制顯示所有用戶列表的API訪問。我們已指定此路由應實現認證策略(我們在中定義了該策略),并且用戶必須具有管理員作用域才能訪問該路由。如果我們檢查jwtserver.js
中的JWT,可以看到我們有一個作用域為admin
。
您可能想知道這是否安全。由于我們可以在調試器中檢查和更改JWT的內容,惡意用戶是否可以更改現有的JWT或創建一個新的JWT來破壞API?請記住,JWT的優點在于它們使用服務器上的密鑰進行數字簽名。要修改JWT使其有效,攻擊者需要知道密鑰。只要我們有一個強大的私鑰,我們的JWT就是安全的。
現在我們已經有了用于創建和驗證用戶的終端節點,我們可以簡單地將身份驗證策略應用于我們喜歡的任何其他終端節點。
我們已經看到了在Hapi應用程序中將認證應用于單個端點是多么容易。我們只需將認證策略附加到路由對象即可。但是,如果我們想為每個端點都應用認證,那么操作將變得更加簡單。為此,我們只需在注冊策略時設置mode
為true
,并且可以通過將'required'
或'optional'
作為第三個參數傳遞來實現。如果我們希望所有端點都需要認證,可以將mode
設置為'required'
。
// server.js
...
server.auth.strategy('jwt', 'jwt', 'required', {
key: secret,
verifyOptions: { algorithms: ['HS256'] }
});
...
Hapi還提供了一些其他有趣的認證功能,其中之一是使認證成為可選的。將mode
設置為'optional'
或'try'
將允許用戶無論是否經過認證都可以訪問該路由。它們之間的區別在于,使用'optional'
時,用戶的認證數據必須有效,而使用'try'
時,即使認證數據無效,也會接受該數據。
我們已經成功地在Hapi上實現了自己的認證功能,但這只是冰山一角。為了構建一個健壯的系統,我們需要考慮認證方面的許多更多細節。如果我們想支持現代認證功能,如社交登錄、多因素認證和單點登錄,那么自己實現端到端的認證可能會非常棘手。幸運的是,Auth0為我們提供了開箱即用的所有這些功能(以及更多)!
使用Auth0,Hapi認證變得非常簡單。
如果您還沒有這樣做,請注冊您的免費Auth0帳戶。免費計劃為您提供7000個常規活躍用戶和兩個社交身份提供商,這對于許多實際應用來說已經足夠了。
我們已經為Hapi設置了一個認證策略,使用上面提到的hapi-auth-jwt?,F在,我們只需要使用我們的Auth0私鑰,而不是在.config.js中設置的簡單密鑰。
// config.js
const key = 'your_auth0_secret';
module.exports = key;
現在,我們可以使用上面描述的任何方法來保護我們的端點。我們可以將認證策略逐個應用于每個路由,或者通過將模式設置為.required來全局設置它。
默認情況下,Auth0 會為您存儲用戶數據,這意味著當用戶在您的應用程序中進行身份驗證時,調用不會轉到您的服務器。相反,Auth0 負責檢查用戶的憑證,并在成功登錄時向他們頒發 JWT。
用戶可以通過幾種不同的方式進行認證并獲得JWT,但最簡單的方法是使用Auth0在您的應用程序前端提供的集中登錄頁面。我們可以輕松地將其添加到我們的項目中,并使用一些簡單的JavaScript觸發它。
注意:Auth0為所有流行的框架提供了SDK和集成示例,您可以在文檔中查看適用于您特定項目的代碼示例。
首先,將庫添加到您的前端。這里指的是auth0-js
庫。
<!-- index.html -->
...
<!-- Auth0.js script -->
<script src="https://cdn.auth0.com/js/auth0/9.0.0/auth0.min.js"></script>
<!-- Setting the right viewport -->
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
...
接下來,配置一個auth0-js
實例。
// app.js
var webAuth = new auth0.WebAuth({
domain: 'YOUR_DOMAIN',
clientID: 'YOUR_CLIENT_ID',
responseType: 'token',
redirectUri: 'YOUR_REDIRECT_URI'
});
您可以將事件監聽器附加到按鈕點擊事件上,并調用它來重定向到集中登錄頁面。一旦授權,用戶將被重定向回我們的頁面,我們可以在那里獲取結果。這里使用的是webAuth.authorize
方法。
// app.js
document.getElementById('btn-login').addEventListener('click', function() {
webAuth.authorize();
});
if (window.location.hash) {
webAuth.parseHash({ hash: window.location.hash }, function(err, authResult) {
if (err) {
return console.log(err);
}
if (authResult) {
webAuth.client.userInfo(authResult.accessToken, function(err, user) {
localStorage.setItem('userProfile', JSON.stringify(user))
localStorage.setItem('id_token', authResult.idToken)
});
}
});
}
當用戶成功登錄時,他們的JWT和個人資料將保存在本地存儲中。
為了向您的API發出安全請求,只需將用戶的JWT作為標頭附加即可。這里使用的是Authorization
標頭。
我們上面構建的API檢查了一個簡單的作用域,這至少為我們提供了一定程度的訪問控制。然而,我們可以通過使作用域特定于用戶應該擁有的單個端點和操作(創建、更新等)來使作用域更加細化。使用Auth0,我們可以為用戶存儲任意元數據,這就是我們可以存儲其作用域的地方。存儲元數據非常容易——我們可以手動輸入,也可以創建管理員規則來自動化該過程。
HapiJS是一個為Node打造的出色框架,它使得構建API既簡單又靈活。Hapi生態系統中的其他包,包括Joi和Boom,使得創建一個健壯的應用程序變得輕而易舉,并且讓我們省去了很多繁重的工作。正如我們所見,為Hapi實現JWT認證也非常簡單——我們只需要使用hapi-auth-jwt并注冊我們的認證策略。
“HapiJS是一個為Node打造的出色框架,它使得構建API既簡單又靈活?!?/p>
你對HapiJS有什么看法?它是否是Express的一個好替代品?讓我們知道你的想法!
原文鏈接:https://auth0.com/blog/hapijs-authentication-secure-your-api-with-json-web-tokens/