我们规定了涉及到用户身份验证的端点,创建了用户数据的模型,也定义好了负责验证的中间件。换句话说,所有有关用户身份验证的组件,我们都有了。这一讲里我们先实现后端应用中仅需要这些组件的功能:
- 用户注册:
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对象给前端”这两段逻辑提取出来,实现为各自的中间件loadUser
和respondUser
。值得注意的是,成功地载入用户对象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不熟悉的读者,建议阅读这篇教程。测试的具体过程这里省略。
网友评论