关于javascript:安全地在前后端之间传输数据-2注册和登录示例

11次阅读

共计 8201 个字符,预计需要花费 21 分钟才能阅读完成。

本文在钻研了应用非对称加密保障数据安全的技术根底上,应用 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 运行(不调试)。

上面是示例的跑起来之后的截图:

预报

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

正文完
 0