安全地在前后端之间传输数据 - 「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源创计划”,欢迎正在阅读的你也加入,一起分享。

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