基於 Egg.js 框架的 Node.js 服務構建之用戶管理設計


前言

近來公司需要構建一套 EMM(Enterprise Mobility Management)的管理平台,就這種面向企業的應用管理本身需要考慮的需求是十分復雜的,技術層面管理端和服務端構建是架構核心,客戶端本身初期倒不需要那么復雜,作為移動端的負責人(其實也就是一個打雜的小組長),這個平台架構我自然是免不了去參與的,作為一個前端 jser 來公司這邊總是接到這種不太像前端的工作,要是以前我可能會有些抵觸這種業務層面需要考慮的很多,技術實現本身又不太容易積累技術成長的活。這一年我成長了太多,總是嘗試着去做一些可能自己談不上喜歡但還是有意義的事情,所以這次接手這個任務還是想好好把這個事情做好,所以想考慮參與到 EMM 服務端構建。其實話又說回來,任何事只要想去把它做好,怎么會存在有意義還是沒意義的區別呢?

考慮到基於 Node.js 構建的服務目前越來越流行,也方便后續放在平台容器雲上構建微服務,另外作為一個前端 jser 出身的程序員,使用 Node.js 來構建服務格外熟悉。之前學習過一段時間 Egg.js,這次毫不猶豫的選擇了基於 Egg.js 框架來搭建。

為什么是 Egg.js ?

去年在 gitchat JavaScript 進階之 Vue.js + Node.js 入門實戰開發 中安利過 Egg.js,那個時候是初接觸 Egg.js,但是還是被它驚艷到了,Egg 繼承於 Koa,奉行『約定優於配置』,按照一套統一的約定進行應用開發,插件機制也比較完善。雖然說 Egg 繼承於 Koa,大家可能覺得完全可以自己基於 Koa 去實現一套,沒必要基於這個框架去搞,但是其實自己去設計一套這樣的框架,最終也是需要去借鑒各家所長,時間成本上短期是划不來的。Koa 是一個小而精的框架,而 Egg 正如文檔說的為企業級框架和應用而生,對於我們快速搭建一個完備的企業級應用還是很方便的。Egg 功能已經比較完善,另外如果沒有實現的功能,自己根據 Koa 社區提供的插件封裝一下也是不難的。

ORM 設計選型

在數據庫選擇上本次項目考慮使用 MySQL,而不是 MongoDB,開始使用的是 egg-mysql 插件,寫了一部分后發現 service 里面寫了太多東西,表字段修改會影響太多代碼,在設計上缺乏對 Model 的管理,看到資料說可以引入 ORM 框架,比如 sequelize,而 Egg 官方恰好提供了 egg-sequelize 插件。

什么是 ORM ?

首先了解一下什么是 ORM ?

對象關系映射(英語:Object Relational Mapping,簡稱 ORM,或 O/RM,或 O/R mapping),是一種程序設計技術,用於實現面向對象編程語言里不同類型系統的數據之間的轉換。從效果上說,它其實是創建了一個可在編程語言里使用的“虛擬對象數據庫”。

類似於 J2EE 中的 DAO 設計模式,將程序中的數據對象自動地轉化為關系型數據庫中對應的表和列,數據對象間的引用也可以通過這個工具轉化為表。這樣就可以很好的解決我遇到的那個問題,對於表結構修改和數據對象操作是兩個獨立的部分,從而使得代碼更好維護。其實是否選擇 ORM 框架,和以前前端是選擇模板引擎還是手動拼字符串一樣,ORM 框架避免了在開發的時候手動拼接 SQL 語句,可以防止 SQL 注入,另外也將數據庫和數據 CRUD 解耦,更換數據庫也相對更容易。

sequelize 框架

sequelize 是 Node.js 社區比較流行的一個 ORM 框架,相關文檔:

sequelize 使用

安裝:

$ npm install --save sequelize

建立連接:

const Sequelize = require("sequelize"); // 完整用法 const sequelize = new Sequelize("database", "username", "password", { host: "localhost", dialect: "mysql" | "sqlite" | "postgres" | "mssql", operatorsAliases: false, pool: { max: 5, min: 0, acquire: 30000, idle: 10000 }, // SQLite only storage: "path/to/database.sqlite" }); // 簡單用法 const sequelize = new Sequelize("postgres://user:pass@example.com:5432/dbname");

校驗連接是否正確:

sequelize
  .authenticate()
  .then(() => { console.log("Connection has been established successfully."); }) .catch(err => { console.error("Unable to connect to the database:", err); });

定義 Model :

定義一個 Model 的基本語法:

sequelize.define("name", { attributes }, { options });

例如:

const User = sequelize.define("user", { username: { type: Sequelize.STRING }, password: { type: Sequelize.STRING } });

對於一個 Model 字段類型設計,主要考慮以下幾個方面:

Sequelize 默認會添加 createdAt 和 updatedAt,這樣可以很方便的知道數據創建和更新的時間。如果不想使用可以通過設置 attributes 的 timestamps: false;

Sequelize 支持豐富的數據類型,例如:STRING、CHAR、TEXT、INTEGER、FLOAT、DOUBLE、BOOLEAN、DATE、UUID
、JSON 等多種不同的數據類型,具體可以看文檔:DataTypes

Getters & setters 支持,當我們需要對字段進行處理的時候十分有用,例如:對字段值大小寫轉換處理。

const Employee = sequelize.define("employee", { name: { type: Sequelize.STRING, allowNull: false, get() { const title = this.getDataValue("title"); return this.getDataValue("name") + " (" + title + ")"; } }, title: { type: Sequelize.STRING, allowNull: false, set(val) { this.setDataValue("title", val.toUpperCase()); } } });

字段校驗有兩種類型:非空校驗及類型校驗,Sequelize 中非空校驗通過字段的 allowNull 屬性判定,類型校驗是通過 validate 進行判定,底層是通過 validator.js 實現的。如果模型的特定字段設置為允許 null(allowNull:true),並且該值已設置為 null,則 validate 屬性不生效。例如,有一個字符串字段,allowNull 設置為 true,validate 驗證其長度至少為 5 個字符,但也允許為空。

const ValidateMe = sequelize.define("foo", { foo: { type: Sequelize.STRING, validate: { is: ["^[a-z]+$", "i"], // will only allow letters is: /^[a-z]+$/i, // same as the previous example using real RegExp not: ["[a-z]", "i"], // will not allow letters isEmail: true, // checks for email format (foo@bar.com) isUrl: true, // checks for url format (http://foo.com) isIP: true, // checks for IPv4 (129.89.23.1) or IPv6 format isIPv4: true, // checks for IPv4 (129.89.23.1) isIPv6: true, // checks for IPv6 format isAlpha: true, // will only allow letters isAlphanumeric: true, // will only allow alphanumeric characters, so "_abc" will fail isNumeric: true, // will only allow numbers isInt: true, // checks for valid integers isFloat: true, // checks for valid floating point numbers isDecimal: true, // checks for any numbers isLowercase: true, // checks for lowercase isUppercase: true, // checks for uppercase notNull: true, // won't allow null isNull: true, // only allows null notEmpty: true, // don't allow empty strings equals: "specific value", // only allow a specific value contains: "foo", // force specific substrings notIn: [["foo", "bar"]], // check the value is not one of these isIn: [["foo", "bar"]], // check the value is one of these notContains: "bar", // don't allow specific substrings len: [2, 10], // only allow values with length between 2 and 10 isUUID: 4, // only allow uuids isDate: true, // only allow date strings isAfter: "2011-11-05", // only allow date strings after a specific date isBefore: "2011-11-05", // only allow date strings before a specific date max: 23, // only allow values <= 23 min: 23, // only allow values >= 23 isCreditCard: true, // check for valid credit card numbers // custom validations are also possible: isEven(value) { if (parseInt(value) % 2 != 0) { throw new Error("Only even values are allowed!"); // we also are in the model's context here, so this.otherField // would get the value of otherField if it existed } } } } });

最后我們說明一個最重要的字段主鍵 id 的設計, 需要通過字段 primaryKey: true 指定為主鍵。MySQL 里面主鍵設計主要有兩種方式:自動遞增UUID

自動遞增設置 autoIncrement: true 即可,對於一般的小型系統這種方式是最方便,查詢效率最高的,但是這種不利於分布式集群部署,這種基本用過 MySQL 里面應用都用過,這里不做深入討論。

UUID, 又名全球獨立標識(Globally Unique Identifier),UUID 是 128 位(長度固定)unsigned integer, 能夠保證在空間(Space)與時間(Time)上的唯一性。而且無需注冊機制保證, 可以按需隨時生成。據 WIKI, 隨機算法生成的 UUID 的重復概率為 170 億分之一。Sequelize 數據類型中有 UUID,UUID1,UUID4 三種類型,基於node-uuid 遵循 RFC4122。例如:

const User = sequelize.define("user", { id: { type: Sequelize.UUID, primaryKey: true, allowNull: false, defaultValue: Sequelize.UUID1 } });

這樣 id 默認值生成一個 uuid 字符串,例如:'1c572360-faca-11e7-83ee-9d836d45ff41',很多時候我們不太想要這個 -字符,我們可以通過設置 defaultValue 實現,例如:

const uuidv1 = require("uuid/v1"); const User = sequelize.define("user", { id: { type: Sequelize.UUID, primaryKey: true, allowNull: false, defaultValue: function() { return uuidv1().replace(/-/g, ""); } } });

使用 Model 對象:

對於 Model 對象操作,Sequelize 提供了一系列的方法:

  • find:搜索數據庫中的一個特定元素,可以通過 findById 或 findOne;
  • findOrCreate:搜索特定元素或在不可用時創建它;
  • findAndCountAll:搜索數據庫中的多個元素,返回數據和總數;
  • findAll:在數據庫中搜索多個元素;
  • 復雜的過濾/ OR / NOT 查詢;
  • 使用 limit(限制),offset(偏移量),order(順序)和 group(組)操作數據集;
  • count:計算數據庫中元素的出現次數;
  • max:獲取特定表格中特定屬性的最大值;
  • min:獲取特定表格中特定屬性的最小值;
  • sum:特定屬性的值求和;
  • create:創建數據庫 Model 實例;
  • update:更新數據庫 Model 實例;
  • destroy:銷毀數據庫 Model 實例。

通過上述提供的一系列方法可以實現數據的增刪改查(CRUD),例如:

User.create({ username: "fnord", job: "omnomnom" }) .then(() => User.findOrCreate({ where: { username: "fnord" }, defaults: { job: "something else" } }) ) .spread((user, created) => { console.log( user.get({ plain: true }) ); console.log(created); /* In this example, findOrCreate returns an array like this: [ { username: 'fnord', job: 'omnomnom', id: 2, createdAt: Fri Mar 22 2013 21: 28: 34 GMT + 0100(CET), updatedAt: Fri Mar 22 2013 21: 28: 34 GMT + 0100(CET) }, false ] */ });

egg-sequelize 插件

文檔:egg-sequelize:https://github.com/eggjs/egg-sequelize

源碼簡析

這里我們暫時先不分析 egg 插件規范,暫時先只看看 egg-sequelize/lib/loader.js 里面的實現:

"use strict"; const path = require("path"); const Sequelize = require("sequelize"); const MODELS = Symbol("loadedModels"); const chalk = require("chalk"); Sequelize.prototype.log = function() { if (this.options.logging === false) { return; } const args = Array.prototype.slice.call(arguments); const sql = args[0].replace(/Executed \(.+?\):\s{0,1}/, ""); this.options.logging.info("[model]", chalk.magenta(sql), `(${args[1]}ms)`); }; module.exports = app => { const defaultConfig = { logging: app.logger, host: "localhost", port: 3306, username: "root", benchmark: true, define: { freezeTableName: false, underscored: true } }; const config = Object.assign(defaultConfig, app.config.sequelize); app.Sequelize = Sequelize; const sequelize = new Sequelize( config.database, config.username, config.password, config ); // app.sequelize Object.defineProperty(app, "model", { value: sequelize, writable: false, configurable: false }); loadModel(app); app.beforeStart(function*() { yield app.model.authenticate(); }); }; function loadModel(app) { const modelDir = path.join(app.baseDir, "app/model"); app.loader.loadToApp(modelDir, MODELS, { inject: app, caseStyle: "upper", ignore: "index.js" }); for (const name of Object.keys(app[MODELS])) { const klass = app[MODELS][name]; // only this Sequelize Model class if ("sequelize" in klass) { app.model[name] = klass; if ( "classMethods" in klass.options || "instanceMethods" in klass.options ) { app.logger .error(`${name} model has classMethods/instanceMethods, but it was removed supports in Sequelize V4.\ see: http://docs.sequelizejs.com/manual/tutorial/models-definition.html#expansion-of-models`); } } } for (const name of Object.keys(app[MODELS])) { const klass = app[MODELS][name]; if ("associate" in klass) { klass.associate(); } } }

很明顯在插件初始化的時候進行了 Sequelize 對象的實例化,並將 Sequelize 對象掛載在 app 對象下,即我們可以通過 app.Sequelize 訪問 Sequelize 對象,同時我們可以通過 app.model 對 Sequelize 實例化進行訪問,app/model 文件夾下存放 model 對象文件。

用戶 Model 設計

這里我們以 egg-sequelize 的使用為例加以說明。

安裝:

$ npm i --save egg-sequelize
$ npm install --save mysql2 # For both mysql and mariadb dialects

配置:

app/config/plugin.js 配置:

exports.sequelize = {
  enable: true, package: "egg-sequelize" };

app/config/config.default.js 配置:

// 數據庫信息配置 exports.sequelize = { // 數據庫類型 dialect: "mysql", // host host: "localhost", // 端口號 port: "3306", // 用戶名 username: "root", // 密碼 password: "xxx", // 數據庫名 database: "AEMM" };

Model 層:

直接使用 Sequelize 雖然可以,但是存在一些問題。團隊開發時,有人喜歡自己加 timestamp,有人又喜歡自增主鍵,並且自定義表名。一個大型 Web App 通常都有幾十個映射表,一個映射表就是一個 Model。如果按照各自喜好,那業務代碼就不好寫。Model 不統一,很多代碼也無法復用。所以我們需要一個統一的模型,強迫所有 Model 都遵守同一個規范,這樣不但實現簡單,而且容易統一風格。

我們首先要定義的就是 Model 存放的文件夾必須在 models 內,並且以 Model 名字命名,例如:Pet.js,User.js 等等。其次,每個 Model 必須遵守一套規范:

  • 統一主鍵,名稱必須是 id,類型必須是 UUID;
  • 所有字段默認為 NULL,除非顯式指定;
  • 統一 timestamp 機制,每個 Model 必須有 createdAt、updatedAt 和 version,分別記錄創建時間、修改時間和版本號。

所以,我們不要直接使用 Sequelize 的 API,而是通過 db.js 間接地定義 Model。例如,User.js 應該定義如下:

app/db.js:

const uuidv1 = require("uuid/v1"); function generateUUID() { return uuidv1().replace(/-/g, ""); } function defineModel(app, name, attributes) { const { UUID } = app.Sequelize; let attrs = {}; for (let key in attributes) { let value = attributes[key]; if (typeof value === "object" && value["type"]) { value.allowNull = value.allowNull && true; attrs[key] = value; } else { attrs[key] = { type: value, allowNull: true }; } } attrs.id = { type: UUID, primaryKey: true, defaultValue: () => { return generateUUID(); } }; return app.model.define(name, attrs, { createdAt: "createdAt", updatedAt: "updatedAt", version: true, freezeTableName: true }); } module.exports = { defineModel };

我們定義的 defineModel 就是為了強制實現上述規則。

app/model/User.js:

const db = require("../db"); module.exports = app => { const { STRING, INTEGER, DATE, BOOLEAN } = app.Sequelize; const User = db.defineModel(app, "users", { username: { type: STRING, unique: true, allowNull: false }, // 用戶名 email: { type: STRING, unique: true, allowNull: false }, // 郵箱 password: { type: STRING, allowNull: false }, // 密碼 name: STRING, // 姓名 sex: INTEGER, // 用戶性別:1男性, 2女性, 0未知 age: INTEGER, // 年齡 avatar: STRING, // 頭像 company: STRING, // 公司 department: STRING, // 部門 telePhone: STRING, // 聯系電話 mobilePhone: STRING, // 手機號碼 info: STRING, // 備注說明 roleId: STRING, // 角色id status: STRING, // 用戶狀態 token: STRING, // 認證 token lastSignInAt: DATE // 上次登錄時間 }); return User; };

在數據庫操作設計中,我們一般是通過腳本提前生成表結構,如果手動寫創建表的 SQL,每次修改表結構其實是一件麻煩事。Sequelize 提供了Migrations 幫助創建或遷移數據庫,egg-sequelize 里面也提供了方便的方法。如果是開發階段,可以使用下面的方法自動執行:

// {app_root}/app.js module.exports = app => { if (app.config.env === "local") { app.beforeStart(function*() { yield app.model.sync({ force: true }); }); } };

當然也可以在 package.json 里面添加下面的腳本:

命令 說明
npm run migrate:new 在 ./migrations/ 中創建一個 遷移文件 to
npm run migrate:up 執行遷移
npm run migrate:down 回滾一次遷移

package.json:

...
"scripts": { "migrate:new": "egg-sequelize migration:create --name init", "migrate:up": "egg-sequelize db:migrate", "migrate:down": "egg-sequelize db:migrate:undo" } ...

執行 npm run migrate:new 后修改 migrations 文件夾下的文件:

module.exports = { async up(queryInterface, Sequelize) { const { UUID, STRING, INTEGER, DATE, BOOLEAN } = Sequelize; await queryInterface.createTable("users", { id: { type: UUID, primaryKey: true, allowNull: false }, // 用戶 ID(主鍵) username: { type: STRING, unique: true, allowNull: false }, // 用戶名 email: { type: STRING, unique: true, allowNull: false }, // 郵箱 password: { type: STRING, allowNull: false }, // 登錄密碼 name: STRING, // 姓名 age: INTEGER, // 用戶年齡 info: STRING, // 備注說明 sex: INTEGER, // 用戶性別:1男性, 2女性, 0未知 telePhone: STRING, // 聯系電話 mobilePhone: STRING, // 手機號碼 roleId: STRING, // 角色ID location: STRING, // 常住地 avatar: STRING, // 頭像 company: STRING, // 公司 department: STRING, // 部門 emailVerified: BOOLEAN, // 郵箱驗證 token: STRING, // 身份認證令牌 status: { type: INTEGER, allowNull: false }, // 用戶狀態:1啟用, 0禁用, 2隱藏, 3刪除 createdAt: DATE, // 用戶創建時間 updatedAt: DATE, // 用戶信息更新時間 lastSignInAt: DATE // 上次登錄時間 }); }, async down(queryInterface, Sequelize) { await queryInterface.dropTable("users"); } };

用戶認證選型

所謂用戶認證(Authentication),就是讓用戶登錄,並且在接下來的一段時間內讓用戶訪問網站時可以使用其賬戶,而不需要再次登錄的機制。

小知識:可別把用戶認證和用戶授權(Authorization)搞混了。用戶授權指的是規定並允許用戶使用自己的權限,例如發布帖子、管理站點等。

用戶認證主要分為兩個部分:

  • 用戶通過用戶名和密碼登錄生成並且獲取 Token;
  • 用戶通過 Token 驗證用戶身份獲取相關信息。

JSON Web Token(JWT)規范

JSON Web Token(JWT)是一個非常輕巧的規范。這個規范允許我們使用 JWT 在用戶和服務器之間傳遞安全可靠的信息。

JWT 的組成

一個 JWT 實際上就是一個字符串,它由三部分組成,頭部、載荷與簽名。

頭部(Header)

JWT 需要一個頭部,頭部用於描述關於該 JWT 的最基本的信息,例如其類型以及簽名所用的算法等。這也可以被表示成一個 JSON 對象。

{
  "typ": "JWT", "alg": "HS256" }

在這里,我們說明了這是一個 JWT,並且我們所用的簽名算法是 HS256 算法。對它也要進行 Base64 編碼,之后的字符串就成了 JWT 的 Header(頭部)。

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

這里我們使用 base64url 模塊進行 Base64 編碼來得到這個字符串,測試代碼如下:

const base64url = require("base64url"); let header = { typ: "JWT", alg: "HS256" }; console.log("header: " + base64url(JSON.stringify(header))); // header: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9
小知識:Base64 是一種編碼,也就是說,它是可以被翻譯回原來的樣子來的。它並不是一種加密過程。

載荷(Payload)

說白了就是我們需要包含的數據,類似於網絡請求的請求體 body,例如:

{
  "iss": "zhaomenghaun", "sub": "*@agree.com.cn", "aud": "www.agree.com.cn", "exp": 1526875179, "iat": 1526871579, "id": "49a9dd505c9d11e8b5e86b9776bb3c4f" }

這里面的前五個字段都是由 JWT 的標准所定義的。

  • iss: 該 JWT 的簽發者
  • sub: 該 JWT 所面向的用戶
  • aud: 接收該 JWT 的一方
  • exp(expires): 什么時候過期,這里是一個 Unix 時間戳
  • iat(issued at): 在什么時候簽發的

將下面的 JSON 對象進行base64 編碼可以得到下面的字符串,這個字符串我們將它稱作 JWT 的 Payload(載荷)。

const base64url = require("base64url"); let payload = { id: "49a9dd505c9d11e8b5e86b9776bb3c4f", iat: 1526871579, exp: 1526875179 }; console.log("payload: " + base64url(JSON.stringify(payload))); // payload: eyJpZCI6IjQ5YTlkZDUwNWM5ZDExZThiNWU4NmI5Nzc2YmIzYzRmIiwiaWF0IjoxNTI2ODcxNTc5LCJleHAiOjE1MjY4NzUxNzl9

簽名(Signature)

將上面的兩個編碼后的字符串都用句號.連接在一起(頭部在前),就形成了:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjQ5YTlkZDUwNWM5ZDExZThiNWU4NmI5Nzc2YmIzYzRmIiwiaWF0IjoxNTI2ODcxNTc5LCJleHAiOjE1MjY4NzUxNzl9

最后,我們將上面拼接完的字符串用 HS256 算法進行加密。在加密的時候,我們還需要提供一個密鑰(secret)。我們可以使用 node-jwa 進行 HS256 算法加密。如果我們用 123456 作為密鑰的話,那么就可以得到我們加密后的內容,這一部分又叫做簽名。最后一步簽名的過程,實際上是對頭部以及載荷內容進行簽名。

const jwa = require("jwa"); const hmac = jwa("HS256"); let secret = "123456"; const signature = hmac.sign( "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjQ5YTlkZDUwNWM5ZDExZThiNWU4NmI5Nzc2YmIzYzRmIiwiaWF0IjoxNTI2ODcxNTc5LCJleHAiOjE1MjY4NzUxNzl9", secret ); console.log("signature: " + signature); // signature: JtrTx9QaN3BD1QkZhY58MTu6WHn_vQwRBxO9VwJgkhE

最后將這一部分簽名也拼接在被簽名的字符串后面,我們就得到了完整的 JWT,如下:

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjQ5YTlkZDUwNWM5ZDExZThiNWU4NmI5Nzc2YmIzYzRmIiwiaWF0IjoxNTI2ODcxNTc5LCJleHAiOjE1MjY4NzUxNzl9.JtrTx9QaN3BD1QkZhY58MTu6WHn_vQwRBxO9VwJgkhE

整個完整過程走下來我們需要思考一下問題,Token 是否安全,是否可以傳輸敏感信息?

我們現在明白了一個 token 是由 Header 的 Base64 編碼 + Payload 的 Base64 編碼 + Signature 三段組成,當其他人拿到我們的 Token,可以通過 Token 前兩段 Base64 解碼得到 Header 和 Payload 對象,這里我們通過 node-jsonwebtoken 模塊 decode 方法直接 "破解" 我們的 Token。

const jwt = require("jsonwebtoken"); let decoded = jwt.decode( "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjQ5YTlkZDUwNWM5ZDExZThiNWU4NmI5Nzc2YmIzYzRmIiwiaWF0IjoxNTI2ODcxNTc5LCJleHAiOjE1MjY4NzUxNzl9.JtrTx9QaN3BD1QkZhY58MTu6WHn_vQwRBxO9VwJgkhE", { complete: true } ); console.log("jsonwebtoken: " + JSON.stringify(decoded)); // jsonwebtoken: {"header":{"typ":"JWT","alg":"HS256"},"payload":{"id":"49a9dd505c9d11e8b5e86b9776bb3c4f","iat":1526871579,"exp":1526875179},"signature":"JtrTx9QaN3BD1QkZhY58MTu6WHn_vQwRBxO9VwJgkhE"}

所以我們的 payload 不能里面不能包含諸如密碼這種敏感信息,對於我們這里的 id 是一串 uuid,即使拿到也無法直接判定相關內容,從而不會直接泄露我們的內容。

一般而言,加密算法對於不同的輸入產生的輸出總是不一樣的。對於兩個不同的輸入,產生同樣的輸出的概率極其地小。如果有人對頭部以及載荷的內容解碼之后進行修改,再進行編碼的話,那么新的頭部和載荷的簽名和之前的簽名就將是不一樣的,而且如果不知道服務器加密的時候用的密鑰的話,得出來的簽名也一定會是不一樣的。

所以服務端拿到 JWT 后,首先會校驗簽名是否過期,以及對頭部和載荷的內容用同一算法(通過 JWT 的頭部 alg 字段指定)再次簽名得到的 JWT 和用戶傳遞的 JWT 是否一致。如果服務器應用對頭部和載荷再次以同樣方法簽名之后發現,自己計算出來的簽名和接受到的簽名不一樣,那么就說明這個 Token 的內容被別人動過的,我們應該拒絕這個 Token,返回一個 HTTP 401 Unauthorized 響應。

egg-jwt 插件

文檔:https://github.com/okoala/egg-jwt

egg-jwt 基於 node-jsonwebtoken 實現,完整文檔可以參考 https://github.com/auth0/node-jsonwebtoken。jwt 對象掛載在 app 對象下,可以通過 app.jwt 訪問 jwt 的三個方法:

  • jwt.sign(payload, secretOrPrivateKey, [options, callback])————生成 token 字符串
  • jwt.verify(token, secretOrPublicKey, [options, callback])————校驗 token 合法性
  • jwt.decode(token [, options])————token 譯碼

安裝:

$ npm i egg-jwt --save

配置:

app/config/plugin.js 配置:

exports.jwt = {
  enable: true, package: "egg-jwt" };

app/config/config.default.js 配置:

exports.jwt = {
  enable: false, secret: "xxxxxxxxxxxxx" };

調用:

請求頭:

Authorization: Bearer {access_token}

注:access_token 為登錄后返回的 token 值。

app/service/user.js:

/** * 生成 Token * @param {Object} data */ createToken(data) { return app.jwt.sign(data, app.config.jwt.secret, { expiresIn: "12h" }); } /** * 驗證token的合法性 * @param {String} token */ verifyToken(token) { return new Promise((resolve, reject) => { app.jwt.verify(token, app.config.jwt.secret, function(err, decoded) { let result = {}; if (err) { /* err = { name: 'TokenExpiredError', message: 'jwt expired', expiredAt: 1408621000 } */ result.verify = false; result.message = err.message; } else { result.verify = true; result.message = decoded; } resolve(result); }); }); }

extend/helper.js:

// 獲取 Token exports.getAccessToken = ctx => { let bearerToken = ctx.request.header.authorization; return bearerToken && bearerToken.replace("Bearer ", ""); }; // 校驗 Token exports.verifyToken = async (ctx, userId) => { let token = this.getAccessToken(ctx); let verifyResult = await ctx.service.user.verifyToken(token); if (!verifyResult.verify) { ctx.helper.error(ctx, 401, verifyResult.message); return false; } if (userId != verifyResult.message.id) { ctx.helper.error(ctx, 401, "用戶 ID 與 Token 不一致"); return false; } return true; }; // 處理成功響應 exports.success = (ctx, result = null, message = "請求成功", status = 200) => { ctx.body = { code: 0, message: message, data: result }; ctx.status = status; }; // 處理失敗響應 exports.error = (ctx, code, message) => { ctx.body = { code: code, message: message }; ctx.status = code; };

controller 中調用:

// 生成Token let token = ctx.service.user.createToken({ id: user.id }); // 校驗Token合法性 let isVerify = await ctx.helper.verifyToken(ctx, id); if (isVerify) { // 合法邏輯 // ... }

這樣對於需要進行身份認證的 restful API,就可以通過 token 進行認證,從而實現用戶認證和授權。

后記

本文原本是想通過用戶管理的設計來說明在構建 Node.js 服務過程遇到的問題以及收獲,太久沒有寫文章,思維一時無法發散,只能平鋪直敘在設計過程用到的插件的基本用法和一些設計上的思考,發出來不求能夠助人,但求能夠幫助自己梳理清楚思路,寫完發現自己的認知也確實明晰了很多,很多之前的疑惑豁然開朗。

很多沒有寫文章了,這半年來主要負責混合式移動端架構設計和模塊開發的工作,摸爬滾打快一年,主要精力都花在做下面這一套 JS SDK 和原生基座。

這半年看了很多框架源碼,也嘗試寫了一些基本架構和內部文檔和筆記,但是沒有在開源社區總結和分享,回頭看終究有些遺憾,雖然可以拿一直很忙沒時間去安慰自己,但是回過頭來看其實時間擠一下也還是有的,所以后續將抽出更多時間去歸檔,畢竟寫出來真的會理解的更深刻。

參考


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM