15
头图

technology foundation , which uses asymmetric encryption to ensure data security, and uses NodeJS as a service to demonstrate the encrypted transmission of passwords during user registration and login operations.

The transfer process of registration/login is roughly as follows:

%%{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: 注册/登录结果

Build the project

1. Environment

In order not to switch the development environment, the front and back ends are developed using JavaScript. The front-end and back-end separation mode is adopted, but the construction process is not introduced to avoid project separation, so that the content of the front-end and back-end can be organized in the same directory in VSCode, and there is no need to worry about the release location. The specific technical options are as follows:

  • Server environment: Node 15+ (14 should be fine too). The use of such a high version is mainly to use the newer JS syntax and features, such as the "null coalescing operator ( ?? )".
  • Web framework: Koa and related middleware

  • Front end: In order to be simple, without using the framework, you need to write some styles yourself. Used some JS libraries,,,,

    • JSEncrypt , for RSA encryption
    • jQuery , DOM operation and Ajax. jQuery Ajax is enough, Axios is not needed.
    • Modular JavaScript requires the support of higher version browsers (Chrome 80+) to avoid front-end construction.
  • VSCode plugin

    • EditorConfig , the standard code style (do not be small instead of good).
    • ESLint , code static check and repair tool.
    • Easy LESS , automatic translation of LESS (the front-end part is not used to build, and tools are needed for simple compilation).
  • Other NPM modules, used during the development period, do not affect the operation, installed in devDependencies

    • @types/koa, provides koa syntax hints (VSCode can provide syntax hints for JS through TypeScript language service)
    • @types/koa__router, provides syntax hints for @koa/router
    • eslint, with VSCode ESLint plug-in for code inspection and repair

2. Initialize the project

Initialize the project directory

mkdir securet-demo
cd securet-demo
npm init -y

Use Git to initialize, support code version management

git init -b main
Since we are talking about using main instead of master , then specify the branch name as main

Add .gitignore

# Node 安装的模块缓存
node_modules/

# 运行中产生的数据,比如密钥文件
.data/

Install ESLint and initialize

npm install -D eslint
npx eslint --init

When eslint initializes the configuration, it will ask some questions, just choose according to the project goal and your own habits.

3. Project directory structure

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. Modify some configurations

Mainly modify package.json to support ESM ( ECMAScript modules ) by default, and specify the application startup entry

"type": "module",
"scripts": {
    "start": "node ./server/index.js"
},

For other configurations, please refer to the source code. The source code is on Gitee (Code Cloud), and the address will be given at the end of the article.

Server key code

Key points: Don't ignore code comments when reading!

Load/generate key pair

The logic of this part is: try to load from the data file, if the load fails, a new pair of keys is generated and saved, and then reloaded.

The file is placed in the .data directory, and the public key and private key are saved in two files, PUBLIC_KEY and PRIVATE_KEY

The process of generating a key pair requires logical blocking, and it doesn't matter whether or not an asynchronous function is used. But when saving, two files can be saved asynchronously and concurrently, so generateKeys() defined as an asynchronous function:

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() is called according to the situation when the key is loaded and does not need to be exported.

The process of loading KEY, whether it is a public key or a private key, is the same. You can write a public private function getKey() , and then encapsulate it into two exportable functions, getPublicKey() and 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() parameters can only be "public" or "private" . Because it is an internal call, you don't need to do parameter verification, just be careful when you call it yourself.

There is no problem with this processing in the small Demo. In the formal application, it is better to find a set of assertion library to use. And for the internal interface, it is best to separate the assertions in the development environment and the production environment: assert and output in the development environment, and ignore the assertion in the production environment to improve efficiency-this is not the problem to be studied in this article, there is a chance to come back later Write related technologies.

API to get public key: GET /public-key

The process of obtaining the key has been completed above, so this part has no technical content, just router and output the public key.

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 registered user: POST /user

Registered users need to receive the encrypted password, decrypt it, and then combine it with username to form user information and save it. This API needs to register a new route router

async function register(ctx, next) { ... }
router.post("/user", register);

In the register() function, we need

  • POST Payload get in username after and encryption password
  • Decrypt from password originalPassword
  • Register { username, originalPassword }

The decryption process has already been discussed in the "Technology Preliminary Research" section, just move it over and encapsulate it into the decrypt() function

async function decrypt(data) {
    const key = await getPrivateKey();
    return crypto.privateDecrypt(
        {
            key,
            padding: crypto.constants.RSA_PKCS1_PADDING
        },
        Buffer.from(data, "base64"),
    ).toString("utf8");
}

Registration process:

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();
}

When saving users, there are a few things to note:

  • In Demo, user information is stored in memory, but in actual applications, it should be stored in a database or file (persistent).
  • The original password is thrown away after use, and cannot be saved to avoid leaking user passwords by dragging the library.
  • Direct Hash original text can be rainbow table after dragging the library, so use HMAC to introduce a random key (salt) to prevent this cracking method.
  • salt must be saved, because during login verification, it also needs to be used to recalculate the Hash of the password entered by the user and compare it with the Hash saved in the database.
  • The above process does not fully consider the fault tolerance processing, and needs to be considered in practical applications. For example, if the input password is not the correct encrypted data, descrypt() will throw an exception.
  • There is one more detail, username is usually case-insensitive, so you need to consider this factor when saving and querying users in formal applications.

API login: POST /user/login

When logging in, the front-end encrypts the password and transmits it to the back-end just like when registering. The back-end decrypts originalPassword and then authenticates.

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);
Remarks: There are multiple ctx.body = ... and return next() in this code, which are written in this way for "narrative". (The code itself is also a human-understandable language?) But in order to reduce unexpected bugs, the logic should be optimized and combined, and try to have only one ctx.body = ... and return next() . The demo code on Gitee is optimized, please find the download link at the end of the article.

Key technologies for front-end applications

The key part of the front-end code is to use JSEncrypt to encrypt the password entered by the user. The sample code has been provided in the Technical Preliminary Research

Use module type script

In index.html , JSEncrypt and jQuery are introduced by conventional means,

<script src="libs/jsencrypt/jsencrypt.js"></script>
<script src="libs/jquery//jquery-3.6.0.js"></script>

Then introduce the business code js/index.js as a module type,

<script type="module" src="js/index.js"></script>

In this way, index.js and its referenced modules can be written in the form of ESM without packaging. For example, index.js is just binding events, and all business processing functions are imported from other source files:

import {
    register, ...
} from "./users.js";

$("#register").on("click", register);
......

users.js actually only contains import/export statements. The valid codes are written in files such as reg.js and login.js

export * from "./users/list.js";
export * from "./users/reg.js";
export * from "./users/login.js";
export { randomUser } from "./users/util.js";

Therefore, to use ESM modular scripts in HTML, you only need to add type="module" <script> tag, and the browser will load the corresponding JS file import But one thing to note: In the import statement, the file extension cannot be omitted, and must be written out.

Combine asynchronous business code

The front-end part of the business needs to call multiple APIs continuously to complete. If this business processing process is directly implemented, the code looks a bit cumbersome. So might as well write a compose() function to process the incoming asynchronous business functions in order (synchronous and asynchronous processing), and return the final processing result. If an error occurs at a certain business node in the middle, the business chain is interrupted. This process is similar to the then chain

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;
}

For example, the registration process can use compose like this:

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),
);

This compose does not specifically deal with the need for parameters in step 1. If you really need it, you can insert a function that returns parameters before the first business, such as:

compose(
    () => "public-key",
    async path => await api.get(path),
    ...
);

Demo code download

The complete example can be obtained from Gitee at: https://gitee.com/jamesfancy/code-for-articles/tree/secure-transmiting

After the code is pulled down, remember npm install .

In VSCode, you can run directly in the "Run and Debug" panel (debugging), or run through npm start (not debugging).

The following is a screenshot of the example after running:

image.png

Notice

Highlights in the next section: Is this "secure" transmission really safe?


边城
59.8k 声望29.6k 粉丝

一路从后端走来,终于走在了前端!