zanePerfor中一套简单通用的Node前后端Token登录机制和github、新浪、微信等第三方授权登录

321 阅读7分钟

HI!,你好,我是zane,zanePerfor是一款我开发的一个前端性能监控平台,现在支持web浏览器端和微信小程序端。

我定义为一款完整,高性能,高可用的前端性能监控系统,这是未来会达到的目的,现今的架构也基本支持了高可用,高性能的部署。实际上还不够,在很多地方还有优化的空间,我会持续的优化和升级。

开源不易,如果你也热爱技术,拥抱开源,希望能小小的支持给个star。

项目的github地址:github.com/wangweiange…

项目开发文档说明:blog.seosiwei.com/performance…


谈起Token登录机制,相信绝大部分人都不陌生,相信很多的前端开发人员都有实际的开发实践。

此文章的Token登录机制主要针对于无实际开发经验或者开发过简单登录机制的人员,如果你是大佬几乎可以略过了,如果你感兴趣或者闲来无事也可以稍微瞅它一瞅。

此文章不会教你一步一步的实现一套登录逻辑,只会结合zanePerfor项目阐述它的登录机制,讲明白其原理比写一堆代码来的更实在和简单。

zanePerfor项目的主要技术栈是 egg.js、redis和mongodb, 如果你不懂没关系,因为他们都只是简单使用,很容易理解。


登录实现结果:

  • 如果用户未注册时先注册然后直接登录
  • 用户每次登录都会动态生成session令牌
  • 同一账号在同一时刻只能在一个地方登录


cookie在项目中的作用

我们知道http是无状态的,因此如果要知道用户某次请求是否登录就需要带一定的标识,浏览器端http请求带标识常用的方式有两种:1、使用cookie附带标识,2、使用header信息头附带标识。

这里我们推荐的方式是使用cooke附带标识,因为它相当于来说更安全和更容易操作。

更安全体现在:cookie只能在同域下传输,还可以设置httpOnly来禁止js的更改。

更容易操作体现在:cookie传输是浏览器请求时自带的传输头信息,我们不需要额外的操作,cookie还能精确到某一个路径,并且可以设置过期时间自动过期,这样就显得更可控。

当然header信息头也有它的优势和用武之地,这里不做阐述。


redis在项目中的作用

一般的项目我们会把识别用户的标识放存放在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, 如果未登录则跳转到登录页面。

User表结构说明

// 代码路径 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值,此值每次用户登录时都会更新,并且是随机和唯一的。


Node Servers端登录逻辑

我们先来一张登录的页面



业务代码如下:

// 代码路径  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;
    }


对照user表来进行逻辑的梳理。

  • 每次登录前都会清除上一次在redis中的登录态信息,所以上一次的登录令牌对应的redis信息会失效,因此我们只需要做一个校验用户Token的信息在redis中是否存在即可判断用户当前登录态是否有效。
  • 清除上一次登录态信息之后立即生成一个随机并唯一的key值做为新的Token令牌,并更新redis中Token的令牌信息 和 设置新的cookie令牌,这样就保证了以前的登录态失效,当前的登录态有效。
  • redis 和 cookie 都设置相同的过期时间,以保证Token的时效性和安全性。
  • cookie的httpOnly 我们需要开启,这样就保证的Token的不可操作性,encrypt 和 signed参数是egg.js 的参数,主要负责对cookie进行加密,让前端的cookie不已明文的方式呈现,提高安全性。
  • 最后再更新用户的Token令牌信息,以保证用户的Token每次都是最新的,也用以下次登录时的清除操作。


Servers 端用户登录校验中间件

中间件的概念相信大家都不陌生,用过koa,express和redux都应该知道,egg.js的中间件来自于与koa,在这里就不说概念了。

在zanePerfor项目中我们只需要对所有需要进行登录校验的路由(请求)进行中间件校验即可。

在egg中可这样使用:

// 代码来源 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也是浏览器请求时自带的,在浏览器端不可操作,这相对的增加了一些安全性
    (项目中暂未做,这个验证比较简单,如果有需要的自己去实现)。

到此zanePerfor的Token校验机制其实已经完全实现完了,只是未做整体的总结,下面来继续的完成注册的逻辑。


用户注册逻辑实现



业务代码如下:

// 代码路径  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;
}


第三方github登录说明

根据zanePerfor的登录校验机制可以得出以下的结论:

  • User表的用户名必须存在,密码可无,并且用户名在代码中强校验不能重复,但是在数据库中用户名是可以重复的。
  • usertoken字段很重要,是实现所有Token机制的核心字段,每次登录和注册都会是随机并唯一的值

基于以上两点做第三方登录我们只需要实现以下几点即可:

  • 只要给用户名赋值即可,因为用户密码登录和第三方登录是两套逻辑,因此用户名可以重复,这就解决了第三方登录一定不会存在用户已注册的提示。
  • 第一次登录时注册用户,并把第三方的用户名当做表的用户名,第三方的secret作为用户的token字段。
  • 第二次登录时使用token字段检测用户是否已注册,已注册走登录逻辑,未注册走注册逻辑。
// 代码地址  app/service/user.js

// github | 新浪微博 register
async githubRegister(userinfo, token) {
    let userInfo = {};
    userInfo = await this.getUserInfoForGithubId(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: userinfo, usertoken: random_key });
        }
    } else {
        // 不存在 先注册 再登录
        const user = this.ctx.model.User();
        user.user_name = userinfo;
        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第三方授权方式请参考:blog.seosiwei.com/performance…


总结:

  • 前端封装统一的登录验证,项目中 code 1004 为用户未登录,1000为成功。
  • user数据表中储存一个usertoken字段,此字段是随机并唯一的标识,在注册时存入此字段,在每次登录时更新此字段。
  • 浏览器端的Token令牌即usertoken字段,redis的每个Token存储的是相应的用户信息。
  • 每次登录时清除上一次用户的登录信息,即清除redis登录校验信息,这样就能保证同一用户同一时间只能在一个地方登录。
  • usertoken字段是随时在变的,redis用户信息和cookie Token令牌都有过期时间,cookie经过加密和httpOnly,更大的保证了Token的安全性。
  • 对所有需要校验的http请求做中间件校验,通过Token令牌获取redis用户信息并验证,验证即通过,验证失败则重新去登录。
  • 第三方登录使用token做用户是否重复校验,第一次时登录注册,第二次登录时则走登录逻辑。