Egg.js 基於 jsonwebtoken 的 Token 實現系統登陸與介面認證
一、概述
本文,主要是對在 Egg.js 框架下如何使用JSON Web Token.js 生成的 Toke 實現使用者登陸認證。
JSON Web Token(縮寫 JWT)是目前最流行的跨域認證解決方案。其簡要介紹如下:
Json web token(JWT)是為了網路應用環境間傳遞宣告而執行的一種基於JSON的開發標準(RFC 7519),該token被設計為緊湊且安全的,特別適用於分散式站點的單點登陸(SSO)場景。JWT的宣告一般被用來在身份提供者和服務提供者間傳遞被認證的使用者身份資訊,以便於從資源伺服器獲取資源,也可以增加一些額外的其它業務邏輯所必須的宣告資訊,該token也可直接被用於認證,也可被加密。
關於 JSON Web Token 的原理與特點,這篇文章不會做詳細的介紹,如果想對其做更多的探究,不妨參看寶藏男孩 阮一峰的部落格:JSON Web Token入門教程
檢視這篇文章的讀者,應該要對 Egg.js 有一定的瞭解。
二、實現思路與相關資源
1、實現思路
首先文章所實現的功能只是簡單的使用者資訊驗證,對使用者系統許可權分配,單點登入等更高階複雜的功能,並未加入考慮。因此,基於此特點,對此功能的實現思路分析如下:
此外,除了使用者登入需要驗證之外,所有需要被保護的介面需要必須,通過Token資訊驗證,才能獲取相關結果。在 Egg.js 中, 這個驗證的過程是放在中介軟體中實現的。
我的習慣是在做弄個功能之前,儘可能去了解其中會涉及到的概念,實現流程,因此文章在程式碼開始之前總是會出現類似這種分析的過程,如果有什麼不好的地方,歡迎各位指正。
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. 使用者登陸
(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,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;
};
複製程式碼
這裡 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,{ 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 官方檔案寫的非常詳盡,但是實踐過程中,難免會有問題,希望有這個方面經驗的朋友多多分享。這也是算是自己的近期覺得有分享價值的東西。
文章參考了: