Securely transfer data between front and back terminals – example of “2” registration and login

Time:2022-5-9

Based on the study of the technology of using asymmetric encryption to ensure data security, this paper uses nodejs as a service to demonstrate the encrypted transmission of passwords during user registration and login.

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

%%{init: {'theme':'forest'}}%%
sequenceDiagram

autonumber
Participant B as front end
Participant s as server

B - > > + s: request public key
S -->>- B: 「P_KEY」

B ->> B: 「E_PASS」
Note right of B: ❸ use "p_key" to encrypt password and get "e_pass"
B - > > + s: request to register / log in to "username, e_pass"
S - > > s: register / verify login
Note right of S: ❺ decrypt "e_pass" with the private key to get the original password for registration or login verification
S -- > > - B: registration / login results

Construction project

1. Environment

In order not to switch the development environment, the front and back ends are developed with JavaScript. The mode of separating the front and rear ends is adopted, but the construction process is not introduced to avoid project separation. In this way, the contents of the front and rear ends can be organized in the same directory in vscode without worrying about the publishing location. The specific technical options are as follows:

  • Server environment: node 15 + (14 should also be OK). This higher version is mainly used to use newer JS syntax and features, such as “empty merge operator”(??)」。
  • Web framework: koa and its related Middleware

    - [@koa/router]( https://www.npmjs.com/package/ @Koa / router), server side routing support
    - [koa-body]( https://www.npmjs.com/package/koa-body ), solve the data passed in by post
    - [koa-static-resolver]( https://www.npmjs.com/package/koa-static-resolver ), static file service (front-end HTML, JS, CSS, etc.)
  • Front end: in order to be simple and not use the frame, you need to write some styles yourself. Used some JS libraries,,,,

    - [JSEncrypt]( http://travistidwell.com/jsencrypt/ ), for RSA encryption
    - [jQuery]( https://jquery.com/ ), DOM operation and Ajax. JQuery AJAX is enough. Axios is not needed.
    -Modular JavaScript requires the support of higher version browser (chrome 80 +) to avoid front-end construction.
  • Vscode plug-in

    - [EditorConfig]( https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig ), standardize the code style (don't be good and small).
    - [ESLint]( https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode -Eslint), code static inspection and repair tool.
    - [Easy LESS]( https://marketplace.visualstudio.com/items?itemName=mrcrowl.easy -Less) and automatically translate less (the front-end part is not built, so tools are needed for simple compilation).
  • Other NPM modules, which are used during the development period and do not affect the operation, are installed indevDependenciesin

    [email protected] types / koa, provide koa syntax prompt (vscode can provide syntax prompt for JS through typescript language service)
    - @types/koa__ Router, providing syntax prompt of @ koa / router
    -Eslint, cooperate with vscode eslint plug-in to check and repair the code

2. Initialize project

Initialize project directory

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

Use git initialization to support code version management

git init -b main

Since they’re all talkingmainreplacemaster, specify the branch name during initializationmainokay

add to.gitignore

#Node installed module cache
node_modules/

#Data generated during operation, such as key file
.data/

Install eslint and initialize

npm install -D eslint
npx eslint --init

Eslint will ask some questions when initializing the configuration. Just choose according to the project objectives and your habits.

3. Project directory structure

SECURET-DEMO
 ♪ public // the static file is sent to the browser directly by koa static resolver
 │   ├── index.html
 │♪ JS // front end business logic script
 │♪ - CSS // style sheet. Both less and CSS are in it
 │ └ - LIBS // third party libraries, such as jsencrypt, jQuery, etc
 Server // service side business logic
 │   └── index. JS // server application entry
 ♪ - (↓↓↓ general project configuration file under root directory ↓↓↓)
 ├── .editorconfig
 ├── .eslintrc.js
 ├── .gitignore
 ├── package.json
 └── README.md

4. Modify some configurations

Mainly modifypackage.jsonMake it 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 placed on gitee (code cloud), and the address will be given at the end of the text.

Server key code

Highlight: 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 loading fails, a new pair of keys will be generated, saved, and then loaded again.

File in.dataIn the directory, the public key and private key are used respectivelyPUBLIC_KEYandPRIVATE_KEYThese two files are saved.

The process of generating key pairs requires logical blocking, and it doesn’t matter whether asynchronous functions are used or not. However, when saving, two files can be saved asynchronously and concurrently, sogenerateKeys()Defined as asynchronous function:

import crypto from "crypto";
import fs from "fs";
import path from "path";
import { promisify } from "util";

// fs. Promises is a promise style API provided by node
//See: https://nodejs.org/api/fs.html#fs_promises_api
const fsPromise = fs.promises;

//Prepare the public key and private key file paths in advance
const filePathes = {
    public: path.join(".data", "PUBLIC-KEY"),
    private: path.join(".data", "PRIVATE_KEY"),
}

//Change the asynchronous function of node callback style into promise style callback function
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" }
        }
    );

    //Ensure that the data directory exists
    await fsPromise.mkdir(".data");

    //Save public and private keys concurrently and asynchronously
    await Promise.allSettled([
        fsPromise.writeFile(filePathes.public, publicKey),
        fsPromise.writeFile(filePathes.private, privateKey),
    ]);
}

generateKey()It is called when loading the key according to the situation, and there is no need to export.

The process of loading key, whether public key or private key, is the same. You can write a public and private functiongetKey()And then package it intogetPublicKey()andgetPrivateKey()Two exportable functions.

/**
 *@ param {"public" | "private"} type can only be one of "public" or "private".
 */
async function getKey(type) {
    const filePath = filePathes[type];
    const getter = async () => {
        //This is an asynchronous operation that returns the read content or undefined (if the read fails)
        try {
            return await fsPromise.readFile(filePath, "utf-8");
        } catch (err) {
            console.error("[error occur while read file]", err);
            return;
        }
    };
    
    //Try to load (read) the key data. If the loading is successful, it will be returned directly
    const key = await getter();
    if (key) { return key; }

    //The previous step failed to load. A new key pair is generated and reloaded
    await generateKeys();
    return await getter();
}

export async function getPublicKey() {
    return getKey("public");
}

export async function getPrivateKey() {
    return getKey("private");
}

getKey()The parameter can only be"public"or"private"。 Because it is an internal call, you can not do parameter verification. Just be careful when calling yourself.

There is no problem with this handling in the small demo. In formal applications, it is best to find a set of assertion library to use. Moreover, for internal interfaces, it is better to separate assertions in the development environment from those in the production environment: assertions are made and output in the development environment, and assertions are directly ignored in the production environment to improve efficiency – this is not the problem to be studied in this paper. I will have the opportunity to write related technologies in the future.

API get public key: get / public key

The process of obtaining the key has been completed above, so there is no technical content in this part, just inrouterRegister a route in 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();
});

//Register other routes
// ......

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 follow itusernameTogether, combined into user information and saved. This API needs to berouterRegister a new route in:

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

stayregister()In the function, we need

  • Get the in post payloadusernameAnd encryptedpassword
  • frompasswordDecryptedoriginalPassword
  • register{ username, originalPassword }

The decryption process has been described in the “technology pre research” part, which is moved and encapsulated intodecrypt()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";

//Use memory objects to save all users
//Cache Users is initialized as an empty array, which can eliminate the availability judgment during use
const cache = { users: [] };

async function register(ctx, next) {
    const { username, password } = ctx.request.body;
    
    if (cache.users.find(u => u.username === username)) {
        //Todo user already exists through CTX Body outputs an error message and ends the current business
        return next();
    }
    
    const originalPassword = await decrypt(password);
    //After getting the originalpassword, you can't save it directly. Use HMAC encryption first
    //Line randomly generates "salt", that is, the key used to encrypt the password
    const salt = crypto.randomBytes(32).toString(hex);
    //Then encrypt the password
    const hash = (hmac => {
        //HAMC is created when it is passed in. It uses the sha256 digest algorithm and takes salt as the key
        hamc.update(password, "utf8");
        return hmac.digest("hex");
    })(crypto.createHmac("sha256", salt, "hex"));
    
    //Last save user
    cache.users.push({
        username,
        salt,
        hash
    });
    
    ctx.body = { success: true };    
    return next();
}

When saving users, you should pay attention to the following points:

  • In demo, user information is saved in memory, but in practical application, it should be saved in database or file (persistence).
  • The original password is thrown away after use and cannot be saved to avoid dragging the library to reveal the user’s password.
  • The original text of direct hash can be cracked through rainbow table after dragging the library, so HMAC is used to introduce random key (salt) to prevent this cracking method.
  • saltIt must be saved, because when logging in and verifying, 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 fault-tolerant processing, which needs to be considered in practical application, such as inputpasswordWhen the data is not encrypted correctly,descrypt()Will throw exceptions.
  • One more detail,usernameIt is usually case insensitive, so this factor should be considered when saving and querying users in formal applications.

API login: post / user / login

When logging in, the front end also transmits the encrypted password to the back end as when registering, and the back end decrypts it firstoriginalPasswordThen verify

async function login(ctx, next) {
    const { username, password } = ctx.request.body;
    //Find the user according to the user name. If it is not found, the direct login fails
    const user = cache.users.find(u => u.username === username);
    
    if (!user) {
        //Todo passes CTX Body failed to output data
        return next();
    }
    
    const originalPassword = decrypt(password);

    const hash = ... //  Refer to the code in the registration section above

    //Compare the calculated hash with the saved hash. If it is consistent, the password entered is correct
    if (hash === user.hash) {
        //Todo passes CTX Body outputs the information and data of successful login
    } else {
        //Todo passes CTX Body output login failure information and data
    }
    
    return next();
}

router.post("/user/login", login);

Note: there are many places in this codectx.body = ...as well asreturn next(), it’s written for “Narration”. (code itself is also a language that human beings can understand, isn’t it?) However, in order to reduce unexpected bugs, we should optimize the combination of logic and try to have only onectx.body = ...andreturn next()。 The demo code on gitee has been optimized. Please find the download link at the end of the article.

Key technologies of front-end application

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 pre research”.

Script using module type

stayindex.htmlIn, jsencrypt and jQuery are introduced through conventional means,

<script></script>
<script></script>

Then put the business codejs/index.jsIntroduced as a module type,

<script type="module"></script>

suchindex.jsAnd the modules referenced by them can be written in the form of ESM without packaging. such asindex.jsAll business processing functions are imported from other source files:

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

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

users.jsIn fact, it only contains import / export statements, and the valid codes are written inreg.jslogin.jsAnd other documents:

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 script in HTML, you only need to<script>Add to labeltype="module", the browser willimportStatement to load the corresponding JS file. But one thing to note:importIn the statement,The file extension cannot be omitted, be sure to write it out.

Combined asynchronous business code

The front-end business needs to call multiple APIs continuously to complete. If you directly implement this business processing process, the code will look a little cumbersome. So write onecompose()Function to process the incoming asynchronous business functions in sequence (synchronous is also asynchronous processing), and return the final processing result. If a business node in the middle makes an error, the business chain will be interrupted. This process is similar to the then chain

export async function compose(...asyncFns) {
    let data;      //  An intermediate data, which saves the output of the previous node as the input of the next node
    for (let fn of asyncFns) {
        try {
            data = await fn(data);
        } catch (err) {
            //Generally, if an error is thrown directly, it's good to deal with it outside.
            //But if you don't want to write try outside catch ...  It can be handled internally
            //Returns a normal but incorrectly identified object
            return {
                code: -1,
                message: err.message ?? `[${err.status}] ${err.statusText}`,
                data: err
            };
        }
    }
    return data;
}

For example, the registration process can be used in this waycompose

const { code, message, data } = await compose(
    //Step 1, get {key}
    async () => await api.get("public-key"),
    //Step 2: encrypt data (synchronous process is asynchronous processing)
    ({ key = "" }) => ({ username, password: encryptPassword(key, password) }),
    //Step 3: take the processing result of step 2 as a parameter and call the registration interface
    async (data) => await api.post("user", data),
);

thiscomposeIt does not specifically deal with the situation that parameters are required in step 1. If necessary, a function that returns parameters can be inserted 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/…

After the code is pulled down, remembernpm install

In vscode, you can run (debug) directly in the “run and debug” panel or throughnpm startRun (without debugging).

The following is a screenshot of the example after running:

Securely transfer data between front and back terminals - example of

Notice

The next section focuses on: is this “secure” transmission really safe?