Node.js 主線任務四:健身房 Admin 登入系統(JWT + bcrypt)
🛑 防暴雷警示:以下筆記包含主線任務的解答與實作細節,強烈建議先親手寫過一次,卡關再來對照參考喔!
本次任務將整合前面所學的知識,替健身房後台加上完整的登入系統。實作範圍包含會員註冊、登入驗證、取得個人資料,並使用 bcrypt 進行密碼雜湊,以及透過 jsonwebtoken (JWT) 結合中介軟體 (Middleware) 來保護私密路由。
任務一:JWT 守門員 (middlewares/verifyToken.js)
🎯 題目要求:
- 讀取
req.headers.authorization,header 沒帶或格式錯誤就回應 401。 - 使用
jwt.verify驗證 Token,失敗則回應 401。 - 驗證通過則將解析出的 payload 掛載到
req.user並呼叫next()。
💡 關鍵點與常犯錯誤:
- 提取 Token 的細節:前端傳來的 Authorization 格式通常為
Bearer <token>。檢查時startsWith('Bearer ')務必注意要有空格,並且需要用字串分割 (split(' ')[1]) 提取出第二個部分的真實 Token。 - 致命崩潰 (Crash) 預警:驗證 Token (
jwt.verify) 的過程如果遇到過期或假造的 Token 會直接拋出例外錯誤 (Throw Error)。如果沒有使用try...catch包覆,整個 Node.js 伺服器會直接當機掛掉!
💻 點擊展開程式碼解答
const jwt = require("jsonwebtoken");
const SECRET = process.env.JWT_SECRET;
const verifyToken = function (req, res, next) {
// 1. 取得 authorization 欄位
const authHeader = req.headers.authorization;
// 1-1. 檢查是否帶入 authHeader 以及格式是否以 'Bearer ' 開頭
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return res.status(401).json({ status: "false", message: "請先登入" });
}
// 1-2. 取得 token 本體
const token = authHeader.split(" ")[1];
try {
// 2-1. 驗證 token 是否正確與未過期
const decoded = jwt.verify(token, SECRET);
// 2-2. 將使用者資訊掛載到 req.user
req.user = decoded;
// 2-3. 驗證通過,交給下一個 middleware
next();
} catch (err) {
// 3. 解析失敗或已過期,回傳 401
return res
.status(401)
.json({ status: "false", message: "Token 無效或已過期" });
}
};
module.exports = verifyToken;
任務二:註冊 API (POST /auth/register)
🎯 題目要求:
- 檢查
email與password必填。 - 檢查
email是否已存在。 - 使用
bcrypt進行密碼加密後,存入users陣列。
💡 關鍵點與常犯錯誤:
- 尋找重複信箱:使用
Array.some()可以有效率地檢查信箱是否已重複註冊。 - 非同步的陷阱:密碼加密的
bcrypt.hash()屬於非同步操作,記得要在路由函式前加上async並使用await,否則你會存入一個 Promise 物件而不是加密字串。 - Headers sent 錯誤:在做
if條件判斷並回傳錯誤訊息時,務必記得在res.status(400).json(...)前面加上return。否則程式會繼續往下執行,導致出現Cannot set headers after they are sent to the client的經典報錯。
💻 點擊展開程式碼解答
// 密碼雜湊函式
async function hashPassword(password) {
// 1. 產生 Salt(鹽巴),cost factor 設為 10
const salt = await bcrypt.genSalt(10);
// 2. 將明文密碼與生成的 salt 結合並進行雜湊運算
return await bcrypt.hash(password, salt);
}
router.post("/register", async (req, res) => {
const { email, password } = req.body;
// 1. 驗證必填欄位
if (!email || !password) {
return res
.status(400)
.json({ status: "false", message: "缺少 email 或 password" });
}
// 2. 驗證信箱唯一性
const emailExists = users.some((user) => user.email === email);
if (emailExists) {
return res.status(400).json({ status: "false", message: "email 已存在" });
}
// 3. 密碼加密:透過自訂的 hashPassword 將密碼加密
const hashedPassword = await hashPassword(password);
// 4. 建立使用者資料並存入
const newUser = {
id: nextId,
email: email,
password: hashedPassword,
};
users.push(newUser);
nextId++;
// 5. 回傳註冊成功
res.status(201).json({ status: "success", message: "註冊成功" });
});
任務三:登入 API (POST /auth/login)
🎯 題目要求:
- 檢查帳號是否存在,以及密碼是否正確,錯誤皆回傳 401 且訊息一致。
- 驗證成功後,使用
jwt.sign簽發 Token。
💡 關鍵點與常犯錯誤:
- 防範帳號探測 (Account Enumeration):無論是找不到帳號或密碼錯誤,都應統一回傳「帳號或密碼錯誤」,不讓惡意攻擊者猜出哪些信箱已註冊。
- 陣列越界與 Undefined 崩潰:如果
users.findIndex找不到信箱,會回傳-1。若沒有先判斷index === -1就直接存取users[index].password來比對,會引發Cannot read properties of undefined導致伺服器掛掉。 - 資安大忌:簽發 Token 的 Payload 裡絕對不能放密碼,只能放置
id或email等非機密識別資訊。
💻 點擊展開程式碼解答
router.post("/login", async (req, res) => {
const { email, password } = req.body;
// 1. 尋找使用者
const index = users.findIndex((item) => item.email === email);
// 2. 防範帳號探測:找不到使用者統一回傳 401
if (index === -1) {
return res.status(401).json({ status: "false", message: "帳號或密碼錯誤" });
}
// 3. 驗證密碼
const checkPassword = await bcrypt.compare(password, users[index].password);
if (!checkPassword) {
return res.status(401).json({ status: "false", message: "帳號或密碼錯誤" });
}
// 4. 簽發 Token
const token = jwt.sign(
{ id: users[index].id, email: users[index].email },
process.env.JWT_SECRET,
{ expiresIn: "30d" },
);
// 5. 回傳成功與 Token
res.status(200).json({ status: "success", token });
});
任務四:取得個人資料 (GET /auth/me)
🎯 題目要求:
- 此為受保護路由,必須經過
verifyToken驗證。 - 驗證後,直接回傳解析出的使用者資訊 (
req.user)。
💡 關鍵點:
- Express 的路由可以傳入多個 Middleware,將
verifyToken放在路徑與 Controller 之間,就能當作守門員。
💻 點擊展開程式碼解答
// 注意:在路由路徑和 handler 之間加上 `verifyToken` 作為守門員
router.get("/me", verifyToken, (req, res) => {
// 驗證成功後,直接回傳在 Middleware 中掛載的 req.user
res.status(200).json({ status: "success", user: req.user });
});
任務五:App 組裝 (app.js)
🎯 題目要求:
- 掛載
cors、express.json以及路由。 - 實作 404 守門員與捕捉所有例外的錯誤處理守門員。
💡 關鍵點與常犯錯誤:
- 順序非常重要:404 守門員與錯誤處理守門員必須放在所有路由的最底下!
- 通用設定 (
cors,express.json) - 應用程式路由 (
/auth,/docs) - 404 找不到路由 (Catch-all)
- 全域錯誤處理
- 通用設定 (
- 魔鬼藏在參數裡:全域錯誤處理 Middleware 必須有完整四個參數
(err, req, res, next)。即使你沒用到next也絕對不能省略!只要少寫一個參數變成三個,Express 就會把它當作一般 Middleware,導致遇到 JSON 格式錯誤 (SyntaxError) 時完全無法捕捉!
💻 點擊展開程式碼解答
const express = require("express");
const cors = require("cors");
const swaggerUi = require("swagger-ui-express");
const authRouter = require("./routes/auth");
const swaggerDoc = require("./fixtures/swagger.json");
const app = express();
// 1. 允許跨域請求
app.use(cors());
// 2. 解析 JSON Body
app.use(express.json());
// 3. Swagger UI
app.use("/docs", swaggerUi.serve, swaggerUi.setup(swaggerDoc));
// 4. 掛載會員路由
app.use("/auth", authRouter);
// 5. 404 守門員
app.use((req, res, next) => {
res.status(404).json({ status: "false", message: "無此路由資訊" });
});
// 6. 錯誤處理守門員(必須有 4 個參數)
app.use((err, req, res, next) => {
res.status(500).json({
err: err.name,
message: err.message,
});
});
module.exports = app;