Eggjs实践之用户注册并用JWT登录

1,196 阅读4分钟

本文不讨论jwt和session登录的优缺点,也不涉及jwt基本原理。如果有需要了解这方面的知识,可以自行google.

本文使用技术包括 mongoose, egg, jwt

1, 创建项目,安装依赖,并对组件做配置

本项目使用mongodb作为数据库,请自行安装

yarn add --global egg-init
egg-init user-register-jwt-login --type=simple
cd user-register-jwt-login
yarn add egg-mongoose egg-jwt

通过vscode打开项目,并且启用mongoose,jwt组件,plugin.js文件内容变成

module.exports = {
 mongoose: {
   enable: true,
   package: 'egg-mongoose',
 },
 jwt: {
   enable: true,
   package: 'egg-jwt',
 },
};

config.default.js中, 对mongoosejwt进行配置

const config = {
    // 一些默认组件配置
};
// 一些用户引用组件配置
const userConfig = {
    mongoose: {
      url: 'mongodb://127.0.0.1/mydb',
      options: {
        useNewUrlParser: true,
      },
    },
    // jwt传递方案,我配置了两种,header中传authorization、query中传token.
    // egg-jwt依赖koa-jwt2,具体配置可以参考koa-jwt2
    jwt: {
      secret: 'yourprivatesecrete',
      expiresIn: '1h', // 配置默认过期时间,这个在koa-jwt2并没有暴露配置项,自己添加的
      getToken(ctx) {
        console.log(ctx.cookies.get('token'));
        if (ctx.headers.authorization && ctx.headers.authorization.split(' ')[0] === 'Bearer') {
          return ctx.headers.authorization.split(' ')[1];
        } else if (ctx.query && ctx.query.token) {
          return ctx.query.token;
        }
        return null;
      },
    },
 };
 
return {
    ...config,
    ...userConfig,
};

2,创建用户模型

app路径下,创建model文件夹,并添加user.js,内容如下:

module.exports = app => {
  const mongoose = app.mongoose;
  const crypto = require('crypto');

  const Schema = mongoose.Schema;

  /**
   * User Schema
   */

  const userSchema = new Schema({
    name: { type: String, default: '' },
    email: { type: String, default: '' },
    username: { type: String, default: '' },
    hashed_password: { type: String, default: '' },
    salt: { type: String, default: '' },
    role: {
      type: Schema.Types.ObjectId,
      ref: 'Role',
    },
  });

  const validatePresenceOf = value => value && value.length;

  /**
   * Virtuals
   */

  userSchema
    .virtual('password')
    .set(function (password) {
      this._password = password;
      this.salt = this.makeSalt();
      this.hashed_password = this.encryptPassword(password);
    })
    .get(function () {
      return this._password;
    });

  /**
   * Validations
   */

  // the below 5 validations only apply if you are signing up traditionally

  userSchema.path('name').validate(function (name) {
    return name.length;
  }, 'Name cannot be blank');

  userSchema.path('email').validate(function (email) {
    return email.length;
  }, 'Email cannot be blank');

  userSchema.path('email').validate(function (email) {
    return new Promise(resolve => {
      const User = mongoose.model('User');

      // Check only when it is a new user or when email field is modified
      if (this.isNew || this.isModified('email')) {
        User.find({ email }).exec((err, users) =>
          resolve(!err && !users.length)
        );
      } else resolve(true);
    });
  }, 'Email `{VALUE}` already exists');

  userSchema.path('username').validate(function (username) {
    return username.length;
  }, 'Username cannot be blank');

  userSchema.path('hashed_password').validate(function (hashed_password) {
    return hashed_password.length && this._password.length;
  }, 'Password cannot be blank');

  /**
   * Pre-save hook
   */

  userSchema.pre('save', function (next) {
    if (!this.isNew) return next();

    if (!validatePresenceOf(this.password)) {
      next(new Error('Invalid password'));
    } else {
      next();
    }
  });

  /**
   * Methods
   */

  userSchema.methods = {
    /**
     * Authenticate - check if the passwords are the same
     *
     * @param {String} plainText
     * @return {Boolean}
     * @api public
     */

    authenticate(plainText) {
      return this.encryptPassword(plainText) === this.hashed_password;
    },

    /**
     * Make salt
     *
     * @return {String}
     * @api public
     */

    makeSalt() {
      return Math.round(new Date().valueOf() * Math.random()) + '';
    },

    /**
     * Encrypt password
     *
     * @param {String} password
     * @return {String}
     * @api public
     */

    encryptPassword(password) {
      if (!password) return '';
      try {
        return crypto
          .createHmac('sha1', this.salt)
          .update(password)
          .digest('hex');
      } catch (err) {
        return '';
      }
    },
  };

  /**
   * Statics
   */

  userSchema.statics = {
    /**
     * Load
     *
     * @param {Object} options
     * @param {Function} cb
     * @api private
     */

    load(options, cb) {
      options.select = options.select || 'name username';
      return this.findOne(options.criteria).select(options.select).exec(cb);
    },
  };

  const User = new mongoose.model('User', userSchema);
  return User;
};

user.js中,实现了用户密码加密,随机加盐,密码校验等逻辑,这里直接参考了 node-express-mongoose-demo里面的实现。

3, 实现用户注册和登录的逻辑

3.1 为ctx添加辅助函数

由于返回成功和失败一般都具备可重复的特点,扩展ctx,添加ctx.successctx.fail方法, 在app文件夹下,添加extend文件夹,创建context.js,文件内容如下:

// 根据自己个人喜好,可以自己定义返回成功和失败时的数据结构,
// 这里我用code, data, message三个字段,仅仅具备参考性。
module.exports = {
   success(data, message) {
     if (!message) message = '成功';
     this.status = 200;
     this.body = {
       code: 0,
       data,
       message,
     };
   },

   fail(data, message) {
     if (!data) data = null;
     if (!message) message = '失败';
     this.body = {
       code: 1000,
       data,
       message,
     };
   },
}

3.2,添加用户注册和登录方法。

controller文件夹下,添加user.js文件,

  async register() {
    const { ctx } = this;
    const User = ctx.model.User;
    const userData = ctx.request.body;

    const user = new User(userData);
    const result = await user.save();
    // 去掉密码和盐
    const cloned = JSON.parse(JSON.stringify(result));
    console.log(cloned);
    delete cloned.hashed_password;
    delete cloned.salt;
    ctx.success(cloned, '注册成功');
  }
  
  // jwt登录验证
  async jwtLogin() {
    const { ctx, app } = this;
    const data = ctx.request.body;
    // 用户名密码确定用户登录是否正确
    const User = ctx.model.User;
    const existUser = await User.findOne({ username: data.username });
    if (!existUser) return ctx.fail('用户名不存在');
    if (!existUser.authenticate(data.password)) return ctx.fail('密码错误');
    
    // 登录成功, 返回token
    const token = app.jwt.sign(
      {
        userId: existUser._id,
      },
      app.config.jwt.secret,
      { expiresIn: app.config.jwt.expiresIn || '1h' }
    );
    ctx.success(token, '');
  }

3.3 添加登录和注册的路由

router.js中,添加路由

module.exports = (app) => {
  const { router } = app;
  const { user } = app.controller;
  
  router.post('/api/user/register', user.register);
  router.post('/api/user/jwtLogin', user.jwtLogin);

通过postman测试通过,截图如下:

image.png

image.png

4,接口请求添加token校验

controller, user.js中,添加方法jwtTest

async jwtTest() {
    // 这里的 ctx.state.user是登录中创建的token解析的值,包括前面设置的userId,还包括exp和iat
    const ctx = this.ctx;
    ctx.success(ctx.state.user, '');
}

router.js中,添加实现:

const jwt = app.jwt;

// 其他路由 ...
// 添加jwt中间件的路由
router.get('/api/jwtTest', jwt, user.jwtTest);

经验证可以得到正确的返回

image.png

5, 添加token刷新功能

egg-jwt并没有集成token将要过期刷新功能,故这部分要我们自己手动写 创建一个名为refreshToken的中间件,文件路径为app/middleware/refreshToken,这里我添加了一个简单的限定,即在配置的时候,只能传入离过期时间间隔为分钟或者秒。比如配置timeBeforeExpire为10s,意味着当前请求如果是离token过期在10s之内,才会更新token。

module.exports = (options) => {
  return async function refreshToken(ctx, next) {
    // 在过期时间之前多长进行刷新, 默认一分钟,只支持s和m,即分钟和秒数
    const timeBeforeExpire = options.timeBeforeExpire || '1m';
    if (!/\d+[ms]$/i.test(timeBeforeExpire)) return await next(new Error('刷新时间只支持分和秒'));
    let time = null;
    if (/\d+m$/i.test(timeBeforeExpire)) {
      time = 1000 * 60 * parseInt(timeBeforeExpire);
    } else {
      time = 1000 * parseInt(timeBeforeExpire);
    }

    console.log(ctx.state.user);
    const userData = JSON.parse(JSON.stringify((ctx.state.user)));

    const exp = 1000 * userData.exp;
    const app = ctx.app;
    if (Date.now() + time > exp) {
      delete userData.iat;
      delete userData.exp;
      const token = app.jwt.sign(userData, app.config.jwt.secret, {
        expiresIn: app.config.jwt.expiresIn || '1h',
      });
      // 对ctx添加tokenRefreshed和newToken字段,
      ctx.tokenRefreshed = true;
      ctx.newToken = token;
    }

    await next();
  };
};

extend/context.js中,通用数据返回,添加token更新的判断,success方法变成

  success(data, message) {
    if (!message) message = '成功';

    let token;
    if (this.tokenRefreshed) {
      token = this.newToken;
    }
    this.body = {
      code: 0,
      data,
      message,
      token,
    };
  }

路由中,在jwt之后,添加refreshToken

  const refreshToken = app.middleware.refreshToken({
    timeBeforeExpire: '20s',
  });
  router.get('/api/jwtTest', jwt, refreshToken, user.jwtTest);

在测试的是,为了检验token刷新,可以调整config.default.js中的jwt.expiresIn为一个合适的值,如40s.

如果token过期,egg-jwt内部会扔出异常,为了简单处理,添加全局的异常捕捉, 这样即使后台报错,请求依然会有返回,而不是一直loading. config.defualt.js中,添加

config.onerror = {
    all(err, ctx) {
      const msg = err.message;
      ctx.status = 500;
      ctx.body = JSON.stringify({
        code: 999,
        data: null,
        message: err.message || '服务器错误',
      });
    },
};