HI!,你好,我是zane,zanePerfor是一款我开发的一个前端性能监控平台,如今支持web浏览器端和微信小程序端。html
我定义为一款完整,高性能,高可用的前端性能监控系统,这是将来会达到的目的,现今的架构也基本支持了高可用,高性能的部署。实际上还不够,在不少地方还有优化的空间,我会持续的优化和升级。前端
开源不易,若是你也热爱技术,拥抱开源,但愿能小小的支持给个star。node
项目的github地址:https://github.com/wangweiang...
项目开发文档说明:https://blog.seosiwei.com/per...git
谈起Token登陆机制,相信绝大部分人都不陌生,相信不少的前端开发人员都有实际的开发实践。github
此文章的Token登陆机制主要针对于无实际开发经验或者开发过简单登陆机制的人员,若是你是大佬几乎能够略过了,若是你感兴趣或者闲来无事也能够稍微瞅它一瞅。web
此文章不会教你一步一步的实现一套登陆逻辑,只会结合zanePerfor项目阐述它的登陆机制,讲明白其原理比写一堆代码来的更实在和简单。ajax
zanePerfor项目的主要技术栈是 egg.js、redis和mongodb, 若是你不懂不要紧,由于他们都只是简单使用,很容易理解。redis
- 若是用户未注册时先注册而后直接登陆
- 用户每次登陆都会动态生成session令牌
- 同一帐号在同一时刻只能在一个地方登陆
咱们知道http是无状态的,所以若是要知道用户某次请求是否登陆就须要带必定的标识,浏览器端http请求带标识经常使用的方式有两种:一、使用cookie附带标识,二、使用header信息头附带标识。
这里咱们推荐的方式是使用cooke附带标识,由于它至关于来讲更安全和更容易操做。mongodb
更安全体如今:cookie只能在同域下传输,还能够设置httpOnly来禁止js的更改。
更容易操做体如今:cookie传输是浏览器请求时自带的传输头信息,咱们不须要额外的操做,cookie还能精确到某一个路径,而且能够设置过时时间自动过时,这样就显得更可控。
固然header信息头也有它的优点和用武之地,这里不作阐述。数据库
通常的项目咱们会把识别用户的标识放存放在Session中,可是Session有其使用的局限性。
Session的局限:Session 默认存放在 Cookie 中,可是若是咱们的 Session 对象过于庞大,浏览器可能拒绝保存,这样就失去了数据的完整性。当 Session 过大时还会对每次http请求带来额外的开销。还有一个比较大的局限性是Session存放在单台服务器中,当有多台服务器时没法保证统一的登陆态。还会带来代码的强耦合性,不能使得登陆逻辑代码解耦。
所以这里引入redis进行用户身份识别的储存。
redis的优点:redis使用简单,redis性能足够强悍,储存空间无限制,多台服务器可使用统一的登陆态,登陆逻辑代码的解耦。
前端统一登陆态应该是每位前端童鞋都作过的事情,下面以zanePerfor的Jquery的AJAX为例作简单的封装为例:
// 代码路径:app/public/js/util.js ajax(json) { // ...代码略... return $.ajax({ type: json.type || "post", url: url, data: json.data || "", dataType: "json", async: asyncVal, success: function(data) { // ...代码略... // success 时统一使用this.error方法进行处理 if (typeof(data) == 'string') { This.error(JSON.parse(data), json); } else { This.error(data, json); } }, // ...代码略... }); }; error(data, json) { //判断code 并处理 var dataCode = parseInt(data.code); // code 为1004表示未登陆 须要统一走登陆页面 if (!json.isGoingLogin && dataCode == 1004) { //判断app或者web if (window.location.href.indexOf(config.loginUrl) == -1) { location.href = config.loginUrl + '?redirecturl=' + encodeURIComponent(location.href); } else { popup.alert({ type: 'msg', title: '用户未登录,请登陆!' }); } } else { switch (dataCode) { // code 为1000表示请求成功 case 1000: json.success && json.success(data); break; default: if (json.goingError) { //走error回调 json.error && json.error(data); } else { //直接弹出错误信息 popup.alert({ type: 'msg', title: data.desc }); }; } }; }
- 前端的逻辑代码很简单,就是统一的判断返回code, 若是未登陆则跳转到登陆页面。
// 代码路径 app/model/user.js const UserSchema = new Schema({ user_name: { type: String }, // 用户名称 pass_word: { type: String }, // 用户密码 system_ids: { type: Array }, // 用户所拥有的系统Id is_use: { type: Number, default: 0 }, // 是否禁用 0:正常 1:禁用 level: { type: Number, default: 1 }, // 用户等级(0:管理员,1:普通用户) token: { type: String }, // 用户秘钥 usertoken: { type: String }, // 用户登陆态秘钥 create_time: { type: Date, default: Date.now }, // 用户访问时间 });
- 用户表中 usertoken 字段比较重要,它表示每次用户登陆时动态生成的Token令牌key, 也是存在在redis中用户信息的key值,此值每次用户登陆时都会更新,而且是随机和惟一的。
咱们先来一张登陆的页面
// 代码路径 app/service/user.js // 用户登陆 async login(userName, passWord) { // 检测用户是否存在 const userInfo = await this.getUserInfoForUserName(userName); if (!userInfo.token) throw new Error('用户名不存在!'); if (userInfo.pass_word !== passWord) throw new Error('用户密码不正确!'); if (userInfo.is_use !== 0) throw new Error('用户被冻结不能登陆,请联系管理员!'); // 清空之前的登陆态 if (userInfo.usertoken) this.app.redis.set(`${userInfo.usertoken}_user_login`, ''); // 设置新的redis登陆态 const random_key = this.app.randomString(); this.app.redis.set(`${random_key}_user_login`, JSON.stringify(userInfo), 'EX', this.app.config.user_login_timeout); // 设置登陆cookie this.ctx.cookies.set('usertoken', random_key, { maxAge: this.app.config.user_login_timeout * 1000, httpOnly: true, encrypt: true, signed: true, }); // 更新用户信息 await this.updateUserToken({ username: userName, usertoken: random_key }); return userInfo; }
- 每次登陆前都会清除上一次在redis中的登陆态信息,因此上一次的登陆令牌对应的redis信息会失效,所以咱们只须要作一个校验用户Token的信息在redis中是否存在便可判断用户当前登陆态是否有效。
- 清除上一次登陆态信息以后当即生成一个随机并惟一的key值作为新的Token令牌,并更新redis中Token的令牌信息 和 设置新的cookie令牌,这样就保证了之前的登陆态失效,当前的登陆态有效。
- redis 和 cookie 都设置相同的过时时间,以保证Token的时效性和安全性。
- cookie的httpOnly 咱们须要开启,这样就保证的Token的不可操做性,encrypt 和 signed参数是egg.js 的参数,主要负责对cookie进行加密,让前端的cookie不已明文的方式呈现,提升安全性。
- 最后再更新用户的Token令牌信息,以保证用户的Token每次都是最新的,也用如下次登陆时的清除操做。
中间件的概念相信你们都不陌生,用过koa,express和redux都应该知道,egg.js的中间件来自于与koa,在这里就不说概念了。
在zanePerfor项目中咱们只须要对全部须要进行登陆校验的路由(请求)进行中间件校验便可。
// 代码来源 app/router/api.js // 得到controller 和 middleware(中间件) const { controller, middleware } = app; // 对须要校验的路由进行校验 // 退出登陆 apiV1Router.get('user/logout', tokenRequired, user.logout);
// 代码路径 app/middleware/token_required.js // Token校验中间件 module.exports = () => { return async function(ctx, next) { const usertoken = ctx.cookies.get('usertoken', { encrypt: true, signed: true, }) || ''; if (!usertoken) { ctx.body = { code: 1004, desc: '用户未登陆', }; return; } const data = await ctx.service.user.finUserForToken(usertoken); if (!data || !data.user_name) { ctx.cookies.set('usertoken', ''); const descr = data && !data.user_name ? data.desc : '登陆用户无效!'; ctx.body = { code: 1004, desc: descr, }; return; } await next(); }; }; // finUserForToken方法代码路径 // 代码路径 app/service/user.js // 根据token查询用户信息 async finUserForToken(usertoken) { let user_info = await this.app.redis.get(`${usertoken}_user_login`); if (user_info) { user_info = JSON.parse(user_info); if (user_info.is_use !== 0) return { desc: '用户被冻结不能登陆,请联系管理员!' }; } else { return null; } return await this.ctx.model.User.findOne({ token: user_info.token }).exec(); }
- 首先会得到上传的token令牌,这里cookie.get方法的 encrypt 和 signed 须要为true,这会把Token解析为明文。
- 在finUserForToken方法中主要是获取Token令牌对应的redis用户信息,只有当用户的信息为真值时才会经过校验
- 在中间件这一环节还有一个比较常规的验证 就是 验证请求的 referer, referer也是浏览器请求时自带的,在浏览器端不可操做,这相对的增长了一些安全性(项目中暂未作,这个验证比较简单,若是有须要的本身去实现)。
// 代码路径 app/service/user.js // 用户注册 async register(userName, passWord) { // 检测用户是否存在 const userInfo = await this.getUserInfoForUserName(userName); if (userInfo.token) throw new Error('用户注册:用户已存在!'); // 新增用户 const token = this.app.randomString(); const user = this.ctx.model.User(); user.user_name = userName; user.pass_word = passWord; user.token = token; user.create_time = new Date(); user.level = userName === 'admin' ? 0 : 1; user.usertoken = token; const result = await user.save(); // 设置redis登陆态 this.app.redis.set(`${token}_user_login`, JSON.stringify(result), 'EX', this.app.config.user_login_timeout); // 设置登陆cookie this.ctx.cookies.set('usertoken', token, { maxAge: this.app.config.user_login_timeout * 1000, httpOnly: true, encrypt: true, signed: true, }); return result; }
- 用户注册的代码比较简单,首先检测用户是否存在,不存在则储存
- 生成动态并惟一的Token令牌,并保持数据到redis 和设置 cookie令牌信息, 这里都设置相同的过时时间,并加密cookie信息和httpOnly。
退出登陆逻辑很简单,直接清除用户Token对应的redis信息和cookie token令牌便可。
// 登出 logout(usertoken) { this.ctx.cookies.set('usertoken', ''); this.app.redis.set(`${usertoken}_user_login`, ''); return {}; }
冻结用户的逻辑也比较简单,惟一须要注意的是,冻结的时候须要清除用户Token对应的redis信息。
// 冻结解冻用户 async setIsUse(id, isUse, usertoken) { // 冻结用户信息 isUse = isUse * 1; const result = await this.ctx.model.User.update( { _id: id }, { is_use: isUse }, { multi: true } ).exec(); // 清空登陆态 if (usertoken) this.app.redis.set(`${usertoken}_user_login`, ''); return result; }
删除用户逻辑跟冻结用户逻辑一致,也须要注意清除用户Token对应的redis信息。
// 删除用户 async delete(id, usertoken) { // 删除 const result = await this.ctx.model.User.findOneAndRemove({ _id: id }).exec(); // 清空登陆态 if (usertoken) this.app.redis.set(`${usertoken}_user_login`, ''); return result; }
根据zanePerfor的登陆校验机制能够得出如下的结论:
- User表的用户名必须存在,密码可无,而且用户名在代码中强校验不能重复,可是在数据库中用户名是能够重复的。
- usertoken字段很重要,是实现全部Token机制的核心字段,每次登陆和注册都会是随机并惟一的值
基于以上两点作第三方登陆咱们只须要实现如下几点便可:
- 只要给用户名赋值便可,由于用户密码登陆和第三方登陆是两套逻辑,所以用户名能够重复,这就解决了第三方登陆必定不会存在用户已注册的提示。
- 第一次登陆时注册用户,并把第三方的用户名当作表的用户名,第三方的secret做为用户的token字段。
- 第二次登陆时使用token字段检测用户是否已注册,已注册走登陆逻辑,未注册走注册逻辑。
// 代码地址 app/service/user.js // github register 核心注册逻辑 async githubRegister(data = {}) { // 此字段为github用户名 const login = data.login; // 此字段为github 惟一用户标识 const token = data.node_id; let userInfo = {}; if (!login || !token) { userInfo = { desc: 'github 权限验证失败, 请重试!' }; return; } // 经过token去查询用户是否存在 userInfo = await this.getUserInfoForGithubId(token); // 身材Token随机并惟一令牌 const random_key = this.app.randomString(); if (userInfo.token) { // 存在则直接登陆 if (userInfo.is_use !== 0) { userInfo = { desc: '用户被冻结不能登陆,请联系管理员!' }; } else { // 清空之前的登陆态 if (userInfo.usertoken) this.app.redis.set(`${userInfo.usertoken}_user_login`, ''); // 设置redis登陆态 this.app.redis.set(`${random_key}_user_login`, JSON.stringify(userInfo), 'EX', this.app.config.user_login_timeout); // 设置登陆cookie this.ctx.cookies.set('usertoken', random_key, { maxAge: this.app.config.user_login_timeout * 1000, httpOnly: true, encrypt: true, signed: true, }); // 更新用户信息 await this.updateUserToken({ username: login, usertoken: random_key }); } } else { // 不存在 先注册 再登陆 const user = this.ctx.model.User(); user.user_name = login; user.token = token; user.create_time = new Date(); user.level = 1; user.usertoken = random_key; userInfo = await user.save(); // 设置redis登陆态 this.app.redis.set(`${random_key}_user_login`, JSON.stringify(userInfo), 'EX', this.app.config.user_login_timeout); // 设置登陆cookie this.ctx.cookies.set('usertoken', random_key, { maxAge: this.app.config.user_login_timeout * 1000, httpOnly: true, encrypt: true, signed: true, }); } return userInfo; }
详细的github第三方受权方式请参考:https://blog.seosiwei.com/per...
- 前端封装统一的登陆验证,项目中 code 1004 为用户未登陆,1000为成功。
- user数据表中储存一个usertoken字段,此字段是随机并惟一的标识,在注册时存入此字段,在每次登陆时更新此字段。
- 浏览器端的Token令牌即usertoken字段,redis的每一个Token存储的是相应的用户信息。
- 每次登陆时清除上一次用户的登陆信息,即清除redis登陆校验信息,这样就能保证同一用户同一时间只能在一个地方登陆。
- usertoken字段是随时在变的,redis用户信息和cookie Token令牌都有过时时间,cookie通过加密和httpOnly,更大的保证了Token的安全性。
- 对全部须要校验的http请求作中间件校验,经过Token令牌获取redis用户信息并验证,验证即经过,验证失败则从新去登陆。
- 第三方登陆使用token作用户是否重复校验,第一次时登陆注册,第二次登陆时则走登陆逻辑。