Egg.js 基於 jsonwebtoken 的 Token 實現系統登錄與接口認證

1、概述

本文,主要是對在 Egg.js 框架下如何使用JSON Web Token.js 生成的 Toke 實現用戶登錄認證。javascript

JSON Web Token(縮寫 JWT)是目前最流行的跨域認證解決方案。其簡要介紹以下:html

JWT

Json web token(JWT)是爲了網絡應用環境間傳遞聲明而執行的一種基於JSON的開發標準(RFC 7519),該token被設計爲緊湊且安全的,特別適用於分佈式站點的單點登錄(SSO)場景。JWT的聲明通常被用來在身份提供者和服務提供者間傳遞被認證的用戶身份信息,以便於從資源服務器獲取資源,也能夠增長一些額外的其它業務邏輯所必須的聲明信息,該token也可直接被用於認證,也可被加密。前端

關於 JSON Web Token 的原理與特色,這篇文章不會作詳細的介紹,若是想對其作更多的探究,不妨參看寶藏男孩 阮一峯的博客:JSON Web Token入門教程vue

查看這篇文章的讀者,應該要對 Egg.js 有必定的瞭解。java

2、實現思路與相關資源

一、實現思路

首先文章所實現的功能只是簡單的用戶信息驗證,對用戶系統權限分配,單點登陸等更高級複雜的功能,並未加入考慮。所以,基於此特色,對此功能的實現思路分析以下:node

Function Workflow

此外,除了用戶登陸須要驗證以外,全部須要被保護的接口須要必須,經過Token信息驗證,才能獲取相關結果。在 Egg.js 中, 這個驗證的過程是放在中間件中實現的。ios

Function Workflow 2

個人習慣是在作弄個功能以前,儘量去了解其中會涉及到的概念,實現流程,所以文章在代碼開始以前老是會出現相似這種分析的過程,若是有什麼很差的地方,歡迎各位指正。web

二、相關資源

json web token 涉及到的加密和解密功能,用到了 jsonwebtoken 插件,vuex

egg.js 用戶密碼的 涉及到的密和解密,用到的是 node-jsencrypt 插件,數據庫

vue.js 中 用戶密碼的 涉及到的密和解密, 用到的是 jsencrypt 插件,從名稱是很容易看得出它和 egg.js 中的加密解密方式是一致的。

二兩個部分同時都用到了 RSA 密鑰文件,所以,這裏也提早把 RSA 密鑰文件 生產的方法貼出來:

利用Openssl生成私鑰公鑰

生成公鑰:openssl genrsa -out rsa_private_key.pem 1024

生成私鑰: openssl rsa -in rsa_private_key.pem -pubout -out rsa_public_key.pem

須要注意,私鑰是不可公開的,不然安全性沒法等到保障。

3、代碼實現

​ 後端框架: Egg.js

​ 前端框架: Vue.js

​ 數據庫:MongoDB

目前我實現的這些功能是正在開發的項目中完成的,所以完整的代碼不會上傳。在正式按上面的流程實現功能以前,須要先搞清楚,Token 是如何生產,如何校驗的以及 RSA 加密方式是什麼。這裏咱們先看看這兩個方法。若是對 Egg.js 框架不熟的話,可能會有不少疑問,建議多參看 egg.js API 文檔

1. 用戶登錄

Function Workflow

(1)前端

如流程圖裏面看到的,用戶輸入用戶名密碼,經過校驗,會先通過加密的過程,再發起 api 請求,這樣保證用戶信息從前端到後端的過程當中是安全的。而前端的加密解密方式(解密暫時沒必要),如 上文 2 中所講的是 jsencrypt

1.1 登錄頁面代碼
// src\views\login\index.vue
<template>
  <div class="login">
    <div class="login-wrap">
      <div class="login-panel">
        <el-form :model="userInfo" :rules="rules" ref="userInfo">
          <el-form-item prop="checkUsername">
            <el-input
              type="text"
              class="user-info-item"
              v-model="userInfo.username"
              placeholder="Username"
              autocomplete="off"
            ></el-input>
          </el-form-item>

          <el-form-item prop="checkPass">
            <el-input
              type="password"
              class="user-info-item"
              v-model="userInfo.password"
              placeholder="Password"
              autocomplete="off"
            ></el-input>
          </el-form-item>
          <el-form-item>
            <el-button class="user-info-submit" type="success" @click="login">Ok</el-button>
          </el-form-item>
        </el-form>
      </div>
    </div>
  </div>
</template>

<script>
import { mapMutations } from "vuex";
export default {
  name: "",
  components: {},
  data() {
    let validateUsername = (rule, value, callback) => {
      if (this.userInfo.username === "") {
        callback(new Error("Please input user name."));
      } else {
        let isValid = /^[a-zA-Z0-9_]{3,16}$/g.test(this.userInfo.username);
        if (isValid) {
          callback();
        } else {
          callback(new Error("Please input valid user name."));
        }
      }
    };

    let validatePass = (rule, value, callback) => {
      if (this.userInfo.password === "") {
        callback(new Error("Please input password."));
      } else {
        callback();
      }
    };
    return {
      userInfo: {
        username: "",
        password: ""
      },
      key: "",
      rules: {
        checkUsername: [{ validator: validateUsername, trigger: "blur" }],
        checkPass: [{ validator: validatePass, trigger: "blur" }]
      }
    };
  },
  computed: {
    userInfoEncryped: function() {
      let username = this.userInfo.username;
      // 對用戶密碼 加密
      let password = this.key
        ? this.$utils.encrypt.rsaEncrypt(this.userInfo.password, this.key)
        : "";
      return {
        username,
        password
      };
    }
  },
  async mounted() {
    // 獲取公鑰信息
    // 使用 jsecrypt 時,必須用到公鑰進行加密,這個公鑰我放在服務端以接口形式提供的,所以這裏我在頁面初     // 始化時獲取公鑰並緩存
      
    let getKey = await this.$service.login.getKey();
    if (getKey.succeed) this.key = getKey.data;
  },
  methods: {
    ...mapMutations("user", ["SET_TOKEN_INFO", "SET_USER_NAME"]),
    async login() {
      this.$refs["userInfo"].validate(async valid => {
        if (valid) {
          let login = await this.$service.login.signIn(this.userInfoEncryped);
          if (login.succeed) {
              this.$router.push("/index");
          } else {
            this.$message.error("Faild to sign in .");
          }
        } else {
          console.log("error submit!!");
          return false;
        }
      });
    }
  }
};
</script>

<style lang="less">
	/* 略 */
</style>
複製代碼

前端的實現方式,這裏只是提供一個示例,主要在於展現 加密 這個步驟,至於其餘業務須要用到的狀態管理能夠略過。

1.2 前端攔截器中http請求頭部添加 token 參數

這裏是在 vue 中使用的 axios

/** * request interceptor * @param {Object} config * @return {Object} */
request.interceptors.request.use(
  config => {
    // do something before request is sent
    let urlParams = config.url + JSON.stringify(config.params);

    if (cancelRequest.has(urlParams) && repeatWhiteLst(urlParams)) {
      cancelRequest.get(urlParams)("Repeat Request");
    }
    config.cancelToken = new CancelToken(cancel => {
      cancelRequest.set(urlParams, cancel);
    });

	// 添加 token 信息到 請求頭部
    let tokenInfo = getToken();
    config.headers["authorization"] = tokenInfo.token;
    return config;
  },
  error => {
    // Do something with request error
    // eslint-disable-next-line
    console.log(error);
    Promise.reject(error);
  }
);
複製代碼
1.3 前端 加密/解密 關鍵代碼
// src\utils\encrypt.js

import JSEncrypt from "jsencrypt";

/** * Encrypt with the public key... * @param {String} text * @param {String} publicKey * @returns ciphertext */
export const rsaEncrypt = (text, publicKey) => {
  // public key 是來自後端保存好的公鑰
  let _publicKey =
    "-----BEGIN PUBLIC KEY-----" + publicKey + "-----END PUBLIC KEY-----";
  let encrypt = new JSEncrypt();
  encrypt.setPublicKey(_publicKey);
  let encrypted = encrypt.encrypt(text);
  return encrypted;
};

/** * Decrypt with the private key... * @param {String} ciphertext * @param {String} privateKey * @returns text */
export const rsaDecrypt = (ciphertext, privateKey) => {
  // let _privateKey =
  // "-----BEGIN RSA PRIVATE KEY-----" +
  // privateKey +
  // "-----END RSA PRIVATE KEY-----";
  
  let decrypt = new JSEncrypt();
  decrypt.setPrivateKey(privateKey);
  let uncrypted = decrypt.decrypt(ciphertext);
  return uncrypted;
};

export default { rsaEncrypt, rsaDecrypt };

複製代碼

(2)後端

後端在完成登陸功能的時候,首先要注意兩個地方,上文提到的 獲取公鑰和用戶登錄接口 在用戶發起這兩個請求時,前端並無 Token 信息,所以須要在中間件中配置進忽略項。而這裏的中間件是指,jwt.js , 即 JSON Web Token 中間件。

2.1 jwt 中間件配置
//
// config\config.default.js
//
 "use strict";

module.exports = appInfo => {
  const config = (exports = {});

  // use for cookie sign key, should change to your own and keep security
  config.keys = appInfo.name + "_";

  // add your config here
  config.middleware = ["jwt", "compress", "errorHandler", "notfoundHandler"];

  // json web token 驗證
  config.jwt = {
    enable: true,
    ignore: ["/sign/in", "/auth/pubkey"] // 哪些請求不須要認證
  };

  // Gzip 壓縮閾值
  config.compress = {
    threshold: 1000
  };

  // 解決 csrf 安全策略,致使 API 沒法訪問
  config.security = {
    csrf: {
      enable: false
      // ignoreJSON: true
    },
    domainWhiteList: ["*"]
  };

  // 結局跨域的我問題
  config.cors = {
    origin: "*",
    allowMethods: "GET,HEAD,PUT,POST,DELETE,PATCH"
  };

  return config;
};

複製代碼

Function Workflow 2
這裏 jwt.js 中間件中的代碼邏輯和圖中流程是一致的,能夠結合圖文邊看邊理解。

//
// app\middleware\jwt.js
// config 中配置的 ["/sign/in", "/auth/pubkey"] 這個兩個接口將不會經過此中間件
//
 "use strict";

module.exports = () => {
  return async function Interceptor(ctx, next) {
    let reqUrl = ctx.request.url;
    if (reqUrl == "/") {
      await next();
    } else {
      // 獲取header裏的authorization
      let authToken = ctx.header.authorization; 
      if (authToken) {
        // 解密獲取的Token
        const declassified = ctx.helper.login.verifyToken(authToken); 
        if (!declassified.exp) {
          // 從數據庫獲取用戶信息進行 Token 驗證
          let userInfo = await ctx.model.Internal.User.find({
            userName: declassified.username
          });

          let user = userInfo[0].toObject();

          if (user.token === authToken) {
            await next();
          } else {
            ctx.throwBizError("USER_INFO_EXPIRED");
          }
        } else {
          ctx.throwBizError("USER_INFO_EXPIRED");
        }
      } else {
        ctx.throwBizError("UNLOGGED");
      }
    }
  };
};

複製代碼
2.2 用戶登錄 controller
//
// app\controller\sign.js
//
 "use strict";

const Controller = require("egg").Controller;

class SignController extends Controller {
  async signIn() {
    const { ctx } = this;
    const user = ctx.request.body.username;
    const pass = ctx.request.body.password;

    let passwordInput = ctx.helper.encrypt.rsaDecrypt(pass);

    let userInfo = await ctx.model.Internal.User.find({ userName: user });
    if (userInfo.lenght == 0) {
      ctx.throwBizError("USER_NOT_FOUND");
    } else if (userInfo.lenght > 1) {
      ctx.throwBizError("USER_CONFLICT");
    } else {
      // 數據庫中的 用戶密碼 也須要用加密後的字符串, 所以須要解密後與請求中的用戶信息作對比
      let passwordInDB = ctx.helper.encrypt.rsaDecrypt(userInfo[0].userPsw);
      if (passwordInput === passwordInDB) {
        // 用戶覈對成功後,生成新的 Token
        let newToken = ctx.helper.login.createToken({
          username: user,
          password: pass
        });
        // 更新數據庫中的 Token
        let userUpdated = await ctx.model.Internal.User.updateOne(
          { userName: user },
          { token: newToken }
        );

        if (
          userUpdated.n === 1 &&
          userUpdated.nModified === 1 &&
          userUpdated.ok === 1
        ) {
          ctx.body = ctx.helper.response.success({
            token: newToken
          });
        } else {
          ctx.throwBizError("FAILD_TO_LOGIN");
        }
      } else {
        ctx.throwBizError("USER_INFO_ERROR");
      }
    }
  }
  async signOut() {
    const { ctx } = this;
    const user = ctx.request.body.username;
    let userUpdated = await ctx.model.Internal.User.updateOne(
      { userName: user },
      { token: "" }
    );
    if (
      userUpdated.n === 1 &&
      userUpdated.nModified === 1 &&
      userUpdated.ok === 1
    ) {
      ctx.body = ctx.helper.response.success({
        message: `User ${user} has sign out.`
      });
    } else {
      ctx.body = ctx.helper.response.success({
        message: `Faild to sign out.`
      });
    }
  }
  async signUp() {
    const { ctx } = this;
  }
  async getPublicKey() {
    const { ctx } = this;
    ctx.body = ctx.helper.response.success(ctx.helper.encrypt.getPublicKey());
  }
}

module.exports = SignController;

複製代碼
2.3 加密/解密的關鍵代碼

上面兩個部分的使用到的 token 加密/解密,密碼 加密/解密 等方法我都是掛載在 helper 對象下的,爲了方便維護和調用,

// 
// 爲了方便維護,不少工具性的方法,我都掛載在 helper 下
//
// app\extend\helper.js

const login = require("../public/js/login");
const encrypt = require("../public/js/encrypt");

module.exports = {
  login,
  encrypt
};
複製代碼
//
// 用於加密和解密用戶密碼
// app\public\js\encrypt.js
//

const fs = require("fs");
const path = require("path");
const JSEncrypt = require("node-jsencrypt");

/** * Encrypt with the public key... * @param {String} text * @param {String} publicKey * @returns ciphertext */
exports.rsaEncrypt = text => {
  const _publicKey = fs.readFileSync(
    path.join(__dirname, "./../files/ssh-key/rsa_public_key.pem")
  );
  let encrypt = new JSEncrypt();
  encrypt.setPublicKey(_publicKey.toString());
  let encrypted = encrypt.encrypt(text);
  return encrypted;
};

/** * Decrypt with the private key... * @param {String} ciphertext * @param {String} privateKey * @returns text */
exports.rsaDecrypt = ciphertext => {
  const _privateKey = fs.readFileSync(
    path.join(__dirname, "./../files/ssh-key/rsa_private_key.pem")
  ); // 公鑰,看後面生成方法
  let decrypt = new JSEncrypt();
  decrypt.setPrivateKey(_privateKey.toString());
  let uncrypted = decrypt.decrypt(ciphertext);
  return uncrypted;
};

exports.getPublicKey = () => {
  let _publicKey = fs.readFileSync(
    path.join(__dirname, "./../files/ssh-key/rsa_public_key.pem")
  );
  _publicKey = _publicKey.toString();
  _publicKey = _publicKey.split("\r\n");
  _publicKey = _publicKey.join("");

  return _publicKey.toString();
};
複製代碼
//
// 用於加密和解密 Token
// app\public\js\login.js
//

const fs = require("fs");
const path = require("path");
const jwt = require("jsonwebtoken"); //引入jsonwebtoken

exports.createToken = (data, expires = 7200) => {
  const exp = Math.floor(Date.now() / 1000) + expires;
  const cert = fs.readFileSync(
    path.join(__dirname, "./../files/ssh-key/rsa_private_key.pem")
  ); // 私鑰,看後面生成方法

  const token = jwt.sign({ data, exp }, cert, { algorithm: "RS256" });
  return token;
};

// 解密,驗證
exports.verifyToken = token => {
  const cert = fs.readFileSync(
    path.join(__dirname, "./../files/ssh-key/rsa_public_key.pem")
  ); // 公鑰,看後面生成方法
  let res = "";

  try {
    const result = jwt.verify(token, cert, { algorithms: ["RS256"] }) || {};
    const { exp } = result,
      current = Math.floor(Date.now() / 1000);
    res = result.data || {};
    current <= exp ? (result.data["exp"] = false) : (result.data["exp"] = true);
  } catch (e) {
    console.log(e);
  }
  return res;
};

複製代碼

4、總結

至此,在 Egg.js 中使用 JSON Web Token實現 用戶登錄 與 API Token 驗證功能所涉及到的代碼都已經介紹完了,但因爲此功能跟業務有關,可能直接使用代碼的可能性比較小,並且功能涉及前端,代碼的連續性可能不方便復現。

總之,雖然 egg.js 官方文檔寫的很是詳盡,可是實踐過程當中,不免會有問題,但願有這個方面經驗的朋友多多分享。這也是算是本身的近期以爲有分享價值的東西。

文章參考了:

egg基於jsonwebtoken的Token實現認證機制

阮一峯的博客:JSON Web Token入門教程

相關文章
相關標籤/搜索