安全地在前後端之間傳輸數據 - 「2」註冊和登錄示例

本文在研究了使用非對稱加密保障數據安全的技術基礎上,使用 NodeJS 作爲服務,演示用戶註冊和登錄操作時對密碼進行加密傳輸。

註冊/登錄的傳輸過程大致如下圖:

搭建項目

1. 環境

爲了不切換開發環境,前後端都使用 JavaScript 開發。採用了前後端分離的模式,但沒有引入構建過程,避免項目分離,這樣在 VSCode 中可以把前後端的內容組織在同一個目錄下,不用操心發佈位置的問題。具體的技術選擇如下:

  • 服務端環境:Node 15+(14 應該也可以)。使用這麼高的版本主要是爲了使用較新的 JS 語法和特性,比如「空合併運算符 (??)」。

  • Web 框架:Koa 及其相關中間件

    • @koa/router,服務端路由支持

    • koa-body,解決 POST 傳入的數據

    • koa-static-resolver,靜態文件服務(前端的 HTML、JS、CSS 等)

  • 前端:爲了簡捷,未使用框架,需要自己寫一些樣式。用了一些 JS 庫,

    • JSEncrypt,RSA 加密用

    • jQuery,DOM 操作及 Ajax。jQuery Ajax 夠用了,不需要 Axios。

    • 模塊化的 JavaScript,需要較高版本瀏覽器 (Chrome 80+) 支持,避免前端構建。

  • VSCode 插件

    • EditorConfig,規範代碼樣式(勿以善小而不爲)。

    • ESLint,代碼靜態檢查和修復工具。

    • Easy LESS,自動轉譯 LESS(前端部分沒有使用構建,需要用工具來進行簡單的編譯)。

  • 其他 NPM 模塊,開發期使用,不影響運行,安裝在 devDependencies

    • @types/koa,提供 koa 語法提示(VSCode 可以通過 TypeScript 語言服務爲 JS 提供語法提示)

    • @types/koa__router,提供 @koa/router 的語法提示

    • eslint,配合 VSCode ESLint 插件進行代碼檢查和修復

2. 初始化項目

初始化項目目錄

 mkdir securet-demo
 cd securet-demo
 npm init -y

使用 Git 初始化,支持代碼版本管理

 git init -b main

既然都在說用 main 代替 master,那就初始化的時候指定分支名稱爲 main 好了

添加 .gitignore

 # Node 安裝的模塊緩存
 node_modules/
 
 # 運行中產生的數據,比如密鑰文件
 .data/

安裝  ESLint 並初始化

 npm install -D eslint
 npx eslint --init

eslint 初始化配置的時候會提一些問題,根據項目目標和自己習慣選擇就好。

3. 項目目錄結構

 SECURET-DEMO
  ├── public             // 靜態文件,由 koa-static-resolver 直接送給瀏覽器
  │   ├── index.html
  │   ├── js             // 前端業務邏輯腳本
  │   ├── css           // 樣式表,Less 和 CSS 都在裏面
  │   └── libs           // 第三方庫,如 JSEncrypt、jQuery 等
  ├── server             // 服務端業務邏輯
  │   └── index.js       // 服務端應用入口
  ├── (↓↓↓ 根目錄下一般放項目配置文件 ↓↓↓)
  ├── .editorconfig
  ├── .eslintrc.js
  ├── .gitignore
  ├── package.json
  └── README.md

4. 修改一些配置

主要是修改 package.json 使之默認支持 ESM (ECMAScript modules),以及指定應用啓動入口

 "type": "module",
 "scripts": {
     "start": "node ./server/index.js"
 },

其他配置可以參閱源代碼,源代碼放在 Gitee(碼雲)上,地址會在文末給出來。

服務端關鍵代碼

劃重點:閱讀時不要忽略代碼註釋哦!

加載/產生密鑰對

這一部分的邏輯是:嘗試從數據文件中加載,如果加載失敗,就產生一對新的密鑰並保存,然後重新加載。

文件放在 .data 目錄中,公鑰和私鑰分別用 PUBLIC_KEYPRIVATE_KEY 這兩個文件保存。

產生密鑰對的過程需要邏輯阻塞,用不用異步函數無所謂。但是保存的時候,兩個文件可以通過異步併發保存,所以把 generateKeys() 定義爲異步函數:

 import crypto from "crypto";
 import fs from "fs";
 import path from "path";
 import { promisify } from "util";
 
 // fs.promises 是 Node 提供的 Promise 風格的 API
 // 參閱:https://nodejs.org/api/fs.html#fs_promises_api
 const fsPromise = fs.promises;
 
 // 提前準備好公鑰和私鑰文件路徑
 const filePathes = {
     public: path.join(".data", "PUBLIC-KEY"),
     private: path.join(".data", "PRIVATE_KEY"),
 }
 
 // 把 Node 回調風格的異步函數變成 Promise 風格的回調函數
 const asyncGenerateKeyPair = promisify(crypto.generateKeyPair);
 
 async function generateKeys() {
     const { publicKey, privateKey } = await asyncGenerateKeyPair(
         "rsa",
         {
             modulusLength: 1024,
             publicKeyEncoding: { type: "spki", format: "pem", },
             privateKeyEncoding: { type: "pkcs1", format: "pem" }
         }
     );
 
     // 保證數據目錄存在
     await fsPromise.mkdir(".data");
 
     // 併發,異步保存公鑰和私鑰
     await Promise.allSettled([
         fsPromise.writeFile(filePathes.public, publicKey),
         fsPromise.writeFile(filePathes.private, privateKey),
     ]);
 }

generateKey() 是在加載密鑰的時候根據情況調用,不需要導出。

而加載 KEY 的過程,不管是公鑰還是私鑰,都是一樣的,可以寫一個公共私有函數 getKey(),再把它封裝成 getPublicKey()getPrivateKey()  兩個可導出的函數。

 /**
  * @param {"public"|"private"} type 只可能是 "public" 或 "private" 中的一個。
  */
 async function getKey(type) {
     const filePath = filePathes[type];
     const getter = async () => {
         // 這是一個異步操作,返回讀取的內容,或者 undefined(如果讀取失敗)
         try {
             return await fsPromise.readFile(filePath, "utf-8");
         } catch (err) {
             console.error("[error occur while read file]", err);
             return;
         }
     };
     
     // 嘗試加載(讀取)密鑰數據,加載成功直接返回
     const key = await getter();
     if (key) { return key; }
 
     // 上一步加載失敗,產生新的密鑰對,並重新加載
     await generateKeys();
     return await getter();
 }
 
 export async function getPublicKey() {
     return getKey("public");
 }
 
 export async function getPrivateKey() {
     return getKey("private");
 }

getKey() 的參數只能是 "public""private"。因爲是內部調用,所以可以不做參數驗證,自己調用的時候小心就行。

小 Demo 中這樣處理沒有問題,正式的應用中,最好還是找一套斷言庫來用。而且對於內部接口,最好能分離開發環境下和生產環境下的斷言:開發環境下進行斷言並輸出,生產環境下直接忽略斷言以提高效率 —— 這不是本文要研究的問題,以後有機會再來寫相關的技術。

API 獲取公鑰: GET /public-key

獲取密鑰的過程在上面已經完成了,所以這部分沒什麼技術含量,只需要在 router 中註冊一個路由,輸出公鑰即可

 import KoaRouter from "@koa/router";
 
 const router = new KoaRouter();
 
 router.get("/public-key", async (ctx, next) => {
     ctx.body = { key: await getPublicKey() };
     return next();
 });
 
 // 註冊其他路由
 // ......
 
 app.use(router.routes());
 app.use(router.allowedMethods());

API 註冊用戶: POST /user

註冊用戶需要接收加密的密碼,將其解密,再跟 username 一起,組合成用戶信息保存起來。這個 API 需要在 router 中註冊一個新的路由:

 async function register(ctx, next) { ... }
 router.post("/user", register);

register() 函數中,我們需要

  • 獲取 POST Payload 中的 username 和加密後的 password

  • password 解密得到 originalPassword

  • 註冊 { username, originalPassword }

其中解密過程在「技術預研」部分已經講過了,搬過來封裝成 decrypt() 函數即可

 async function decrypt(data) {
     const key = await getPrivateKey();
     return crypto.privateDecrypt(
         {
             key,
             padding: crypto.constants.RSA_PKCS1_PADDING
         },
         Buffer.from(data, "base64"),
     ).toString("utf8");
 }

註冊過程:

 import crypto from "crypto";
 
 // 使用內存對象來保存所有用戶
 // 將 cache.users 初始化爲空數組,可省去使用時的可用性判斷
 const cache = { users: [] };
 
 async function register(ctx, next) {
     const { username, password } = ctx.request.body;
     
     if (cache.users.find(u => u.username === username)) {
         // TODO 用戶已經存在,通過 ctx.body 輸出錯誤信息,結束當前業務
         return next();
     }
     
     const originalPassword = await decrypt(password);
     // 得到 originalPassword 之後不能直接保存,先使用 HMAC 加密
     // 行隨機產生“鹽”,也就是用來加密密碼的 KEY
     const salt = crypto.randomBytes(32).toString(hex);
     // 然後加密密碼
     const hash = (hmac => {
         // hamc 在傳入時創建,使用 sha256 摘要算法,把 salt 作爲 KEY
         hamc.update(password, "utf8");
         return hmac.digest("hex");
     })(crypto.createHmac("sha256", salt, "hex"));
     
     // 最後保存用戶
     cache.users.push({
         username,
         salt,
         hash
     });
     
     ctx.body = { success: true };    
     return next();
 }

在保存用戶的時候,需要注意幾點:

  • Demo 中把用戶信息保存在內存中,但實際應用中應該保存在數據庫或文件中(持久化)。

  • 密碼原文用後即拋,不可以保存下來,避免拖庫泄漏用戶密碼。

  • 直接 Hash 原文可以在拖庫後通過彩虹表破解,所以使用 HMAC 引入隨機密鑰 (salt) 來預防這種破解方式。

  • salt 必須保存,因爲登錄驗證的時候,還需要用它對用戶輸入的密碼重算 Hash,並於數據庫中保存的 Hash 進行比較。

  • 上述過程沒有充分考慮容錯處理,實際應用中需要考慮,比如輸入的 password 不是正確的加密數據時,descrypt() 會拋異常。

  • 還有一個細節,username 通常不區分大小寫,所以正式應用中保存和查詢用戶的時候,需要考慮這一因素。

API 登錄: POST /user/login

登錄時,前端也跟註冊時一樣加密密碼傳給後端,後端先解密出 originalPassword 之後再進行驗證

 async function login(ctx, next) {
     const { username, password } = ctx.request.body;
     // 根據用戶名找到用戶,如果沒找到,直接登錄失敗
     const user = cache.users.find(u => u.username === username);
     
     if (!user) {
         // TODO 通過 ctx.body 輸出失敗數據
         return next();
     }
     
     const originalPassword = decrypt(password);
 
     const hash = ... // 參考上面註冊部分的代碼
 
     // 比較計算出來的 hash 和保存的 hash,一致則說明輸入的密碼無誤
     if (hash === user.hash) {
         // TODO 通過 ctx.body 輸出登錄成功的信息和數據
     } else {
         // TODO 通過 ctx.body 輸出登錄失敗的信息和數據
     }
     
     return next();
 }
 
 router.post("/user/login", login);

備註:這段代碼中有多處 ctx.body = ... 以及 return next(),這樣寫是爲了“敘事”。(代碼本身也是一種人類可理解的語言不是嗎!)但爲了減少意外 BUG,應該將邏輯優化組合,儘量只有一個 ctx.body = ...return next()。Gitee 上的演示代碼是進行過優化處理的,請在文末查找下載鏈接。

前端應用的關鍵技術

前端代碼的關鍵部分是使用JSEncrypt 對用戶輸入的密碼進行加密,「技術預研 」中已經提供了示例代碼。

使用模塊類型的腳本

index.html 中,通過常規手段引入 JSEncrypt 和 jQuery,

 <script src="libs/jsencrypt/jsencrypt.js"></script>
 <script src="libs/jquery//jquery-3.6.0.js"></script>

然後將業務代碼 js/index.js 以模塊類型引入,

 <script type="module" src="js/index.js"></script>

這樣 index.js  及其引用的各個模塊都可以用 ESM 的形式來寫,不需要打包。比如 index.js 中就只是綁定事件,所有業務處理函數都是從別的源文件引入的:

 import {
     register, ...
 } from "./users.js";
 
 $("#register").on("click", register);
 ......

users.js 其實也只包含了導入/導出語句,有效代碼都是寫在reg.jslogin.js 等文件中:

 export * from "./users/list.js";
 export * from "./users/reg.js";
 export * from "./users/login.js";
 export { randomUser } from "./users/util.js";

所以,在 HTML 中使用 ESM 模塊化的腳本,只需要在 <script> 標籤中添加 type="module",瀏覽器會根據 import 語句去加載對應的 JS 文件。但有一點需要注意:import 語句中,文件擴展名不可省略,一定要寫出來。

組合異步業務代碼

前端部分業務需要連續調用多個 API 來完成,如果直接實現這個業務處理過程,代碼看起來會有點繁瑣。所以不妨寫一個 compose() 函數來按順序處理傳入的異步業務函數(同步的也當異步處理),返回最終的處理結果。如果中間某個業務節點出錯,則中斷業務鏈。這個處理過程和 then 鏈類似

 export async function compose(...asyncFns) {
     let data;      // 一箇中間數據,保存上一節點的輸出,作爲下一節點的輸入
     for (let fn of asyncFns) {
         try {
             data = await fn(data);
         } catch (err) {
             // 一般,如果發生錯誤直接拋出,在外面進行處理就好。
             // 但是,如果不想在外面寫 try ... catch ... 可以在內部處理了
             // 返回一個正常但標識錯誤的對象
             return {
                 code: -1,
                 message: err.message ?? `[${err.status}] ${err.statusText}`,
                 data: err
             };
         }
     }
     return data;
 }

比如註冊過程就可以這樣使用 compose

 const { code, message, data } = await compose(
     // 第 1 步,得到 { key }
     async () => await api.get("public-key"),
     // 第 2 步,加密數據(同步過程當異步處理)
     ({ key = "" }) => ({ username, password: encryptPassword(key, password) }),
     // 第 3 步,將第 2 步的處理結果作爲參數,調用註冊接口
     async (data) => await api.post("user", data),
 );

這個 compose 並沒有專門處理第 1 步需要參數的情況,如果確實需要,可以在第 1 個業務前插入一個返回參數的函數,比如:

 compose(
     () => "public-key",
     async path => await api.get(path),
     ...
 );

演示代碼下載

完整的示例可以從 Gitee 獲取,地址:

https://gitee.com/jamesfancy/code-for-articles/tree/secure-transmiting

代碼拉下來之後,記得 npm install

在 VSCode 中可以在「運行和調試」面板中直接運行(調試),也可以通過 npm start 運行(不調試)。

下面是示例的跑起來之後的截圖:

預告

下節看點:這樣的“安全”傳輸,真的安全嗎?



本文分享自微信公衆號 - 邊城客棧(fancyidea-full)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章