安全地在前後端之間傳輸資料 - 「2」註冊和登入範例

2021-04-19 09:02:07

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

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

%%{init: {'theme':'forest'}}%%
sequenceDiagram

autonumber
participant B as 前端
participant S as 伺服器端

B ->>+ S: 請求公鑰
S -->>- B: 「P_KEY」

B ->> B: 「E_PASS」
Note right of B: ❸ 使用「P_KEY」加密 password,得到 「E_PASS」
B ->>+ S: 請求註冊/登入「username, E_PASS」
S ->> S: 註冊/驗證登入
Note right of S: ❺ 使用私鑰解密「E_PASS」得到密碼原文,進行註冊或登入驗證
S -->>- B: 註冊/登入結果

搭建專案

1. 環境

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

  • 伺服器端環境:Node 15+(14 應該也可以)。使用這麼高的版本主要是為了使用較新的 JS 語法和特性,比如「空合併運運算元 (??)」。
  • Web 框架:Koa 及其相關中介軟體

    - [@koa/router](https://www.npmjs.com/package/@koa/router),伺服器端路由支援
    - [koa-body](https://www.npmjs.com/package/koa-body),解決 POST 傳入的資料
    - [koa-static-resolver](https://www.npmjs.com/package/koa-static-resolver),靜態檔案服務(前端的 HTML、JS、CSS 等)
  • 前端:為了簡捷,未使用框架,需要自己寫一些樣式。用了一些 JS 庫,,,,

    - [JSEncrypt](http://travistidwell.com/jsencrypt/),RSA 加密用
    - [jQuery](https://jquery.com/),DOM 操作及 Ajax。jQuery Ajax 夠用了,不需要 Axios。
    - 模組化的 JavaScript,需要較高版本瀏覽器 (Chrome 80+) 支援,避免前端構建。
  • VSCode 外掛

    - [EditorConfig](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig),規範程式碼樣式(勿以善小而不為)。
    - [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint),程式碼靜態檢查和修復工具。
    - [Easy LESS](https://marketplace.visualstudio.com/items?itemName=mrcrowl.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/...

程式碼拉下來之後,記得 npm install

在 VSCode 中可以在「執行和偵錯」面板中直接執行(偵錯),也可以通過 npm start 執行(不偵錯)。

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

image.png

預告

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