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

前言

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

考慮到基於 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 社區提供的插件封裝一下也是不難的。node

ORM 設計選型

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

什麼是 ORM ?

首先了解一下什麼是 ORM ?git

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

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

sequelize 框架

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

sequelize 使用

安裝:算法

$ npm install --save sequelize
複製代碼

創建鏈接:sql

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:github.com/eggjs/egg-s…

源碼簡析

這裏咱們暫時先不分析 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 插件

文檔:github.com/okoala/egg-…

egg-jwt 基於 node-jsonwebtoken 實現,完整文檔能夠參考 github.com/auth0/node-…。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 和原生基座。

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

參考

相關文章
相關標籤/搜索