Previous Page
cover
MongoDB
mongoose
TypeScript
加密
Author: Edison Chue 2022-02-27 6 mins read

TypeScript Mongoose: 在儲存用戶的密碼前先做 hashing

所需套件

  • mongoose(ODM:將資料庫的資料結構對應轉換成 Javascript 的 Object,方便操作資料庫的資料)。
  • bcrypt(用作產生 salt 和 hashing),Salt: 為密碼加鹽,在密碼上加上隨機生成的字串,增加儲存的密碼的亂度。
// install dependencies
yarn add mongoose bcryptjs

// install dev dependencies
yarn add @types/mongoose, @types/bcryptjs

上下文

假設現有一 user model,在 mongoose 中 model 要先定義好它的綱要 Schema 後再以mongoose.model(Schema) 方法將將其 “編譯”成 model。

// import required package
import mongoose from 'mongoose';
import bcrypt from "bcrypt"

// Schema Definition
const userSchema = new mongoose.Schema(
    {
        email: { type: String, required: true, unique: true },
        name: { type: String, required: true },
        password: { type: String, required: true },
    },
    {
        timestamps: true, // mongoose assign createdAt and updatedAt automatically
);

對應的 Type Definition:

export interface IUserDocument extends Document {
    email: string;
    name: string;
    password: string;
    createdAt: Date; // we defined timestamp as true in our schema
    updatedAt: Date;
    comparePassword(candidatePassword: string): Promise<boolean>;
}

在 userSchema 的資料被存到資料庫前,我們希望先把密碼加密,這裏可以引用 mongoose 的 middleware(又被稱爲 pre / post hooks)。在操作文件的 middleware 當中 mongoose 提供了下列幾種類型:

  • validate
  • save
  • remove
  • updateOne
  • deleteOne
  • init

我們要在儲存前處理資料,所以用的是 schema.pre(’save’, function(next){}),next 的 Type Definition 在 mongoose 6.0版本前爲 HookNextFunction,要是你使用的版本爲6.0以後直接 implicit any 即可。

在實現加密的部分我們用的是 bcrypt 套件:

bcrypt.genSalt(saltWorkFactor)方法能產生salt,saltWorkFactor 控制計算單個 BCrypt hash 需要多少時間,數值越高越安全,但設太高會影響用戶體驗(加密時間太長)。

bcrypt.hashSync(user.password, salt)實際加密密碼的方法。

// hash the password before(hence pre) saving
// arrow function changes the scope of 'this.', so don't use it!
userSchema.pre('save', async function (next) {
    let user = this as IUserDocument; // a little trick so don't have to reference this later

    // only hash the password if it has been modified (or is new)
    if (!user.isModified('password')) {
        return next();
    }

    /*
        In our .env saltWorkFactor is setted as 10.
    */

    try {
        // Salt is the random data that is used as an additional input to a one-way function that hashes data
        const salt = await bcrypt.genSalt(+process.env.saltWorkFactor); // saltWorkFactor is a string in .env file, +saltWorkFactor convert it to number
        const hash = await bcrypt.hashSync(user.password, salt);
        user.password = hash;

        return next();
    } catch (error) {
        return next(error);
    }
});

注意不管加密過程中有沒有出現錯誤,跟 express 使用 middleware 的時候相似,middleware結束時都需要呼叫 next()

在 Schema 的 methods prototype 中實現驗證密碼用的方法,bcrypt.compare(API傳入的密碼, model裏已加密的密碼)可以比對字串和已加密的字串,回傳 boolean。

// Password validation method
userSchema.methods.comparePassword = async function (
    candidatePassword: string
): Promise<boolean> {
    const user = this as IUserDocument;
    try {
        return bcrypt.compare(candidatePassword, user.password);
    } catch (error) {
        return false;
    }
};

在比對密碼的過程中出錯一律回傳 false。

最後將 Schema “編譯”成 model:

// User Model
const UserModel = model<IUserDocument>('User', userSchema);

export default UserModel;

實測 mongoDB 裏的密碼被已加密: 截圖 2022-02-27 21.50.43.png

完整程式碼

import { Document, Schema, model } from 'mongoose';
import bcrypt from 'bcrypt';

export interface IUserDocument extends Document {
    email: string;
    name: string;
    password: string;
    createdAt: Date; // timestamps is true, so that we would have createdAt and updatedAt on our schema
    updatedAt: Date;
    comparePassword(candidatePassword: string): Promise<boolean>;
}

// Schema Definition
const userSchema = new Schema(
    {
        email: { type: String, required: true, unique: true },
        name: { type: String, required: true },
        password: { type: String, required: true },
    },
    {
        timestamps: true, // mongoose assign createdAt and updatedAt fields to the schema automatically
    }
);

// hash the password before(hence pre) saving
userSchema.pre('save', async function (next) {
    let user = this as IUserDocument; // a little trick so don't have to reference this later

    // only hash the password if it has been modified (or is new)
    if (!user.isModified('password')) {
        return next();
    }

    try {
        // salt is the random data that is used as an additional input to a one-way function that hashes data
        const salt = await bcrypt.genSalt(+process.env.saltWorkFactor); // saltWorkFactor is a string in .env file, +saltWorkFactor convert it to number
        const hash = await bcrypt.hashSync(user.password, salt);
        user.password = hash;

        return next();
    } catch (error) {
        return next(error);
    }
});

// Password validation method
userSchema.methods.comparePassword = async function (
    candidatePassword: string
): Promise<boolean> {
    const user = this as IUserDocument;
    try {
        return bcrypt.compare(candidatePassword, user.password);
    } catch (error) {
        return false;
    }
};

// User Model
const UserModel = model<IUserDocument>('User', userSchema);

export default UserModel;

參考資料

Mongoose v6.2.3: Middleware (https://mongoosejs.com/docs/middleware.html)

Mongoose中介軟體入門示例 (https://www.itread01.com/content/1549650963.html)