本文在研究了使用非对称加密保障数据安全的技术基础上,使用 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
运行(不调试)。
下面是示例的跑起来以后的截图:
下节看点:这样的“安全”传输,真的安全吗?