本文不讨论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中, 对mongoose和jwt进行配置
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.success和ctx.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测试通过,截图如下:
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);
经验证可以得到正确的返回
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 || '服务器错误',
});
},
};