8

安全地在前后端之间传输数据 - 「2」注册和登录示例

 3 years ago
source link: https://segmentfault.com/a/1190000039844746
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

安全地在前后端之间传输数据 - 「2」注册和登录示例

本文在研究了使用非对称加密保障数据安全的技术基础上,使用 NodeJS 作为服务,演示用户注册和登录操作时对密码进行加密传输。

注册/登录的传输过程大致如下图:

前端服务端请求公钥1「P_KEY」2「E_PASS」3❸ 使用「P_KEY」加密 password,得到 「E_PASS」请求注册/登录「username, E_PASS」4注册/验证登录5❺ 使用私钥解密「E_PASS」得到密码原文,进行注册或登录验证注册/登录结果6前端服务端

为了不切换开发环境,前后端都使用 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

下节看点:这样的“安全”传输,真的安全吗?


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK