Egg.js 基于 jsonwebtoken 的 Token 实现系统登陆与接口认证

4,420 阅读6分钟

一、概述

本文,主要是对在 Egg.js 框架下如何使用JSON Web Token.js 生成的 Toke 实现用户登陆认证。

JSON Web Token(缩写 JWT)是目前最流行的跨域认证解决方案。其简要介绍如下:

JWT

Json web token(JWT)是为了网络应用环境间传递声明而执行的一种基于JSON的开发标准(RFC 7519),该token被设计为紧凑且安全的,特别适用于分布式站点的单点登陆(SSO)场景。JWT的声明一般被用来在身份提供者和服务提供者间传递被认证的用户身份信息,以便于从资源服务器获取资源,也可以增加一些额外的其它业务逻辑所必须的声明信息,该token也可直接被用于认证,也可被加密。

关于 JSON Web Token 的原理与特点,这篇文章不会做详细的介绍,如果想对其做更多的探究,不妨参看宝藏男孩 阮一峰的博客:JSON Web Token入门教程

查看这篇文章的读者,应该要对 Egg.js 有一定的了解。

二、实现思路与相关资源

1、实现思路

首先文章所实现的功能只是简单的用户信息验证,对用户系统权限分配,单点登录等更高级复杂的功能,并未加入考虑。因此,基于此特点,对此功能的实现思路分析如下:

Function Workflow

此外,除了用户登录需要验证之外,所有需要被保护的接口需要必须,通过Token信息验证,才能获取相关结果。在 Egg.js 中, 这个验证的过程是放在中间件中实现的。

Function Workflow 2

我的习惯是在做弄个功能之前,尽可能去了解其中会涉及到的概念,实现流程,因此文章在代码开始之前总是会出现类似这种分析的过程,如果有什么不好的地方,欢迎各位指正。

2、相关资源

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

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

需要注意,私钥是不可公开的,否则安全性无法等到保障。

三、代码实现

​ 后端框架: 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;
};

四、总结

至此,在 Egg.js 中使用 JSON Web Token实现 用户登陆 与 API Token 验证功能所涉及到的代码都已经介绍完了,但由于此功能跟业务有关,可能直接使用代码的可能性比较小,而且功能涉及前端,代码的连续性可能不方便复现。

总之,虽然 egg.js 官方文档写的非常详尽,但是实践过程中,难免会有问题,希望有这个方面经验的朋友多多分享。这也是算是自己的近期觉得有分享价值的东西。

文章参考了:

egg基于jsonwebtoken的Token实现认证机制

阮一峰的博客:JSON Web Token入门教程