Express + MongoDB 實作使用者增刪改查 API 及 JWT 驗證

最後更新於 2022 年 7 月 3 日

目標:

  1. 新增使用者增刪改查的 API
  2. 新增登入 API (含 token 的產生及密碼加密)

準備工作

以下為本篇文章所需安裝的 package:

  • mongoose
  • cors
  • jsonwebtoken
  • bcrypt
STEP 1

安裝所需套件:

mkdir myapp
cd myapp
npm init
npm i express mongoose --save
STEP 2

在根目錄底下新增 src/webserver.js

const express = require("express");
const WebServer = express();
const cors = require("cors");

WebServer.use(cors());
WebServer.use(express.json());
WebServer.use(express.urlencoded({ extended: false }));

WebServer.get("/health", function (req, res) {
  res.setHeader("Content-Type", "application/json");
  res.send({ message: "I'm alive." });
});

WebServer.get("/", (req, res) => {
  res.send("Hello, World!");
});

WebServer.use(function onError(err, req, res, next) {
  res.statusCode = 500;
  res.end(err);
});

module.exports = WebServer;
STEP 3

新增 src/index.js

// src/index.js
const app = require("./webserver");
const port = 8080;

app.listen(port);
STEP 4

接著在 package.json 中加上 start 指令後 npm start 啟動 expressjs 測試一下 (如果沒裝 nodemon 可以裝在全局):

// package.json
"scripts": {
    "start": "nodemon src/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },

打開瀏覽器輸入 http://localhost:8080 測試一下能不能看到 Hello, World!。

連線到 MongoDB

STEP 1

接著在根目錄底下新建 db/index.js,專門用於寫與 db 的連接邏輯,這裡我們使用的是 mongoose 和 mongoDB 連線:

  • mongoose.connect("mongodb://[MongoDB位置]:[port, 預設為 27017]/[資料庫名稱]", {})
  • 下方範例的資料庫名稱為 demo,當啟動 app 時 mongoose 會自動檢查是否有這個資料庫,如果沒有則會建立一個。
// src/db/index.js
const mongoose = require("mongoose");

// 與 mongoDB 建立連線
mongoose.connect("mongodb://localhost:27017/demo", {
  useNewUrlParser: true, // url 字串解析器
  useUnifiedTopology: true, // 使用統一拓撲
});

module.exports = mongoose;
STEP 2

在 index.js 中引入 mongoose:

// src/index.js
const mongoose = require('./db');
const db = mongoose.connection;
// 取得資料庫連線狀態
db.once("open", (db) => console.log("Connected to MongoDB")); // 連線成功
db.on("error", (err) => console.error("connection error", err)); // 連線異常

Mongoose Schema & Model

Mongoose 中的一切都以 Schema 開始。每個 Schema 映射到一個 MongoDB collection ,並定義這個collection 裡的文檔(documents) 的構成。

Model 是從 Schema 編譯來的建構子。它們的實例(instance)可以從 DB 保存和讀取 documents。從 DB 創建和讀取 documents 的所有操作都是通過 Model 進行的。

現在我們先在根目錄底下新建 models/User.js

const mongoose = require("mongoose");

const UserSchema = new mongoose.Schema({
  account: {
    type: String, // 類型
    required: true, // 是否必要
    trim: true, // 是否需要消除前後空格
    unique: true, // 是否唯一
    minLength: [6, "帳號需至少 6 個字符以上"], // [最小長度, 錯誤訊息]
  },
  password: {
    type: String,
    required: true,
    trim: true,
    minLength: [6, "密碼需至少 6 個字符以上"],
    // 也可以使用 validate 函數來進行資料驗證
    validate(value) {
      if (value.toLowerCase().includes("123456")) {
        throw new Error("密碼不能為123456!");
      }
    },
  },
  authority: {
    type: Number,
    required: true,
  }
}, {
  // 存入的 document 是否要有 createdAt 和 updatedAt 時間
  timestamps: true
});

// 添加使用者相關方法
UserSchema.method("getAccount", function () {
  return this.account;
});

UserSchema.method("getAuthority", function () {
  return this.authority;
});

// 在 mongoDB 中建立名為 User 的 collection
const User = mongoose.model("User", UserSchema);

module.exports = User;

Express 路由

STEP 1

新增 routes/user.js 並引入剛剛寫的 User Modal:

const User = require("../models/User");

module.exports = function (app, cors) {

}
STEP 2

新增 routes/index.js 引入所有 endpoint:

module.exports = (WebServer, cors) => {
  require("./user")(WebServer, cors)
}
STEP 3

webserver.js 中引入 routes/index.js,並將 WebServer 及 cors 傳入:

// ...

WebServer.get("/", (req, res) => {
  res.send("Hello, World!");
});

require("./routes")(WebServer, cors);

WebServer.use(function onError(err, req, res, next) {
  res.statusCode = 500;
  res.end(err);
});

// ...

創建 Documents

完成以上準備工作之後,現在我們可以來實作使用者 API 了。

STEP 1

首先安裝 bcrypt,用於對使用者輸入的密碼加密後保存到 db 中:

npm i bcrypt --save
STEP 2

接著在 routes/user.js 中使用 new User(req.body) 創建 User Modal 的實例,並使用 save() 函數將資料保存到 db 中,記得將密碼透過 bcrypt 的 hashSync() 函數進行加密:

// routes/user.js
const User = require("../models/User");
const bcrypt = require("bcrypt");

module.exports = function (app, cors) {
  // post /register 註冊用戶
  app.post("/register", async (req, res) => {
    try {
      // 將使用者輸入的密碼進行加密
      const hashPwd = bcrypt.hashSync(req.body.password, 10);
      let userObj = { ...req.body, password: hashPwd };
      // 新增一個 User model 的實例(instance)
      const user = new User(userObj);
      // 將資料保存到 db 中
      await user.save();
      res.send(user);
    } catch (e) {
      res.status(404).send(e);
    }
  });
}

一定要確保加密過後的密碼保存到 db 時有儲存完全,否則登入時比對會失敗。

STEP 3

由於目前我們還沒寫前端頁面,所以我們暫時先用 POSTMAN 測試一下 post /user API 能不能正常使用:

  1. 新建一個 request: POST http://localhost:8080/user
  2. 下方選擇 Body -> raw (JSON)
postman Express + MongoDB 實作使用者增刪改查 API 及 JWT 驗證

測試一下長度不足密碼為 123456 時會回傳什麼結果:

validate Express + MongoDB 實作使用者增刪改查 API 及 JWT 驗證

如果驗證成功就代表順利新增一筆使用者到 db 裡面了。

post user Express + MongoDB 實作使用者增刪改查 API 及 JWT 驗證
user documents Express + MongoDB 實作使用者增刪改查 API 及 JWT 驗證

但通常來說密碼會經過加密後才存到 db 中,所以後面會提到如何幫密碼加密再存入 db。

實作使用者的增刪改查 API

剛剛上方已經將 POST 請求完成,那麼接下來我們就要實作獲取使用者列表、修改使用者資料以及刪除使用者的部分。

獲取使用者列表

mongoose 提供了 find() 函數用於執行查詢語句,因為是無條件展示用戶列表所以做最簡單的 find() 將結果全部返回即可:

  • find(篩選條件, (err, doc) => {});
// routes/user.js
// get /user/list 獲取用戶列表
app.get("/user/list", (req, res) => {
  User.find({}, (err, doc) => {
    if (err) res.status(404).send(err);
    res.send(doc);
  });
});

還有 findById(), findOne() …等,可以自行到 Mongoose docs 查看。

修改使用者資料

修改使用者的話我們可以透過 query 傳送 account (或者 id),查詢 db 中是否有該用戶的資料,並且因為 account 是作為識別的 key 所以無法修改。

  • updateOne(篩選條件, 修改內容, (err, doc) => {})
// routes/user.js
// put /user 修改使用者資料
app.put("/user", (req, res) => {
  const { account } = req.query;
  const filter = { account }; // 欲修改的用戶
  const update = req.body; // 修改的內容

  // 不能修改帳號
  if ("account" in req.body) {
    res.status(403).send({ success: false, message: "無法修改帳號。" });
  } else {
    // 找到該用戶並修改資料
    User.updateOne(filter, update, (err, doc) => {
      if (err) res.status(404).send(err);
      // 沒找到該用戶
      if (!doc.matchedCount) {
        res.status(404).send({ success: false, message: `使用者${account}並不存在。` });
      }
      res.send({ success: true, message: `修改使用者${account}成功!` });
    });
  }
});

刪除使用者

刪除使用者和修改使用者類似,都是透過 account 查找資料。

  • deleteOne(篩選條件, (err, doc) => {})
// routes/user.js
// delete /user 刪除使用者
app.delete("/user", (req, res) => {
  const { account } = req.query;
  User.deleteOne({ account }, (err, doc) => {
    if (err) return res.status(404).send(err);
    // 沒找到該用戶
    if (!doc.deletedCount) {
      return res.status(404).send({ success: false, message: `使用者${account}並不存在。` });
    }
    res.send({ success: true, message: `刪除使用者${account}成功!` });
  });
});

實作登入 API

接下來會實作登入路由以及 JWT 的產生和驗證,但由於本篇文章只是實作學習筆記,所以不會帶太多使用方式。

有興趣可以看我之前發的一篇文章,裡面有稍微詳細的介紹了在 Express 中使用 Session 和 JWT 驗證身分的方法:

STEP 1

首先需要安裝 jsonwebtoken,用於將使用者的資料加密成為 JWT 傳給前端作為登入狀態識別的方法。

npm i jsonwebtoken --save
STEP 2

然後在下方新增一個 UserSchema 實例能使用的產生 token 的方法,以及一個驗證 db 中是否有該用戶資料的方法:

  • 產生JWTjwt.sign(payload, key, options);
  • 比對密碼bcrypt.compare(使用者輸入的密碼, db中的密碼);
// models/User.js
const jwt = require("jsonwebtoken");
const bcrypt = require("bcrypt");

// 產生 token 的方法
UserSchema.methods.generateAuthToken = async function () {
  // this: 當前用戶實例
  const user = this;
  // 將使用者資料加密產生成 token
  const payload = { _id: user._id.toString(), account: this.account };
  const token = jwt.sign(payload, 'pluto', { expiresIn: '24h' });
  // 回傳 token
  return token;
}

// 驗證用戶是否存在
UserSchema.statics.findByCredentials = async (account, password) => {
  // 根據帳號至資料庫找尋該用戶資料
  const user = await User.findOne({ account });
  if (!user) throw new Error("WRONG_ACCOUNT");
  // 驗證密碼
  const isMatch = await bcrypt.compare(password, user.password);
  if (!isMatch) throw new Error("WRONG_PASSWORD");
  // 驗證成功回傳該用戶完整資料
  return user;
}
STEP 3

現在我們修改註冊 API,使註冊成功後也能直接產生 token,並且檢查 db 中是否已有相同帳號,如果已存在該帳號就拋出一個錯誤:

// routes/user.js
// post /register 註冊用戶
  app.post("/register", async (req, res, next) => {
    try {
      // 將使用者輸入的密碼進行加密
      const hashPwd = bcrypt.hashSync(req.body.password, 10);
      let userObj = { ...req.body, password: hashPwd };
      // 新增一個 User model 的實例(instance)
      const user = new User(userObj);
      // 產生 token
      const token = await user.generateAuthToken();
      // 將資料保存到 db 中
      await user.save().catch((e) => {
        // db 中已有相同帳號
        if (e.code === 11000)
          throw new Error("DUPLICATE_ACCOUNT");
        else
          throw new Error(e);
      });
      res.send({ user, token });
    } catch (e) {
      next(e);
    }
  });
post user with token Express + MongoDB 實作使用者增刪改查 API 及 JWT 驗證
STEP 4

接著在 src/routes/user.js 中加上 post /login 實作登入 API,大概邏輯如下:

  1. 在 db 中查找使用者輸入的帳號是否存在
  2. 比對使用者輸入的密碼和 db 中該帳號的密碼是否一致
  3. 依據使用者資料加密成 token 回傳給前端

但是因為前面我們已經把這三個步驟分為了 findByCredentials generateAuthToken 這兩個方法,所以我們只需要分別調用這兩個方法即可。

// routes/user.js
// post /login 登入
app.post("/login", async (req, res, next) => {
    try {
      const { account, password } = req.body;
      // 驗證帳號密碼是否正確
      const user = await User.findByCredentials(account, password);
      // 為該用戶產生 token
      const token = await user.generateAuthToken();
      res.send({ user, token });
    } catch (e) {
      next(e);
    }
  });
login with token api Express + MongoDB 實作使用者增刪改查 API 及 JWT 驗證

驗證前端請求 Token

在剛剛我們已經完成了註冊及登入時產生 token 並傳給前端的步驟,現在我們要來處理前端發送請求時的驗證,確保某些路由只有在使用者已經登入後才能訪問。

首先我們需要寫一個 middleware,用於判斷請求 headers 是否帶有正確有效的 token,如果有才放行,沒有就攔截。

新增 midllewares/auth-middleware.js

const jwt = require("jsonwebtoken");
const User = require("../models/User");

module.exports = async (req, res, next) => {
  // 登入及註冊不需要 token
  if (['/login', '/register'].includes(req.url)) {
    next();
  } else {
    try {
      // 從來自客戶端請求的 header 取得和擷取 JWT
      let token = req.headers.authorization;
      // headers 中存在 token
      if (token) {
        // 將 token 前方 Bearer 刪掉
        token = token.replace("Bearer ", "");
        // 驗證 Token 是否有效
        const decoded = jwt.verify(token, "pluto");
        // 查詢 token 解密出來所包含的 id 與帳號是否在 db 中存在
        const user = await User.findOne({
          _id: decoded._id,
          account: decoded.account,
        });
        // 沒找到該用戶代表 token 無效
        if (!user) {
          throw new Error('WRONG_TOKEN');
        }
        // token 有效
        next();
      } else {
        // headers 中並無 token
        throw new Error('WRONG_TOKEN');
      }
    } catch (err) {
      res.status(403).send({ success: false, message: `您無權進行此操作。${err}` });
    }
  }
};

然後將 auth middleware 放到 routes 之前,以確保這些路由需要有權限才能訪問:

// 確保路由需要驗證後才可訪問
const auth = require("./middlewares/auth.middleware");
WebServer.use(auth);
require("./routes")(WebServer, cors);

// 捕獲錯誤
const errors = [
  { key: 'WRONG_TOKEN', message: "TOKEN 無效" },
  { key: 'WRONG_ACCOUNT', message: "帳號錯誤" },
  { key: 'WRONG_PASSWORD', message: "密碼錯誤" },
  { key: 'DUPLICATE_ACCOUNT', message: "該帳號已被註冊" }
];

WebServer.use((err, req, res, next) => {
  const errorIndex = errors.findIndex(item => item.key === err.message);

  if (errorIndex > -1)
    return res.status(401).send({ success: false, message: errors[errorIndex].message });
  else
    res.status(500).send({ success: false, message: "伺服器端錯誤" });
});

文中提到的所有程式碼我已經 push 到我的 github repo 裡:React-Express-Mongoose-Login

brown wild mammal lying on tree trunk
Mongoose. Photo by Dušan veverkolog on Unsplash
0 0 評分數
Article Rating
訂閱
通知
guest

0 Comments
在線反饋
查看所有評論