本文在研究了使用非對稱加密保障數據安全的技術基礎上,使用 NodeJS 做爲服務,演示用戶註冊和登陸操做時對密碼進行加密傳輸。css
註冊/登陸的傳輸過程大體以下圖:html
%%{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: 註冊/登陸結果
爲了避免切換開發環境,先後端都使用 JavaScript 開發。採用了先後端分離的模式,但沒有引入構建過程,避免項目分離,這樣在 VSCode 中能夠把先後端的內容組織在同一個目錄下,不用操心發佈位置的問題。具體的技術選擇以下:前端
??
)」。Web 框架:Koa 及其相關中間件node
- [@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 庫,,,,jquery
- [JSEncrypt](http://travistidwell.com/jsencrypt/),RSA 加密用 - [jQuery](https://jquery.com/),DOM 操做及 Ajax。jQuery Ajax 夠用了,不須要 Axios。 - 模塊化的 JavaScript,須要較高版本瀏覽器 (Chrome 80+) 支持,避免前端構建。
VSCode 插件ios
- [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
中git
- @types/koa,提供 koa 語法提示(VSCode 能夠經過 TypeScript 語言服務爲 JS 提供語法提示) - @types/koa__router,提供 @koa/router 的語法提示 - eslint,配合 VSCode ESLint 插件進行代碼檢查和修復
初始化項目目錄算法
mkdir securet-demo cd securet-demo npm init -y
使用 Git 初始化,支持代碼版本管理shell
git init -b main
既然都在說用main
代替master
,那就初始化的時候指定分支名稱爲main
好了
添加 .gitignore
數據庫
# Node 安裝的模塊緩存 node_modules/ # 運行中產生的數據,好比密鑰文件 .data/
安裝 ESLint 並初始化
npm install -D eslint npx eslint --init
eslint 初始化配置的時候會提一些問題,根據項目目標和本身習慣選擇就好。
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
主要是修改 package.json
使之默認支持 ESM (ECMAScript modules),以及指定應用啓動入口
"type": "module", "scripts": { "start": "node ./server/index.js" },
其餘配置能夠參閱源代碼,源代碼放在 Gitee(碼雲)上,地址會在文末給出來。
劃重點:閱讀時不要忽略代碼註釋哦!
這一部分的邏輯是:嘗試從數據文件中加載,若是加載失敗,就產生一對新的密鑰並保存,而後從新加載。
文件放在 .data
目錄中,公鑰和私鑰分別用 PUBLIC_KEY
和 PRIVATE_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 中這樣處理沒有問題,正式的應用中,最好仍是找一套斷言庫來用。並且對於內部接口,最好能分離開發環境下和生產環境下的斷言:開發環境下進行斷言並輸出,生產環境下直接忽略斷言以提升效率 —— 這不是本文要研究的問題,之後有機會再來寫相關的技術。
獲取密鑰的過程在上面已經完成了,因此這部分沒什麼技術含量,只須要在 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());
註冊用戶須要接收加密的密碼,將其解密,再跟 username
一塊兒,組合成用戶信息保存起來。這個 API 須要在 router
中註冊一個新的路由:
async function register(ctx, next) { ... } router.post("/user", register);
在 register()
函數中,咱們須要
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(); }
在保存用戶的時候,須要注意幾點:
salt
必須保存,由於登陸驗證的時候,還須要用它對用戶輸入的密碼重算 Hash,並於數據庫中保存的 Hash 進行比較。password
不是正確的加密數據時,descrypt()
會拋異常。username
一般不區分大小寫,因此正式應用中保存和查詢用戶的時候,須要考慮這一因素。登陸時,前端也跟註冊時同樣加密密碼傳給後端,後端先解密出 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.js
、login.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
運行(不調試)。
下面是示例的跑起來以後的截圖:
下節看點:這樣的「安全」傳輸,真的安全嗎?