定义用户身份验证相关的路由处理程序,将逐步实现注册、登陆、访问受保护资源、用户授权、更新密码、更新个人信息、注销功能。这节主要理解token使用。
用户建模
userModel:用户存入数据库的格式
const userSchema = new mongoose.Schema({
email: {
type: String,
required: [true, 'Please provide your email'],
unique: true,
validate: [validator.isEmail, 'Please provide a valid email']//validator用来验证邮箱格式
},
password: {
type: String,
required: [true, 'Please provide a password'],
minlength: 8,
select: false
},
passwordConfirm: {
type: String,
required: [true, 'Please confirm your password'],
validate: {
// This only works on CREATE and SAVE!!!
validator: function(el) {
return el === this.password;
},
message: 'Passwords are not the same!'
}
}
//...
});
注意:因为要使用this,这里的回调没有使用箭头函数:
mongoose中间件:
userSchema.pre('save',function(next){//... next();})
userSchema.pre(/^find/, function(next) {//... next();})
定义实例对象上的方法:
userSchema.methods.methodName = function(){//...}
基于JSON Web Token的身份验证
token验证过程
假设一个已经注册过的用户要登陆,基于令牌的登录验证时序图如下所示:
-
用户输入用户名/邮箱、密码,Client发出POST请求
-
Server接受,检查user是否存在,password是否正确,若通过,为该用户创建一个唯一的令牌JWT,其本质是一串加密字符,注意,该令牌并没有保存在Server上
-
Server发送JWT给Client
-
用户拿到JWT,储存在缓存Cookie或者本地localStorage
-
用户有了token,相当于有了授权。再次进行需要认证的操作时,Client会通过请求发送这个token 给 Server
-
Server 验证用户token是否有效。具体方法:通过这个 token 的首部和载荷,并结合Secret生成测试签名Test Signature,通过Test Signature和 token中的 Original Signature部分比较,来确认
-
Server向Client返回受保护的资源
JWT
可以把JWT想象成进入应用被保护部分的通行证。JWT由三个部分组成:header,payload,signature
- 生成: 根据首部、载荷和储存在Server上的密钥Secret生成签名,签名再结合首部、载荷生成JWT
- 验证:根据JWT解码出首部、载荷,再结合密钥Secret生成测试签名,验证测试签名是否与原token的签名相同
工程实践
bcrypt库
不以明文形式将用户密码储存进数据库。用到一个流行的哈希算法——bcrypt用来生成密码的哈希值,去避免暴力攻击。
npm install bcrypt
saltRounds越大,加密越强,但是设置过大会使计算机计算时间延长,适度取值。
tips:Schema的pre方法定义了一个document级别的中间件,'save'表示在接收POST数据之后,写入数据库
userModel.create()或user.save()之前,执行事件处理回调,正好用于密码的加密处理,在userModel.js中定义该方法。
jsonwebtoken库
安装jsonwebtoken 库,它允许我们用jwt.sign(payload, secretOrPrivateKey, [options, callback])生成Json Web Token
$ npm install jsonwebtoken
Secret定义在环境变量的配置文件config.env中,至少要32位。
创建并发送令牌
Cookie
- expires
- httpOnly
- secure
项目功能实现
singup 注册
实现用户注册。
前端表现为用户根据Schema录入个人信息,点击创建,向server发送POST请求;后端创建用户document存入数据库,根据用户id(payload)、SECRET,生成并返回token。
代码实现
const User = require('./../models/userModel');
const signup = catchAsync(async (req, res, next) => {
const newUser = await User.create({
email: req.body.email,
password: req.body.password
});
createSendToken(newUser, 201, res);
});
login 登录
实现注册过的用户登录。
前端表现为用户输入邮箱和密码,点击登录,向server发送POST请求;后端根据请求体信息
- 验证是否输入了邮箱/密码
- 输入的邮箱是否存在,存在的话对应密码是否正确。
- 1、2都通过,创建并发送token给用户 代码实现
-
定义Schema时,用户的password,select属性设置了false,默认 查询(query) 结果是不显示的,在这里用
findOne({ email }).select('+password')设置其可见,然后验证。 -
在数据库储存的不是明文密码,而是密码的哈希值。加密的密码和明文密码如何比较?这里用到
bcrypt.compare()方法,在userSchema.methods定义中间件。
protect 保护路由
实现让登录的用户访问受保护的路由。比如之前我们写的getAllUsers请求,希望只有登录的管理员可以访问到所有用户信息。
- 拿到token
req.header形式 :
token验证
// 2) Verification token
const decoded = await promisify(jwt.verify)(token, process.env.JWT_SECRET);
若签名有效,返回由
- 载荷(用户id)
- token生成时间issue at time
- 过期时间expiration 构成的对象。现在有了用户id,事情就很简单了。以上时间格式由Unix时间戳形式表示。
- 检查token对应用户是否仍然存在,如果用户注销了账号,旧的token将不能使用
// 3) Check if user still exists
const currentUser = await User.findById(decoded.id);
if (!currentUser) {
return next(new AppError('该用户已注销', 401));
}
- 检查token颁发之后用户是否改变了密码。若token泄露,用户修改了密码,旧的token将不能使用。需要修改userModel:
- 增加属性。
passwordChangedAt: Date,记录修改密码的时间
- 增加方法。比较token生成时间和用户修改密码时间:
// 4) Check if user changed password after the token was issued
if (currentUser.changedPasswordAfter(decoded.iat)) {
return next(new AppError('用户更改了密码,请重新登陆', 401));
}
传递用户信息
在请求体中增加当前用户信息,继续通过中间件传递
最后,只需要将保护路由添加在获取资源的路由处理程序之前:
express.Router()
.route('/')
.get(authController.protect, tourController.getAllTours)
isLoggedIn 条件渲染前端页面
与保护路线很像,但它是用于全部视图路由的。实现登陆和未登录用户页面显示的差异:
restrictTo 用户授权
实现登陆的特定角色的用户可以使用特定功能。如对于一个旅游网站,授权“管理员”可以“删除旅游资源”,而“普通用户”不可以;又如一个社区网站,可以有“贡献者”,“成员”等多种身份。 属性,默认设置为普通用户。
role: {
type: String,
enum: ['user', 'guide', 'lead-guide', 'admin'],
default: 'user'
},
- 修改delete路由的中间件,将访问受保护资源、身份授权加入deleteTour事件回调之前。在restrictTo实现判断当前用户是否有权限进行delete操作
router
.route('/:id')
.delete(
authController.protect,//访问受保护资源
authController.restrictTo('admin','lead-guide'),//对管理员授权
tourController.deleteTour//删除旅游资源
);
因为身份授权需要参数,所以需要用wrapper函数传递允许操作的角色(知识点:闭包的使用),返回一个中间件
tips:数组方法Array.includes
exports.restrictTo = (...roles) => {
return (req, res, next) => {
// roles ['admin', 'lead-guide']. role='user'
if (!roles.includes(req.user.role)) {
return next(
new AppError('您没有操作权限', 403)
);
}
next();
};
};
根据role条件渲染前端页面
用户更改信息
updateMyPassword 更改密码
实现登陆用户修改密码。
前端表现为用户提供当前密码和新密码、密码确认,数据库更新用户密码。
router.patch(
'/updateMyPassword',
authController.protect,
authController.updatePassword
);
关于为什么不能用findByIdAndUpdate
- passwordConfirm的validator将不会起作用,找不到this,当前对象并没有在内存里,validator只有在create和save方法下才起作用
- 这样就不会调用我们之前的pre方法,存入的将是未加密的密码,也不会有密码改变的时间戳
代码实现
const updatePassword = catchAsync(async (req, res, next) => {
// 1) Get user from collection
const user = await User.findById(req.user.id).select('+password');
// 2) Check if POSTed current password is correct
if (!(await user.correctPassword(req.body.passwordCurrent, user.password))) {
return next(new AppError('Your current password is wrong.', 401));
}
// 3) If so, update password
user.password = req.body.password;
user.passwordConfirm = req.body.passwordConfirm;
await user.save();
// User.findByIdAndUpdate will NOT work as intended!
// 4) Log user in, send JWT
createSendToken(user, 200, res);
});
updateMe 更新用户信息
实现登录用户修改邮箱或用户名。
tips:获取Object的key,forEach
loggot 退出登录状态
实现登录用户退出登录。只需要设置cookie中的token为随机字符串。
logout = (req, res) => {
res.cookie('jwt', 'loggedout', {
expires: new Date(Date.now() + 10 * 1000),
httpOnly: true
});
res.status(200).json({ status: 'success' });
};
deleteMe 注销
实现登陆用户注销。实际上并没有在数据库中删除,而是将Schema中active状态设为不活跃。
deleteMe = catchAsync(async (req, res, next) => {
await User.findByIdAndUpdate(req.user.id, { active: false });
res.status(204).json({
status: 'success',
data: null
});
});
安全
速率限制防止暴力攻击
Express build-in模块:express-rate-limit
const limiter = rateLimit({
max: 100,
windowMs: 60 * 60 * 1000,
message: 'Too many requests from this IP, please try again in an hour!'
});
app.use('/api', limiter);
超过设置的规定时间内请求次数,返回429 Too Many Requests
设置HTTP安全头信息
npm i helmet
数据清洗
NoSql 查询注入
npm i express-mongo-sanitize
XSS 跨站点脚本攻击
npm i xss-clean