【SECTION 10】从用户建模到用户身份验证,授权和安全

667 阅读7分钟

定义用户身份验证相关的路由处理程序,将逐步实现注册、登陆、访问受保护资源、用户授权、更新密码、更新个人信息、注销功能。这节主要理解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验证过程

假设一个已经注册过的用户要登陆,基于令牌的登录验证时序图如下所示: image.png

  1. 用户输入用户名/邮箱、密码,Client发出POST请求

  2. Server接受,检查user是否存在,password是否正确,若通过,为该用户创建一个唯一的令牌JWT,其本质是一串加密字符,注意,该令牌并没有保存在Server上

  3. Server发送JWT给Client

  4. 用户拿到JWT,储存在缓存Cookie或者本地localStorage

  5. 用户有了token,相当于有了授权。再次进行需要认证的操作时,Client会通过请求发送这个token 给 Server

  6. Server 验证用户token是否有效。具体方法:通过这个 token 的首部和载荷,并结合Secret生成测试签名Test Signature,通过Test Signature和 token中的 Original Signature部分比较,来确认

  7. Server向Client返回受保护的资源

JWT

可以把JWT想象成进入应用被保护部分的通行证。JWT由三个部分组成:header,payload,signature image.png

  • 生成: 根据首部、载荷和储存在Server上的密钥Secret生成签名,签名再结合首部、载荷生成JWT
  • 验证:根据JWT解码出首部、载荷,再结合密钥Secret生成测试签名,验证测试签名是否与原token的签名相同 image.png

工程实践

bcrypt库

不以明文形式将用户密码储存进数据库。用到一个流行的哈希算法——bcrypt用来生成密码的哈希值,去避免暴力攻击。

npm install bcrypt

saltRounds越大,加密越强,但是设置过大会使计算机计算时间延长,适度取值。 image.png

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

image.png Secret定义在环境变量的配置文件config.env中,至少要32位。

image.png

创建并发送令牌

Cookie

  • expires
  • httpOnly
  • secure image.png image.png

项目功能实现

singup 注册

实现用户注册

前端表现为用户根据Schema录入个人信息,点击创建,向server发送POST请求;后端创建用户document存入数据库,根据用户id(payload)、SECRET,生成并返回token。

注册.png 代码实现

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请求;后端根据请求体信息

登录成功弹窗显示.png

  1. 验证是否输入了邮箱/密码
  2. 输入的邮箱是否存在,存在的话对应密码是否正确。
  3. 1、2都通过,创建并发送token给用户 代码实现

image.png

  • 定义Schema时,用户的password,select属性设置了false,默认 查询(query) 结果是不显示的,在这里用findOne({ email }).select('+password')设置其可见,然后验证。

  • 在数据库储存的不是明文密码,而是密码的哈希值。加密的密码和明文密码如何比较?这里用到bcrypt.compare()方法,在userSchema.methods定义中间件。

image.png

protect 保护路由

实现让登录的用户访问受保护的路由。比如之前我们写的getAllUsers请求,希望只有登录的管理员可以访问到所有用户信息。

  1. 拿到token req.header形式 : image.png

image.png

token验证

  1. 验证拿到的token 使用jwt.verify(token, secretOrPublicKey, [options, callback])方法
  // 2) Verification token
  const decoded = await promisify(jwt.verify)(token, process.env.JWT_SECRET);

image.png

若签名有效,返回由

  • 载荷(用户id)
  • token生成时间issue at time
  • 过期时间expiration 构成的对象。现在有了用户id,事情就很简单了。以上时间格式由Unix时间戳形式表示。
  1. 检查token对应用户是否仍然存在,如果用户注销了账号,旧的token将不能使用
  // 3) Check if user still exists
  const currentUser = await User.findById(decoded.id);
  if (!currentUser) {
    return next(new AppError('该用户已注销', 401));
  }

  1. 检查token颁发之后用户是否改变了密码。若token泄露,用户修改了密码,旧的token将不能使用。需要修改userModel:
  • 增加属性。passwordChangedAt: Date,记录修改密码的时间

image.png

  • 增加方法。比较token生成时间和用户修改密码时间:

image.png

  // 4) Check if user changed password after the token was issued
  if (currentUser.changedPasswordAfter(decoded.iat)) {
    return next(new AppError('用户更改了密码,请重新登陆', 401));
  }

传递用户信息

在请求体中增加当前用户信息,继续通过中间件传递 image.png 最后,只需要将保护路由添加在获取资源的路由处理程序之前:

 express.Router()
  .route('/')
  .get(authController.protect, tourController.getAllTours)

isLoggedIn 条件渲染前端页面

与保护路线很像,但它是用于全部视图路由的。实现登陆和未登录用户页面显示的差异: image.png 未登录用户首页.png image.png image.png image.png

image.png

image.png

restrictTo 用户授权

实现登陆的特定角色的用户可以使用特定功能。如对于一个旅游网站,授权“管理员”可以“删除旅游资源”,而“普通用户”不可以;又如一个社区网站,可以有“贡献者”,“成员”等多种身份。 属性,默认设置为普通用户。

  role: {
    type: String,
    enum: ['user', 'guide', 'lead-guide', 'admin'],
    default: 'user'
  },
  1. 修改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条件渲染前端页面

登录用户个人中心.png

管理员个人中心.png image.png

用户更改信息

updateMyPassword 更改密码

实现登陆用户修改密码

前端表现为用户提供当前密码和新密码、密码确认,数据库更新用户密码。

router.patch(
  '/updateMyPassword',
  authController.protect,
  authController.updatePassword
);

关于为什么不能用findByIdAndUpdate

  1. passwordConfirm的validator将不会起作用,找不到this,当前对象并没有在内存里,validator只有在create和save方法下才起作用
  2. 这样就不会调用我们之前的pre方法,存入的将是未加密的密码,也不会有密码改变的时间戳

image.png 代码实现

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 更新用户信息

实现登录用户修改邮箱或用户名image.png

tips:获取Object的key,forEach

image.png

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

安全

image.png

速率限制防止暴力攻击

Express build-in模块:express-rate-limit

image.png

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

image.png

XSS 跨站点脚本攻击

npm i xss-clean