HapiJS 身份验证 - 使用 JWT 保护您的 API
TL;DR:长话短说:使用JWT认证来保护你的Hapi API非常容易,本文我们将探讨如何创建和认证用户,并向他们颁发JWT。我们将把用户数据存储在MongoDB中,并使用Mongoose来简化数据库交互。你可以直接访问代码仓库来查看代码。
如果你已经在NodeJS上构建了应用程序——尤其是API——那么你很可能对Express有所了解。许多人在开始学习NodeJS开发时就会接触到这个框架,并且它做得很好。正如它的口号所说,它快速、无偏见且极简。
近年来,另一个不太为人所知但逐渐受到欢迎的替代方案是HapiJS。Hapi是一个用于Node的服务器框架,它以一种有趣且优雅的方式帮助构建应用程序。它非常注重可重用性和配置,这意味着开发人员可以将更多时间花在业务逻辑上,而不是花在实现应用程序的基础设施上。
在本文中,我们将探讨如何使用HapiJS构建一个连接到MongoDB的API。我们将使用HapiJS生态系统中的一些出色工具,如Joi用于输入验证,Boom用于错误处理。我们将使用Mongoose与数据库进行交互,尽管你也可以选择跳过这一步而直接与Mongo进行交互。我们将使用jsonwebtoken包来创建JWT,并使用hapi-auth-jwt在它们作为头部到达时进行验证。
我们构建的API将主要关注身份验证。因此,我们将创建用于创建用户帐户、认证用户以及为具有管理员访问权限的用户显示已注册用户列表的端点。当用户成功认证后,他们将获得JSON Web Tokens(JWT),这些令牌可用于访问其他端点。我们将在JWT中存储一个声明,这将为我们提供一种简单的方法来实现访问控制。
HapiJS 身份验证入门
让我们从安装一些我们需要的依赖项开始。
npm install hapi joi boom hapi-auth-jwt mongoose glob --save
创建并启动一个Hapi服务器非常简单。让我们进行设置,并为数据库和身份验证策略添加配置。
// server.js
'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路由创建单独的文件,因此这里我们使用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
}
此函数在数据库中查找具有与负载中传递的相同用户名或电子邮件地址的用户,如果找到,则返回相应的错误消息。如果一切正常,则发送负载以供处理程序使用。
对 JSON Web 令牌进行签名
在上述路由中,当用户成功创建账户时,他们的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就是安全的。
现在我们已经有了用于创建和验证用户的终端节点,我们可以简单地将身份验证策略应用于我们喜欢的任何其他终端节点。
其他 happy 身份验证功能
我们已经看到了在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'
时,即使认证数据无效,也会接受该数据。
旁注:使用Auth0进行Hapi认证
我们已经成功地在Hapi上实现了自己的认证功能,但这只是冰山一角。为了构建一个健壮的系统,我们需要考虑认证方面的许多更多细节。如果我们想支持现代认证功能,如社交登录、多因素认证和单点登录,那么自己实现端到端的认证可能会非常棘手。幸运的是,Auth0为我们提供了开箱即用的所有这些功能(以及更多)!
使用Auth0,Hapi认证变得非常简单。
第 0 步:注册您的免费 Auth0 帐户
如果您还没有这样做,请注册您的免费Auth0帐户。免费计划为您提供7000个常规活跃用户和两个社交身份提供商,这对于许多实际应用来说已经足够了。
第 1 步:添加您的 Auth0 私钥
我们已经为Hapi设置了一个认证策略,使用上面提到的hapi-auth-jwt。现在,我们只需要使用我们的Auth0私钥,而不是在.config.js中设置的简单密钥。
// config.js
const key = 'your_auth0_secret';
module.exports = key;
现在,我们可以使用上面描述的任何方法来保护我们的端点。我们可以将认证策略逐个应用于每个路由,或者通过将模式设置为.required来全局设置它。
第 2 步:为您的用户颁发 JWT
默认情况下,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
标头。
步骤 3:使用 Auth0 规则添加范围(可选)
我们上面构建的API检查了一个简单的作用域,这至少为我们提供了一定程度的访问控制。然而,我们可以通过使作用域特定于用户应该拥有的单个端点和操作(创建、更新等)来使作用域更加细化。使用Auth0,我们可以为用户存储任意元数据,这就是我们可以存储其作用域的地方。存储元数据非常容易——我们可以手动输入,也可以创建管理员规则来自动化该过程。
结束语
HapiJS是一个为Node打造的出色框架,它使得构建API既简单又灵活。Hapi生态系统中的其他包,包括Joi和Boom,使得创建一个健壮的应用程序变得轻而易举,并且让我们省去了很多繁重的工作。正如我们所见,为Hapi实现JWT认证也非常简单——我们只需要使用hapi-auth-jwt并注册我们的认证策略。
“HapiJS是一个为Node打造的出色框架,它使得构建API既简单又灵活。”
你对HapiJS有什么看法?它是否是Express的一个好替代品?让我们知道你的想法!
原文链接:https://auth0.com/blog/hapijs-authentication-secure-your-api-with-json-web-tokens/