美文网首页
“真实世界”全栈开发-3.6-整合验证逻辑

“真实世界”全栈开发-3.6-整合验证逻辑

作者: 桥头堡2015 | 来源:发表于2018-02-07 09:44 被阅读26次

我们规定了涉及到用户身份验证的端点,创建了用户数据的模型,也定义好了负责验证的中间件。换句话说,所有有关用户身份验证的组件,我们都有了。这一讲里我们先实现后端应用中仅需要这些组件的功能:

  • 用户注册:POST /api/users,必需验证。只要邮箱和用户名格式合法且都没有被占用,则在数据库中新建用户文档,并回复该新用户的带有JWT令牌的数据(User对象)
  • 用户登录:POST /api/users/login,无需验证。如果邮箱/密码对通过,则返回User对象。
  • 获取用户对象并更新令牌:GET /api/user,必需验证。如果Authentication请求头中带有合法的令牌,则返回User对象(内含更新过的令牌)。
  • 更新用户信息:PUT /api/user,必需验证。

这一讲的新概念也比较多。为了循序渐进地引入新内容,我们先实现“更新用户信息”,再实现“获取用户对象并更新令牌”,把注册和登录的功能留到最后。

Router对象

打开routes/api/index.js。查看下已有的代码:

var router = require('express').Router();

module.exports = router;

router是一个Express的Router对象。这样的对象代表一套具有相同根URL的路由。所谓“路由”,简单理解,就是从端点到处理逻辑的映射。所以,我们可以将一个Router对象想象成一个Map,它虽然没有实现Map的接口,但是它在概念上代表的就是从(HTTP_METHOD, URL)到中间件的一组映射。

打开routes/index.js,其内容为:

var router = require('express').Router();

router.use('/api', require('./api'));

module.exports = router;

可以看到,这里也新建了一个Router对象,并且从routes/api/index.js中导入了它所定义的路由,并设定其根URL为/api

同样地,打开app.js,我们可以找到这么一行代码:

app.use(require('./api'));

这表明定义在routes/index.js中的Router对象加入到了后端应用中。

更多有关Express路由和Router对象的信息,请阅读这篇文档

更新用户信息

新建routes/api/user.js文件,写入以下内容:

const router = require('express').Router();
const auth = require('../auth');
const mongoose = require('mongoose');
const User = mongoose.model('User');

// 更新用户信息
router.put('/', auth.required, (req, res, next) => {
  User.findById(req.payload.id).then(user => {
    if (!user)
      return res.status(410).json({errors: {user_id: [`${req.payload.id}`, 'gone']}});

    ['username', 'email', 'bio', 'image', 'password'].forEach(propName => {
      if (!req.body.user.hasOwnProperty(propName) return;
      // 只更新请求体中确实包含的字段
      if (propName === 'password')
        return user.setPassword(req.body.user.password);
      user[propName] = req.body.user[propName];
    });

    return user.save().then(() => {
      return res.json({user: user.toAuthJSON()});
    });
  }).catch(next);
});

module.exports = router;

router.put('/', callbacks)表示当这套路由收到HTTP方法为PUT,子URL为/的请求时(假设这套路由的根URL为/api/user,则完整的为/api/user),应用就会按顺序调用callbacks中的中间件。auth.required是上一讲里实现的验证令牌必须存在的中间件,当这一步验证成功的话,后面用箭头函数形式定义的中间件就会被调用;否则接下来被调用的就是其后最近的处理错误的中间件(详情见下)。

用户的身份验证成功后,我们用User.findById来载入与令牌相对应的用户对象。如果它返回的Promise没有被拒绝,不过其兑现的user是个假值(falsy value),说明传入的令牌确实是我们的后端签发的,但是其中包含的用户ID(req.payload.id)却在数据库中找不到。这只可能是一种情况:令牌签发之后该用户删除了自己的账户。这是非常边缘的情况:事实上我们的应用不会实现删除账户的功能。不过,为了逻辑的完整,我们还是为其返回合适的410响应。

更新用户文档时,我们使用了Object.prototype.hasOwnProperty来确保后端只更新前端传过来的字段,免得不小心把用户的用户名或者密码设置成null或者undefined。而验证每个字段的值是否“合法”,我们依靠的是定义在用户模式中的选项:比如必有非空的用户名和密码等等。user.save()返回一个promise。如果这个promise兑现了,说明这个用户文档成功地更新了;如果它被拒绝了,说明验证不通过,Mongoose会抛出ValidationError错误,这时候后端默认地会给前端返回500响应。但这不是我们想要的,所以我们在catch中把传给最近的处理错误的中间件。在这个中间件里,我们需要为验证错误返回前端能够理解的信息。

我们两次提到了处理错误的中间件。它到底是什么?说白了就是签名为(err, req, res, next)=>void的特殊中间件。在某个中间件里,如果调用next时传入一个参数,那么Express就会明白有错误发生了,并且这个参数就是代表该错误的对象,并跳过所有正常的(不能处理错误的)中间件,把错误对象传给后面最近的参数个数为4的那个中间件。对Express中间件的详细讲解,请阅读我的这篇博文

处理Mongoose验证错误的中间件

routes/api/index.js末尾加入如下代码:

// +++
// 处理Mongoose验证错误的中间件
const validationErrorHandler = (err, req, res, next) => {
  if (err.name === 'ValidationError') {
    return res.status(422).json({
      errors: Object.keys(err.errors).reduce((errors, key) => {
        errors[key] = err.errors[key].message;
        return errors;
      }, {})
    });
  }

  return next(err);
};

router.use(validationErrorHandler);
// +++

module.exports = router;

上面的validationErrorHandler就是一个典型的Express处理错误的中间件。它只能处理Mongoose抛出的数据验证错误('ValidationError'),所以在最后它把错误对象传递给下一个处理错误的中间件(如果存在的话),让它们去处理别的情况。

从上面的讲解可以看出,处理错误的中间件最好位于中间件栈的最后部分。如果有一个正常的中间件位于整个栈的最后,那它抛出的错误就不会有后续的中间件来处理(这时候,如前面所说的,后端会返回500响应)。所以我们在module.exports语句之前才把validationErrorHandler加入到中间件栈里去。

获取用户对象并更新令牌

如果上面的代码和概念都弄明白了,那么这个功能实现起来就非常简单了。

回到routes/api/user.js,加入如下的代码:

const User = mongoose.model('User');

// +++
// 获取用户对象并更新令牌
router.get('/', auth.required, (req, res, next) => {
  User.findById(req.payload.id).then(user => {
    if (!user)
      return res.status(410).json({errors: {user_id: [`${req.payload.id}`, 'gone']}});

    return res.json({user: user.toAuthJSON()});
  }).catch(next);
});
// +++

// 更新用户信息
// ...

眼尖的读者能够发现,上面的代码,比起上一个功能,没有任何新东西,只是重复了其头尾的两段逻辑而已。有重复的代码,我们就可以把它们提取出来,如下所示。

const User = mongoose.model('User');

// +++
const loadCurrentUser = (req, res, next) => {
  User.findById(req.payload.id)
    .then(user => {
      if (!user)
        return res.status(410).json({errors: {user_id: [`${req.payload.id}`, 'gone']}});
      res.locals.user = user;
      next();
    })
    .catch(next)
};

const respondUser = (req, res, next) => {
  return res.json({user: res.locals.user.toAuthJSON()});
};
// +++

// ***
// 获取用户信息并更新令牌
router.get('/', auth.required, loadCurrentUser, respondUser);

// 更新用户信息
router.put('/', auth.required, loadCurrentUser, (req, res, next) => {
  const user = res.locals.user;

  ['username', 'email', 'bio', 'image', 'password'].forEach(propName => {
    if (!req.body.user.hasOwnProperty(propName)) return;
    // 只更新请求体中确实包含的字段
    if (propName === 'password')
      return user.setPassword(req.body.user.password);
    user[propName] = req.body.user[propName];
  });

  user.save().then(() => {
    return next();
  }).catch(next);
}, respondUser);

module.exports = {
  router,
  loadCurrentUser,
  respondUser
};
// ***

上面的代码把“从数据库中载入用户对象”以及“最后返回用户JSON对象给前端”这两段逻辑提取出来,实现为各自的中间件loadUserrespondUser。值得注意的是,成功地载入用户对象user后,我们需要把它存到res.locals中,以供后面的中间件使用。

通过之前和上面的代码,我相信读者们也能看明白Express中间件的好处:灵活、可复用性强、用来生成中间件的插件多。

另外,我们同时也导出上面新加的两个中间件,方便后面会别的模组使用。

最后,重新打开routes/api/index.js,把上面定义的“子路由”加入到“API的主路由”中:

var router = require('express').Router();

// +++
router.use('/user', require('./user').router);
// +++

// 处理Mongoose验证错误的中间件
// ...

接下来,我们实现用户注册和登录的功能。

新的Router对象

用户注册和登录对应的URI都位于/api/users之下,我们说它俩具有相同的根URL,所以可以新建一套路由来专门处理该根URI下的映射。

打开routes/api/users.js,添加如下代码:

const passport = require('passport');
// +++
const router = require('express').Router();
const mongoose = require('mongoose');
const auth = require('../auth');

const User = mongoose.model('User');
// +++

const localAuthentication = // ...

// +++
module.exports = router;
// +++

我们为/api/users下的URI新建了一个Router对象。同样地,为了让后端使用,这个Router对象还得要登记到“API的主路由”之上。

routes/api/index.js里加入下面一行代码:

router.use('/user', require('./user').router);
// +++
router.use('/users', require('./users'));
// +++

// 处理Mongoose验证错误的中间件
// ...

用户登录

因为我们之前已经实现了检查用户邮箱和密码的中间件,所以这一功能实现起来特别简单。

回到routes/api/users.js,加入下面的代码:

// +++
router.post('/login', localAuthentication);
// +++

module.exports = router;

用户注册

有了前面的知识,用户注册实现起来也不困难。唯一值得注意的是,我们需要用到前面定义的respondUser中间件。

routes/api/users.js里加入:

// +++
const respondUser = require('./user').respondUser;

router.post('/users', (req, res, next) => {
  const user = new User();

  user.username = req.body.user.username;
  user.email = req.body.user.email;
  user.setPassword(req.body.user.password);

  res.locals.user = user;

  user.save()
    .then(user => {
      res.locals.user = user;
      return next();
    })
    .catch(next);
}, respondUser);
// +++

router.post('/users/login', localAuthentication);

这里我们依旧是靠Mongoose帮忙保证不会忘数据库里插入非空且不重复的用户名、邮箱。否则,Mongoose同样抛出ValidationError,然后交由validationErrorHandler处理。

到此为止,开头提到的四个功能就全部实现了。建议这时候利用Postman测试下已有的API。对Postman不熟悉的读者,建议阅读这篇教程。测试的具体过程这里省略。

相关文章

网友评论

      本文标题:“真实世界”全栈开发-3.6-整合验证逻辑

      本文链接:https://www.haomeiwen.com/subject/efawzxtx.html