HapiJS认证–通过JWT保护您的应用编程接口


如果你已经在节点上构建了一个应用——尤其是一个应用编程接口——你很有可能已经熟悉了Express。许多人在开始学习NodeJS开发时就被引入了这个框架,而且它做得很好。正如它的口号所说,它快速、无束缚、简约。

近年来,一种不太为人所知的替代方法越来越受欢迎HapiJS。哈比神是一个帮助构建应用程序的节点服务器框架,它以一种有趣而优雅的方式实现了这一点。它非常注重可重用性和配置,这意味着开发人员可以在业务逻辑上花费更多的时间,而在实现应用程序的基础设施上花费更少的时间。

在本文中,我们将研究如何用HapiJS构建一个连接到MongoDB的应用编程接口。我们将使用HapiJS生态系统中的一些优秀工具,例如Joi用于输入验证和Boom用于错误处理。我们将使用猫鼬来与数据库交互,尽管您可能更喜欢跳过这一步,直接与猫鼬交互。我们将使用jsonwebtoken创建JWTs和hapi-auth-jwt来验证他们什么时候到达Authorization标题。

我们构建的应用编程接口将主要侧重于身份验证。因此,我们将创建用于创建用户帐户、验证用户身份以及为具有管理员访问权限的用户显示注册用户列表的端点。当用户成功通过身份验证时,他们将被颁发JSON Web Tokens (JWT)其可用于访问其他端点。我们将储存一个scope这将为我们提供一种实现访问控制的简单方法。

HapiJS认证入门

让我们从安装一些我们需要的依赖项开始。

npm install hapi joi boom hapi-auth-jwt mongoose glob --save

创建和启动哈比神服务器很容易。让我们设置一个,并为我们的数据库和身份验证策略添加配置。

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

我们将为每条应用编程接口路线准备单独的文件,所以我们在这里使用glob找到所有这些文件,这样我们就可以为每个文件创建一个新的路径。当我们设置身份验证策略时,我们需要提供一个要使用的密钥,该密钥将根据JWT提供的密钥进行验证。该键设置在config.js文件,这样我们就可以在其他地方共享它。我们还指定应该使用的算法是HS256,但是我们当然可以使用其他算法。最后,我们通过连接到我们的数据库mongoose一旦服务器启动并在运行过程中查找错误。

我们需要在config.js。对于生产应用程序来说,这应该是一个长而不可忽略的字符串,但是我们现在只使用一些简单的东西。

// config.js

const key = 'secretkey';

module.exports = key;

为我们的路线做准备

我们使用这个应用编程接口的目的是利用哈比神生态系统提供的一些工具,比如用于表单验证的Joi。我们也在使用猫鼬,这意味着我们需要为我们的数据资源建立一个模式(模型)。为了保持整洁,我们将把资源分成几个不同的文件:

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

我们将把猫鼬模型保存在model目录,以及我们可能有的任何验证模式schemas。我们还有一个util特定于路由的任何实用程序功能的目录。

创建用户

我们应该研究的第一条途径是创建新用户。该端点将接受用户名、电子邮件和密码,然后将用户保存在数据库中。当然,我们希望对密码进行加盐和散列,以便安全存储,我们可以用加密来做到这一点。

npm install bcrypt

首先,让我们为users资源。

// 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);

该模型描述了users资源应该被塑造,也确实如此一些我们的验证。正如我们将在下面看到的,我们将通过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 and assign the result to 'user'
    pre: [
      { method: verifyUniqueUser, assign: 'user' }
    ],
    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
    }
  }  
}

哈比神路线需要有一条路线methodpath至少,还有handler如果它们有用的话。在这里配置这些细节是不言自明的,但是有些事情可能是不熟悉的。在底部,我们有一个验证输入的地方,在这种情况下,我们希望验证payload进来了。如果我们接受用户的参数,那么我们可以在params钥匙。这种验证来自createUserSchemaschemas子目录。

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

这个模式非常易读——我们想确保每个项目都是一个字符串,我们说它们都是必需的。我们可以超越这一点,就像我们正在做的那样usernameemail。Joi模式有很多选项,您可以看到完整的应用编程接口文档here。用Joi设置验证是很好的,因为它会自动拒绝任何与模式不匹配的输入,并在不需要任何配置的情况下提供合理的错误消息。

路线中另一个可能不熟悉的项目是pre中的数组config对象。使用哈比神,我们可以定义在到达路由处理器之前运行的任意数量的先决函数。如果我们需要对传入的数据有效负载进行一些处理,这很好,并且是验证usernameemail提供给端点的是唯一的,并且不存在具有这些细节的用户。我们指向pre方法verifyUniqueUser在我们的userFunctions.js文件。

我们可以做很多事情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
}

该函数在数据库中查找与有效负载中传送的用户名或电子邮件地址相同的用户,如果找到一个,则返回适当的错误消息。如果一切正常,有效负载将被发送到handler使用。当我们建立了pre方法,我们可以有选择地将响应分配给一个属性,在这种情况下,我们将它分配给user。该属性在路由处理程序中可用,也是我们用来创建令牌的属性。

签署JSON网络令牌

createUser在上面的路由中,当他们成功创建帐户时,用户的JWT会被发送回给他们。我们需要一个叫做createToken签署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;

你可能已经注意到我们违约了adminfalsecreateUser上面的路由处理器。当我们签署JWT时,我们首先检查用户是否是管理员,如果是,我们附加适当的范围。我们还在这里指定了我们想要使用的HS256因为我们的算法和JWT将在一小时后过期。

注意:在您自己的应用程序中,您实现将用户范围附加到新创建的用户的方式可能与我们在这里所做的不同,但是我们可以通过这种方式快速了解它。

现在,当用户成功注册时,他们的JWT被返回。

hapijs authentication

我们可以看到verifyUniqueUser如果我们试图再次保存同一个用户。

hapijs authentication

验证用户

稍后我们将看到如何保护各种路由,但首先,让我们放入一个允许用户在注册后进行身份验证的路由。我们需要一些逻辑来检查用户传入的密码和存储在数据库中的哈希密码。如果两者匹配,那么我们可以向用户发出JWT。这是另一个我们可以使用pre方法,我们将粘贴一个名为verifyCredentials在上面。

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

现在我们需要建立我们的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;

try方法接受我们想要尝试的任何验证选项的参数。这些可以是像这样的事情Joi.string(),或者我们可以通过个人Joi物体。在本例中,我们传递了两个对象——一个用于处理username案件和其他处理email。这将允许用户使用他们的username或者email

正在列出用户

对于这个简单的应用编程接口,我们会说管理员应该是唯一能够获得数据库中所有用户列表的人。在哈比神应用程序中使用带有范围的JWT身份验证使得创建细粒度的用户访问变得容易,但是现在,我们只有两个级别:管理员和其他人。记住我们编码了我们的createUser'设置新用户的路线'admin范围至false默认情况下。我们可以将此设置为true暂时在处理程序中,以获得具有管理员访问权限的用户,或者我们可以在数据库中更改该值。看到了吗repo 对于响应的端点PATCH允许管理员为其他用户更改此范围的请求。

设置后admintrue对于我们的一个用户,让我们看看如何限制显示所有用户列表的端点的应用编程接口访问。

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

我们已经指定此路由应该实现jwt授权策略(我们在server.js),并且用户必须具有admin进入路线。如果我们inspect our JWT,我们可以看到我们有一个scopeadmin

hapijs authentication

你可能想知道这是否安全。既然我们可以在调试器中检查和更改JWT的内容,难道一个恶意用户就不能更改一个现有的JWT,或者创建一个新的可能危及应用编程接口的吗?请记住,JWTs的美妙之处在于它们在我们的服务器上用密钥进行了数字签名。为了使修改后的JWT有效,攻击者需要知道这个秘密。只要我们有一个强私钥,我们的JWT就是安全的。

hapijs authentication

现在我们已经有了用于创建和验证用户的端点,我们可以简单地将我们的验证策略应用到我们喜欢的任何其他端点。

其他哈比神认证功能

我们已经看到了在我们的哈比神应用程序中对单个端点应用身份验证是多么容易。我们只需要将授权策略附加到路由对象上,就可以开始了。但是,如果我们想为每个端点应用身份验证,那就更容易了。为此,我们只需要设置mode当我们注册这个策略时,我们可以通过传入true或者'required'作为第三个论点。

// server.js

...

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

...

哈比神还附带了一些其他有趣的身份验证特性,其中之一就是使身份验证成为可选的能力。通过'optional'或者'try'因为无论用户是否经过身份验证,该模式都允许他们访问该路由。它们之间的区别在于optional,用户的身份验证数据必须有效,而对于try,即使身份验证数据无效,它仍将被接受。

旁白:哈比神认证与授权0

我们已经成功地向哈比神推出了我们自己的认证,但这只是冰山一角。为了拥有一个健壮的系统,我们需要考虑更多关于身份验证的细节。如果我们想支持现代身份验证功能,如社交登录、多因素身份验证和单点登录,那么实现我们自己的端到端身份验证可能会很棘手。谢天谢地,Auth0为我们开箱即用地完成了所有这些(甚至更多)!

使用身份验证0,哈比神身份验证变得非常简单。

步骤0:注册您的免费授权0帐户

如果你还没有这样做,注册你的free Auth0 account。这个免费计划为你提供了7000个常规活跃用户和两个社交身份提供者,这对许多现实世界的应用来说已经足够了。

步骤1:添加您的授权0私钥

我们已经有了一个针对哈比神的认证策略,我们在上面使用hapi-auth-jwt进行了设置。我们现在需要做的就是使用我们的Auth0私钥,而不是我们在中设置的简单密钥config.js

// config.js

const key = 'your_auth0_secret';

module.exports = key;

现在我们可以用上面描述的任何方法来保护我们的端点。我们可以将授权策略单独应用于每条路由,也可以通过将模式设置为来全局设置它required

第二步:为你的用户发布联合工作流

默认情况下,授权0为您存储用户数据,这意味着当用户在您的应用程序中进行身份验证时,呼叫不会转到您的服务器。相反,身份验证0负责检查用户的凭据,并在成功登录时向他们颁发JWT证书。

您的用户可以通过几种不同的方式进行身份验证并获得JWT证书,但最简单的方法是在应用程序的前端使用现成的锁定小部件。我们可以很容易地将锁添加到我们的项目中,并用一些简单的JavaScript触发它。

注意:Auth0为所有流行的框架提供了SDK和集成示例,您可以查看docs适用于特定项目的代码示例。

首先,将锁库添加到您的前端。

  <!-- index.html -->

  ...

  <!-- Auth0Lock script -->
  <script src="https://cdn.auth0.com/js/lock-8.2.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" />

  ...

接下来,配置的实例Auth0Lock

// app.js

var lock = new Auth0Lock('YOUR_CLIENT_ID', 'YOUR_DOMAIN');

您可以将事件侦听器附加到按钮点击和调用lock.show打开锁定部件。

// app.js

document.getElementById('btn-login').addEventListener('click', function() {
  lock.show(function(err, profile, token) {
    if (err) {
      // Error callback
      console.error("Something went wrong: ", err);
    } else {
      // Success calback  

      // Save the JWT token.
      localStorage.setItem('id_token', token);
      // Save the profile
      localStorage.setItem('userProfile', JSON.stringify(profile));
    }
  });
});

当用户成功登录时,他们的JWT和个人资料将保存在本地存储中。

要对您的应用编程接口进行安全调用,只需将用户的JWT作为Authorization标题。

步骤3:添加具有授权0规则的范围(可选)

我们上面构建的应用编程接口检查一个简单的admin给我们至少某种程度的访问控制的范围。然而,通过使范围特定于我们的用户应该拥有的单个端点和操作(创建、更新等),我们可以得到比这更精细的东西。使用Auth0,我们可以为用户存储任意的元数据,这是我们可以保存他们的范围的地方。存储元数据非常简单—我们可以手动输入,也可以创建rules自动化这个过程。

hapijs authentication

包扎

HapiJS是一个非常棒的节点框架,它使得构建API变得简单而灵活。哈比神生态系统中的其他软件包,包括Joi和Boom,使得创建一个健壮的应用程序变得很容易,并且让我们不用做很多繁重的工作。正如我们已经看到的,哈比神的JWT认证也非常简单——我们只需要使用hapi-auth-jwt并注册我们的认证策略。

你对HapiJS有什么想法?这看起来像是快递的好选择吗?让我们知道!