zanePerfor中一套簡單通用的Node先後端Token登陸機制和github受權登陸方式

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令牌
  • 同一帳號在同一時刻只能在一個地方登陸

cookie在項目中的做用

咱們知道http是無狀態的,所以若是要知道用戶某次請求是否登陸就須要帶必定的標識,瀏覽器端http請求帶標識經常使用的方式有兩種:一、使用cookie附帶標識,二、使用header信息頭附帶標識。
這裏咱們推薦的方式是使用cooke附帶標識,由於它至關於來講更安全和更容易操做。mongodb

更安全體如今: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端登陸邏輯

咱們先來一張登陸的頁面
GbYwRSR4MbRkG5sP2DhjMFpFNM4an4Gn.png?imageslim

eHKSAjsDM5C3t4FdtFXjbGtwGHarH72m.png?imageslim

業務代碼以下:

// 代碼路徑  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);

WY7znt3hmCYjsAjK3tms4sjyZhs6itwp.png?imageslim

業務代碼以下:

// 代碼路徑  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校驗機制其實已經徹底實現完了,只是未作總體的總結,下面來繼續的完成註冊的邏輯。

用戶註冊邏輯實現

ReNeAszDf4626aRsp65ZTG32nB8yTGce.png?imageslim

業務代碼以下:

// 代碼路徑  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(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作用戶是否重複校驗,第一次時登陸註冊,第二次登陸時則走登陸邏輯。

原文地址:https://blog.seosiwei.com/det...

相關文章
相關標籤/搜索