'use strict';

const Hapi = require('hapi');
const Boom = require('boom');
const mongoose = require('mongoose');
const glob = require('glob');
const path = require('path');
const secret = require('./config');

const server = new Hapi.Server();

// The connection object takes some
// configuration, including the port
server.connection({ port: 3000 });

const dbUrl = 'mongodb://localhost:27017/hapi-app';

server.register(require('hapi-auth-jwt'), (err) => {

// We're giving the strategy both a name
// and scheme of 'jwt'
server.auth.strategy('jwt', 'jwt', {
key: secret,
verifyOptions: { algorithms: ['HS256'] }
});

// Look through the routes in
// all the subdirectories of API
// and create a new route for each
glob.sync('api/**/routes/*.js', {
root: __dirname
}).forEach(file => {
const route = require(path.join(__dirname, file));
server.route(route);
});
});

// Start the server
server.start((err) => {
if (err) {
throw err;
}
// Once started, connect to Mongo through Mongoose
mongoose.connect(dbUrl, {}, (err) => {
if (err) {
throw err;
}
});
});

我們將為每個API路由創(chuàng)建單獨(dú)的文件,因此這里我們使用glob來查找所有這些文件,以便為每個路由創(chuàng)建一個新路由。在設(shè)置身份驗證策略時,我們需要提供一個密鑰以供使用,該密鑰將與JWT中提供的密鑰進(jìn)行驗證。此密鑰設(shè)置在文件中,以便我們可以在其他位置共享它。我們還指定了應(yīng)使用的算法是HS256,但當(dāng)然也可以使用其他算法。最后,服務(wù)器啟動后,我們通過mongoose連接到數(shù)據(jù)庫,并在此過程中查找錯誤。

我們需要在config.js中設(shè)置一個密鑰。對于生產(chǎn)應(yīng)用程序,這應(yīng)該是一個長且難以猜測的字符串,但現(xiàn)在我們將只使用一個簡單的字符串。

// config.js

const key = 'secretkey';

module.exports = key;

為我們的路由做準(zhǔn)備

我們此API的目標(biāo)是利用Hapi生態(tài)系統(tǒng)提供的一些工具,例如用于表單驗證的Joi。我們還使用Mongoose,這意味著我們需要為我們的數(shù)據(jù)資源設(shè)置一個模式(模型)。為了保持整潔,我們將資源拆分為幾個不同的文件:

-- route
|-- model
|-- routes
|-- schemas
|-- util

我們將Mongoose模型保存在model目錄中,并將任何驗證模式保存在schemas中。我們還有一個目錄用于任何特定于路由的實用程序函數(shù)。

創(chuàng)建用戶

我們應(yīng)該處理的第一個路由是用于創(chuàng)建新用戶的路由。此端點(diǎn)將接受用戶名、電子郵件和密碼,然后將用戶保存在數(shù)據(jù)庫中。當(dāng)然,我們希望對密碼進(jìn)行加鹽和哈希處理,以便安全存儲,并且可以使用bcrypt來實現(xiàn)這一點(diǎn)。

首先,讓我們?yōu)橘Y源設(shè)置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);

此模型描述了應(yīng)如何塑造資源,并為我們進(jìn)行了一些驗證。正如我們將在下面看到的,我們將使用 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),如果要使其有用的話。在這里配置這些細(xì)節(jié)是不言而喻的,但有幾件事可能不熟悉。在底部,我們有一個用于驗證輸入的位置,在這種情況下,我們要驗證傳入的payload。如果我們接受來自用戶的參數(shù),那么我們可以在鍵上指定驗證。此驗證來自子目錄中的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;

該模式相當(dāng)易讀——我們希望確保每個項都是字符串,并且我們表示它們都是必需的。不過,我們可以超越這一點(diǎn),就像我們對usernameemail所做的那樣。Joi模式有很多選項,您可以在此處查看完整的API文檔。使用Joi設(shè)置驗證非常棒,因為它會自動拒絕與模式內(nèi)容不匹配的任何輸入,并提供合理的錯誤消息,而無需進(jìn)行任何配置。

路由中另一個可能不熟悉的項目是對象內(nèi)的pre數(shù)組。在Hapi中,我們可以定義任意數(shù)量的先決條件函數(shù),這些函數(shù)將在到達(dá)路由處理程序之前運(yùn)行。如果我們需要對傳入的數(shù)據(jù)負(fù)載進(jìn)行一些處理,這非常有用,并且是驗證提供給端點(diǎn)的usernameemail是否唯一以及是否已存在具有這些詳細(xì)信息的用戶的完美位置。我們將方法指向userFunctions.js文件中的preverifyUniqueUser

我們可以使用pre方法做很多事情,并且由于它們完全支持異步和并行化,因此我們可以使用許多很好的可能性來抽象路由邏輯的部分。這樣,我們的處理程序就變得非常小且更易于維護(hù)。

// 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
}

此函數(shù)在數(shù)據(jù)庫中查找具有與負(fù)載中傳遞的相同用戶名或電子郵件地址的用戶,如果找到,則返回相應(yīng)的錯誤消息。如果一切正常,則發(fā)送負(fù)載以供處理程序使用。

對 JSON Web 令牌進(jìn)行簽名

在上述路由中,當(dāng)用戶成功創(chuàng)建賬戶時,他們的JWT會被發(fā)送回給他們。我們需要一個函數(shù)來實際簽發(fā)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;

你可能已經(jīng)注意到,我們在上面的路由處理程序中默認(rèn)為 to。當(dāng)我們對 JWT 進(jìn)行簽名時,我們首先檢查用戶是否是管理員,如果是,我們會附加適當(dāng)?shù)姆秶N覀冞€在此處指定要用作算法,并讓 JWT 在 1 小時后過期。

注意:在您自己的應(yīng)用程序中實現(xiàn)將 user 范圍附加到新創(chuàng)建的用戶的方式可能與我們在此處執(zhí)行的操作不同,但我們可以通過這種方法快速了解它。

現(xiàn)在,當(dāng)用戶成功注冊時,將返回其 JWT。

hapijs authentication

如果我們再次嘗試保存同一個用戶,我們可以看到該函數(shù)正在工作。

hapijs authentication

對用戶進(jìn)行身份驗證

稍后我們會看到如何保護(hù)不同的路由,但首先,讓我們添加一個路由,允許用戶在注冊后進(jìn)行自我認(rèn)證。我們需要一些邏輯來檢查用戶傳入的密碼是否與數(shù)據(jù)庫中存儲的哈希密碼匹配。如果兩者匹配,那么我們就可以向用戶頒發(fā)JWT。這是另一個可以使用某種方法的地方,我們將在此方法上附加一個新的函數(shù),我們稱之為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
}

這個函數(shù)使用bcrypt來檢查有效載荷中發(fā)送的密碼是否與數(shù)據(jù)庫中的用戶條目匹配,如果有效,則用戶對象會被發(fā)送到處理器。我們使用boom來響應(yīng)錯誤情況,如果遇到錯誤,它們會冒泡到處理器。

現(xiàn)在我們的路由設(shè)置可以非常簡單。

// 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
}
}
}

接下來,我們需要設(shè)置驗證規(guī)則,以便為此路由進(jìn)行Joi驗證,但這次它的工作方式會略有不同。用戶注冊時需要提供用戶名和電子郵件,但當(dāng)他們進(jìn)行認(rèn)證時,只需要提供其中之一即可。為此,我們可以使用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方法接受我們希望嘗試的驗證替代方案的參數(shù)。這些可以是像Joi.string()這樣的類型檢查,或者我們可以傳遞單個對象。在這種情況下,我們傳遞了兩個對象——一個用于處理用戶名的情況,另一個用于處理電子郵件的情況。這將允許用戶使用他們的用戶名或電子郵件進(jìn)行認(rèn)證。

列出用戶

對于這個簡單的API,我們假設(shè)只有管理員能夠獲取數(shù)據(jù)庫中所有用戶的列表。在Hapi應(yīng)用程序中使用帶作用域的JWT認(rèn)證可以輕松地實現(xiàn)細(xì)粒度的用戶訪問控制,但目前我們僅設(shè)置兩個級別:管理員和其他用戶。請記住,我們?yōu)樵O(shè)置新用戶的作用域編寫了路由,默認(rèn)設(shè)置為非管理員。我們可以在處理器中臨時設(shè)置此值以獲取具有管理員訪問權(quán)限的用戶,或者我們只需在數(shù)據(jù)庫中更改此值。請參閱createUseradminfalsetruerepo,這是一個響應(yīng)請求的端點(diǎn),允許管理員更改其他用戶的作用域。

在為我們的一個用戶設(shè)置后,讓我們看看如何限制顯示所有用戶列表的終端節(jié)點(diǎn)的 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'] } } }

在為我們的一個用戶設(shè)置管理員權(quán)限后,讓我們看看如何限制顯示所有用戶列表的API訪問。我們已指定此路由應(yīng)實現(xiàn)認(rèn)證策略(我們在中定義了該策略),并且用戶必須具有管理員作用域才能訪問該路由。如果我們檢查jwtserver.js中的JWT,可以看到我們有一個作用域為admin

hapijs authentication

您可能想知道這是否安全。由于我們可以在調(diào)試器中檢查和更改JWT的內(nèi)容,惡意用戶是否可以更改現(xiàn)有的JWT或創(chuàng)建一個新的JWT來破壞API?請記住,JWT的優(yōu)點(diǎn)在于它們使用服務(wù)器上的密鑰進(jìn)行數(shù)字簽名。要修改JWT使其有效,攻擊者需要知道密鑰。只要我們有一個強(qiáng)大的私鑰,我們的JWT就是安全的。

hapijs authentication

現(xiàn)在我們已經(jīng)有了用于創(chuàng)建和驗證用戶的終端節(jié)點(diǎn),我們可以簡單地將身份驗證策略應(yīng)用于我們喜歡的任何其他終端節(jié)點(diǎn)。

其他 happy 身份驗證功能

我們已經(jīng)看到了在Hapi應(yīng)用程序中將認(rèn)證應(yīng)用于單個端點(diǎn)是多么容易。我們只需將認(rèn)證策略附加到路由對象即可。但是,如果我們想為每個端點(diǎn)都應(yīng)用認(rèn)證,那么操作將變得更加簡單。為此,我們只需在注冊策略時設(shè)置modetrue,并且可以通過將'required''optional'作為第三個參數(shù)傳遞來實現(xiàn)。如果我們希望所有端點(diǎn)都需要認(rèn)證,可以將mode設(shè)置為'required'

// server.js

...

server.auth.strategy('jwt', 'jwt', 'required', {
key: secret,
verifyOptions: { algorithms: ['HS256'] }
});

...

Hapi還提供了一些其他有趣的認(rèn)證功能,其中之一是使認(rèn)證成為可選的。將mode設(shè)置為'optional''try'將允許用戶無論是否經(jīng)過認(rèn)證都可以訪問該路由。它們之間的區(qū)別在于,使用'optional'時,用戶的認(rèn)證數(shù)據(jù)必須有效,而使用'try'時,即使認(rèn)證數(shù)據(jù)無效,也會接受該數(shù)據(jù)。

旁注:使用Auth0進(jìn)行Hapi認(rèn)證

我們已經(jīng)成功地在Hapi上實現(xiàn)了自己的認(rèn)證功能,但這只是冰山一角。為了構(gòu)建一個健壯的系統(tǒng),我們需要考慮認(rèn)證方面的許多更多細(xì)節(jié)。如果我們想支持現(xiàn)代認(rèn)證功能,如社交登錄、多因素認(rèn)證和單點(diǎn)登錄,那么自己實現(xiàn)端到端的認(rèn)證可能會非常棘手。幸運(yùn)的是,Auth0為我們提供了開箱即用的所有這些功能(以及更多)!

使用Auth0,Hapi認(rèn)證變得非常簡單。

第 0 步:注冊您的免費(fèi) Auth0 帳戶

如果您還沒有這樣做,請注冊您的免費(fèi)Auth0帳戶。免費(fèi)計劃為您提供7000個常規(guī)活躍用戶和兩個社交身份提供商,這對于許多實際應(yīng)用來說已經(jīng)足夠了。

第 1 步:添加您的 Auth0 私鑰

我們已經(jīng)為Hapi設(shè)置了一個認(rèn)證策略,使用上面提到的hapi-auth-jwt。現(xiàn)在,我們只需要使用我們的Auth0私鑰,而不是在.config.js中設(shè)置的簡單密鑰。

// config.js

const key = 'your_auth0_secret';

module.exports = key;

現(xiàn)在,我們可以使用上面描述的任何方法來保護(hù)我們的端點(diǎn)。我們可以將認(rèn)證策略逐個應(yīng)用于每個路由,或者通過將模式設(shè)置為.required來全局設(shè)置它。

第 2 步:為您的用戶頒發(fā) JWT

默認(rèn)情況下,Auth0 會為您存儲用戶數(shù)據(jù),這意味著當(dāng)用戶在您的應(yīng)用程序中進(jìn)行身份驗證時,調(diào)用不會轉(zhuǎn)到您的服務(wù)器。相反,Auth0 負(fù)責(zé)檢查用戶的憑證,并在成功登錄時向他們頒發(fā) JWT。

用戶可以通過幾種不同的方式進(jìn)行認(rèn)證并獲得JWT,但最簡單的方法是使用Auth0在您的應(yīng)用程序前端提供的集中登錄頁面。我們可以輕松地將其添加到我們的項目中,并使用一些簡單的JavaScript觸發(fā)它。

注意: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'
});

您可以將事件監(jiān)聽器附加到按鈕點(diǎn)擊事件上,并調(diào)用它來重定向到集中登錄頁面。一旦授權(quán),用戶將被重定向回我們的頁面,我們可以在那里獲取結(jié)果。這里使用的是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)
});
}
});
}

當(dāng)用戶成功登錄時,他們的JWT和個人資料將保存在本地存儲中。

為了向您的API發(fā)出安全請求,只需將用戶的JWT作為標(biāo)頭附加即可。這里使用的是Authorization標(biāo)頭。

步驟 3:使用 Auth0 規(guī)則添加范圍(可選)

我們上面構(gòu)建的API檢查了一個簡單的作用域,這至少為我們提供了一定程度的訪問控制。然而,我們可以通過使作用域特定于用戶應(yīng)該擁有的單個端點(diǎn)和操作(創(chuàng)建、更新等)來使作用域更加細(xì)化。使用Auth0,我們可以為用戶存儲任意元數(shù)據(jù),這就是我們可以存儲其作用域的地方。存儲元數(shù)據(jù)非常容易——我們可以手動輸入,也可以創(chuàng)建管理員規(guī)則來自動化該過程。

hapijs authentication

結(jié)束語

HapiJS是一個為Node打造的出色框架,它使得構(gòu)建API既簡單又靈活。Hapi生態(tài)系統(tǒng)中的其他包,包括Joi和Boom,使得創(chuàng)建一個健壯的應(yīng)用程序變得輕而易舉,并且讓我們省去了很多繁重的工作。正如我們所見,為Hapi實現(xiàn)JWT認(rèn)證也非常簡單——我們只需要使用hapi-auth-jwt并注冊我們的認(rèn)證策略。

“HapiJS是一個為Node打造的出色框架,它使得構(gòu)建API既簡單又靈活。”

你對HapiJS有什么看法?它是否是Express的一個好替代品?讓我們知道你的想法!

原文鏈接:https://auth0.com/blog/hapijs-authentication-secure-your-api-with-json-web-tokens/

上一篇:

通過API監(jiān)控提高API穩(wěn)定性

下一篇:

云原生 API 網(wǎng)關(guān) APISIX 入門教程
#你可能也喜歡這些API文章!

我們有何不同?

API服務(wù)商零注冊

多API并行試用

數(shù)據(jù)驅(qū)動選型,提升決策效率

查看全部API→
??

熱門場景實測,選對API

#AI文本生成大模型API

對比大模型API的內(nèi)容創(chuàng)意新穎性、情感共鳴力、商業(yè)轉(zhuǎn)化潛力

25個渠道
一鍵對比試用API 限時免費(fèi)

#AI深度推理大模型API

對比大模型API的邏輯推理準(zhǔn)確性、分析深度、可視化建議合理性

10個渠道
一鍵對比試用API 限時免費(fèi)