日韩性视频-久久久蜜桃-www中文字幕-在线中文字幕av-亚洲欧美一区二区三区四区-撸久久-香蕉视频一区-久久无码精品丰满人妻-国产高潮av-激情福利社-日韩av网址大全-国产精品久久999-日本五十路在线-性欧美在线-久久99精品波多结衣一区-男女午夜免费视频-黑人极品ⅴideos精品欧美棵-人人妻人人澡人人爽精品欧美一区-日韩一区在线看-欧美a级在线免费观看

歡迎訪問 生活随笔!

生活随笔

當(dāng)前位置: 首頁 > 编程资源 > 编程问答 >内容正文

编程问答

基于 Egg.js 框架的 Node.js 服务构建之用户管理设计

發(fā)布時(shí)間:2024/4/13 编程问答 28 豆豆
生活随笔 收集整理的這篇文章主要介紹了 基于 Egg.js 框架的 Node.js 服务构建之用户管理设计 小編覺得挺不錯的,現(xiàn)在分享給大家,幫大家做個(gè)參考.
轉(zhuǎn)載需經(jīng)本人同意且標(biāo)注本文原始地址:https://zhaomenghuan.github.io/blog/nodejs-eggjs-usersytem.html

前言

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

考慮到基于 Node.js 構(gòu)建的服務(wù)目前越來越流行,也方便后續(xù)放在平臺容器云上構(gòu)建微服務(wù),另外作為一個(gè)前端 jser 出身的程序員,使用 Node.js 來構(gòu)建服務(wù)格外熟悉。之前學(xué)習(xí)過一段時(shí)間 Egg.js,這次毫不猶豫的選擇了基于 Egg.js 框架來搭建。

為什么是 Egg.js ?

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

ORM 設(shè)計(jì)選型

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

什么是 ORM ?

首先了解一下什么是 ORM ?

對象關(guān)系映射(英語:Object Relational Mapping,簡稱 ORM,或 O/RM,或 O/R mapping),是一種程序設(shè)計(jì)技術(shù),用于實(shí)現(xiàn)面向?qū)ο缶幊陶Z言里不同類型系統(tǒng)的數(shù)據(jù)之間的轉(zhuǎn)換。從效果上說,它其實(shí)是創(chuàng)建了一個(gè)可在編程語言里使用的“虛擬對象數(shù)據(jù)庫”。

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

sequelize 框架

sequelize 是 Node.js 社區(qū)比較流行的一個(gè) ORM 框架,相關(guān)文檔:

  • sequelize.js 文檔:http://docs.sequelizejs.com/

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 onlystorage: "path/to/database.sqlite" });// 簡單用法 const sequelize = new Sequelize("postgres://user:pass@example.com:5432/dbname");

校驗(yàn)連接是否正確:

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

定義 Model :

定義一個(gè) Model 的基本語法:

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

例如:

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

對于一個(gè) Model 字段類型設(shè)計(jì),主要考慮以下幾個(gè)方面:

Sequelize 默認(rèn)會添加 createdAt 和 updatedAt,這樣可以很方便的知道數(shù)據(jù)創(chuàng)建和更新的時(shí)間。如果不想使用可以通過設(shè)置 attributes 的 timestamps: false;

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

Getters & setters 支持,當(dāng)我們需要對字段進(jìn)行處理的時(shí)候十分有用,例如:對字段值大小寫轉(zhuǎn)換處理。

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());}} });

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

const ValidateMe = sequelize.define("foo", {foo: {type: Sequelize.STRING,validate: {is: ["^[a-z]+$", "i"], // will only allow lettersis: /^[a-z]+$/i, // same as the previous example using real RegExpnot: ["[a-z]", "i"], // will not allow lettersisEmail: 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 formatisIPv4: true, // checks for IPv4 (129.89.23.1)isIPv6: true, // checks for IPv6 formatisAlpha: true, // will only allow lettersisAlphanumeric: true, // will only allow alphanumeric characters, so "_abc" will failisNumeric: true, // will only allow numbersisInt: true, // checks for valid integersisFloat: true, // checks for valid floating point numbersisDecimal: true, // checks for any numbersisLowercase: true, // checks for lowercaseisUppercase: true, // checks for uppercasenotNull: true, // won't allow nullisNull: true, // only allows nullnotEmpty: true, // don't allow empty stringsequals: "specific value", // only allow a specific valuecontains: "foo", // force specific substringsnotIn: [["foo", "bar"]], // check the value is not one of theseisIn: [["foo", "bar"]], // check the value is one of thesenotContains: "bar", // don't allow specific substringslen: [2, 10], // only allow values with length between 2 and 10isUUID: 4, // only allow uuidsisDate: true, // only allow date stringsisAfter: "2011-11-05", // only allow date strings after a specific dateisBefore: "2011-11-05", // only allow date strings before a specific datemax: 23, // only allow values <= 23min: 23, // only allow values >= 23isCreditCard: 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}}}} });

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

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

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

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

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

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:搜索數(shù)據(jù)庫中的一個(gè)特定元素,可以通過 findById 或 findOne;
  • findOrCreate:搜索特定元素或在不可用時(shí)創(chuàng)建它;
  • findAndCountAll:搜索數(shù)據(jù)庫中的多個(gè)元素,返回?cái)?shù)據(jù)和總數(shù);
  • findAll:在數(shù)據(jù)庫中搜索多個(gè)元素;
  • 復(fù)雜的過濾/ OR / NOT 查詢;
  • 使用 limit(限制),offset(偏移量),order(順序)和 group(組)操作數(shù)據(jù)集;
  • count:計(jì)算數(shù)據(jù)庫中元素的出現(xiàn)次數(shù);
  • max:獲取特定表格中特定屬性的最大值;
  • min:獲取特定表格中特定屬性的最小值;
  • sum:特定屬性的值求和;
  • create:創(chuàng)建數(shù)據(jù)庫 Model 實(shí)例;
  • update:更新數(shù)據(jù)庫 Model 實(shí)例;
  • destroy:銷毀數(shù)據(jù)庫 Model 實(shí)例。

通過上述提供的一系列方法可以實(shí)現(xiàn)數(shù)據(jù)的增刪改查(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

源碼簡析

這里我們暫時(shí)先不分析 egg 插件規(guī)范,暫時(shí)先只看看 egg-sequelize/lib/loader.js 里面的實(shí)現(xiàn):

"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.sequelizeObject.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 classif ("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();}} }

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

用戶 Model 設(shè)計(jì)

這里我們以 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 配置:

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

Model 層:

直接使用 Sequelize 雖然可以,但是存在一些問題。團(tuán)隊(duì)開發(fā)時(shí),有人喜歡自己加 timestamp,有人又喜歡自增主鍵,并且自定義表名。一個(gè)大型 Web App 通常都有幾十個(gè)映射表,一個(gè)映射表就是一個(gè) Model。如果按照各自喜好,那業(yè)務(wù)代碼就不好寫。Model 不統(tǒng)一,很多代碼也無法復(fù)用。所以我們需要一個(gè)統(tǒng)一的模型,強(qiáng)迫所有 Model 都遵守同一個(gè)規(guī)范,這樣不但實(shí)現(xiàn)簡單,而且容易統(tǒng)一風(fēng)格。

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

  • 統(tǒng)一主鍵,名稱必須是 id,類型必須是 UUID;
  • 所有字段默認(rèn)為 NULL,除非顯式指定;
  • 統(tǒng)一 timestamp 機(jī)制,每個(gè) Model 必須有 createdAt、updatedAt 和 version,分別記錄創(chuàng)建時(shí)間、修改時(shí)間和版本號。

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

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 就是為了強(qiáng)制實(shí)現(xiàn)上述規(guī)則。

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, // 聯(lián)系電話mobilePhone: STRING, // 手機(jī)號碼info: STRING, // 備注說明roleId: STRING, // 角色idstatus: STRING, // 用戶狀態(tài)token: STRING, // 認(rèn)證 tokenlastSignInAt: DATE // 上次登錄時(shí)間});return User; };

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

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

當(dāng)然也可以在 package.json 里面添加下面的腳本:

命令說明
npm run migrate:new在 ./migrations/ 中創(chuàng)建一個(gè) 遷移文件 to
npm run migrate:up執(zhí)行遷移
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" } ...

執(zhí)行 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, // 聯(lián)系電話mobilePhone: STRING, // 手機(jī)號碼roleId: STRING, // 角色I(xiàn)Dlocation: STRING, // 常住地avatar: STRING, // 頭像company: STRING, // 公司department: STRING, // 部門emailVerified: BOOLEAN, // 郵箱驗(yàn)證token: STRING, // 身份認(rèn)證令牌status: { type: INTEGER, allowNull: false }, // 用戶狀態(tài):1啟用, 0禁用, 2隱藏, 3刪除createdAt: DATE, // 用戶創(chuàng)建時(shí)間updatedAt: DATE, // 用戶信息更新時(shí)間lastSignInAt: DATE // 上次登錄時(shí)間});},async down(queryInterface, Sequelize) {await queryInterface.dropTable("users");} };

用戶認(rèn)證選型

所謂用戶認(rèn)證(Authentication),就是讓用戶登錄,并且在接下來的一段時(shí)間內(nèi)讓用戶訪問網(wǎng)站時(shí)可以使用其賬戶,而不需要再次登錄的機(jī)制。

小知識:可別把用戶認(rèn)證和用戶授權(quán)(Authorization)搞混了。用戶授權(quán)指的是規(guī)定并允許用戶使用自己的權(quán)限,例如發(fā)布帖子、管理站點(diǎn)等。

用戶認(rèn)證主要分為兩個(gè)部分:

  • 用戶通過用戶名和密碼登錄生成并且獲取 Token;
  • 用戶通過 Token 驗(yàn)證用戶身份獲取相關(guān)信息。

JSON Web Token(JWT)規(guī)范

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

JWT 的組成

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

頭部(Header)

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

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

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

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9

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

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

載荷(Payload)

說白了就是我們需要包含的數(shù)據(jù),類似于網(wǎng)絡(luò)請求的請求體 body,例如:

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

這里面的前五個(gè)字段都是由 JWT 的標(biāo)準(zhǔn)所定義的。

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

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

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

簽名(Signature)

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

eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpZCI6IjQ5YTlkZDUwNWM5ZDExZThiNWU4NmI5Nzc2YmIzYzRmIiwiaWF0IjoxNTI2ODcxNTc5LCJleHAiOjE1MjY4NzUxNzl9

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

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

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

我們現(xiàn)在明白了一個(gè) token 是由 Header 的 Base64 編碼 + Payload 的 Base64 編碼 + Signature 三段組成,當(dāng)其他人拿到我們的 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,即使拿到也無法直接判定相關(guān)內(nèi)容,從而不會直接泄露我們的內(nèi)容。

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

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

egg-jwt 插件

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

egg-jwt 基于 node-jsonwebtoken 實(shí)現(xiàn),完整文檔可以參考 https://github.com/auth0/node-jsonwebtoken。jwt 對象掛載在 app 對象下,可以通過 app.jwt 訪問 jwt 的三個(gè)方法:

  • jwt.sign(payload, secretOrPrivateKey, [options, callback])————生成 token 字符串
  • jwt.verify(token, secretOrPublicKey, [options, callback])————校驗(yàn) 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" };

調(diào)用:

請求頭:

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"}); }/*** 驗(yàn)證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 ", ""); };// 校驗(yàn) 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; };// 處理成功響應(yīng) exports.success = (ctx, result = null, message = "請求成功", status = 200) => {ctx.body = {code: 0,message: message,data: result};ctx.status = status; };// 處理失敗響應(yīng) exports.error = (ctx, code, message) => {ctx.body = {code: code,message: message};ctx.status = code; };

controller 中調(diào)用:

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

這樣對于需要進(jìn)行身份認(rèn)證的 restful API,就可以通過 token 進(jìn)行認(rèn)證,從而實(shí)現(xiàn)用戶認(rèn)證和授權(quán)。

后記

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

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

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

參考

  • JSON Web Token - 在 Web 應(yīng)用間安全地傳遞信息
  • 八幅漫畫理解使用 JSON Web Token 設(shè)計(jì)單點(diǎn)登錄系統(tǒng)

總結(jié)

以上是生活随笔為你收集整理的基于 Egg.js 框架的 Node.js 服务构建之用户管理设计的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。

如果覺得生活随笔網(wǎng)站內(nèi)容還不錯,歡迎將生活随笔推薦給好友。