iT邦幫忙

2026 iThome 鐵人賽

DAY 19
0
佛心分享-IT 人自學之術

出發吧!後端菜鳥:30 天的後端學習紀錄系列 第 19 篇

Day 19|密碼千萬不能直接存!bcrypt 登場

  • 分享至 

  • xImage
  •  

前言

前面已經建立好 users Table,也透過 ORM 開始操作資料庫。到了這裡,User 的資料結構算是準備好了,接下來就可以開始處理使用者註冊。

註冊看起來其實不複雜,使用者送進 email 和 password,後端驗證資料,確認沒問題後再把 User 存進資料庫。不過真正開始寫之前,有一件事情得先想清楚:使用者的密碼到底要怎麼存?

最直覺的做法,可能就是把使用者輸入的密碼直接寫進資料庫:

email: user@example.com
password: 12345678

這種方式就是直接保存明文密碼。問題在於,只要資料庫外洩,攻擊者拿到的就已經是可以直接使用的密碼。如果使用者又習慣在不同網站使用相同密碼,一個資料庫外洩,也可能牽連到其他帳號。

所以在設計註冊功能時,第一件要注意的事情就是:原始密碼不能直接存進資料庫。

密碼為什麼需要 Hash?

仔細想想,使用者登入的時候,其實也不需要把當初設定的密碼找回來。後端只需要確認一件事:

這次輸入的密碼,和註冊時設定的是不是同一個?

既然登入只需要完成這個確認,就可以在密碼存進資料庫之前先做一次轉換,只留下轉換後的結果,這就是 Hash。

例如:

password123
     ↓
   Hash
     ↓
Hash 結果

Hash 可以理解成一種單向的轉換方式,產生結果之後,無法直接從結果還原出原本的資料。因此資料庫裡保存的是 passwordHash,使用者真正輸入的密碼則不會被保存。

這和一般資料加密的使用情境不太一樣。像信用卡資料這類資訊,之後可能還需要取得原始內容,就會需要能夠透過金鑰解開的加密方式;密碼則只需要拿來確認使用者輸入是否正確,所以使用 Hash 就很適合。

可以先這樣理解:

加密
→ 之後還需要取得原始資料

Hash
→ 留下轉換後的結果,用來驗證

為什麼還需要 Salt?

如果只是把密碼直接拿去 Hash,還會遇到一個問題:相同的密碼會得到相同的 Hash。

假設兩個使用者都設定:

password123

經過 Hash 後,可能都會得到相同的結果:

password123
     ↓
   Hash
     ↓
Hash A

這樣一來,如果攻擊者已經知道某個常見密碼對應的 Hash,就可以拿這個結果去和其他帳號比較,判斷不同使用者是不是使用了相同的密碼。

因此在處理密碼時,通常還會加入一段隨機產生的資料,也就是 Salt。

例如兩個人都使用 password123,但加入不同的 Salt:

password123 + Salt A
        ↓
      Hash
        ↓
      Hash A

password123 + Salt B
        ↓
      Hash
        ↓
      Hash B

即使兩個人的密碼完全一樣,最後得到的 Hash 也會不同。Salt 的作用就在這裡,讓相同的密碼不會因為輸入完全相同,就產生完全相同的結果。

到了這裡,可以先把這幾個概念串起來:Hash 負責把密碼轉換成不能直接還原的結果,Salt 讓相同密碼可以產生不同的結果,而接下來要使用的 bcrypt,就是幫我們處理這些事情的工具。

bcrypt 是什麼?

bcrypt 是專門用來處理密碼 Hash 的方法,在 Node.js 裡可以直接安裝 bcrypt 套件來使用。

它有兩個特性特別重要。第一個是會自動產生 Salt,所以我們不用自己產生,也不用另外設計一個欄位來保存。第二個是可以設定計算成本,讓產生 Hash 需要一定的時間。

為什麼要故意讓它慢一點?因為密碼 Hash 如果算得太快,攻擊者拿到資料庫裡的 Hash 後,就可以在短時間內嘗試大量密碼。bcrypt 刻意增加計算成本,就是希望讓這種大量嘗試變得更困難。

在 Node.js 專案中,可以先安裝:

npm install bcrypt

接著引入:

import bcrypt from "bcrypt";

平常最常使用的就是兩個方法:

bcrypt.hash()
→ 把密碼轉成 Hash

bcrypt.compare()
→ 確認密碼是否正確

另外,bcrypt 的計算本身需要一些 CPU 資源,因此在 Node.js Server 中通常會使用非同步方式,避免一次 Hash 就把其他請求卡住。

先實際 Hash 一個密碼

假設使用者註冊時輸入:

const password = "password123";

可以直接使用:

const passwordHash = await bcrypt.hash(password, 10);

這裡的 10 是計算成本的設定,bcrypt 會根據這個設定產生 Salt 並完成 Hash。

最後可能會得到類似這樣的結果:

$2b$10$.....................................................

如果把同一個密碼 Hash 兩次,得到的結果不一定相同:

await bcrypt.hash("password123", 10);
await bcrypt.hash("password123", 10);

這其實是正常的,因為每次產生的 Salt 都可能不同。雖然 Hash 不一樣,之後還是可以正常驗證。

這也是 bcrypt 幫我們處理好的地方,我們不用自己產生 Salt,也不用另外把 Salt 存在資料庫裡,bcrypt 會把之後驗證需要的資訊一起放進產生的結果中。

登入時怎麼驗證?

看到這裡可能會開始有一個疑問:如果資料庫裡只有 Hash,那登入的時候到底要怎麼知道密碼對不對?

這時候就會用到 bcrypt.compare()。例如:

const isMatch = await bcrypt.compare(
  password,
  user.passwordHash,
);

假設使用者這次輸入:

password123

而資料庫裡保存的是:

$2b$10$...

bcrypt 會根據資料庫裡的 Hash 進行驗證,最後得到:

true

或:

false

註冊時使用 bcrypt.hash() 產生 Hash,登入時則使用 bcrypt.compare() 確認使用者輸入的密碼是否符合資料庫裡保存的 Hash。

註冊
使用者輸入密碼
        ↓
bcrypt.hash()
        ↓
passwordHash
        ↓
存進資料庫
登入
使用者輸入密碼
        ↓
bcrypt.compare()
        ↓
比對 passwordHash
        ↓
true / false

登入失敗時,錯誤訊息也需要稍微注意,假設使用者輸入了一個不存在的 Email,如果直接回覆「帳號不存在」,攻擊者就可以透過不斷嘗試 Email,找出哪些帳號真的存在。

同樣地,如果 Email 存在,但密碼錯誤,也不應該直接回覆「密碼錯誤」。

因此,登入失敗時通常會使用統一的訊息,例如:

{
  "message": "Invalid email or password"
}

這樣不管是 Email 不存在,還是密碼不正確,前端看到的都是相同結果,可以減少帳號是否存在被猜出的機會。

所以登入時可以把流程理解成:

輸入 Email + Password
        ↓
尋找 User
        ↓
確認密碼
        ↓
成功 → 登入
失敗 → 帳號或密碼錯誤

這也是帳號登入功能中很常見的一個安全細節:錯誤訊息要提供足夠的資訊讓使用者知道登入失敗,但不要透露太多帳號狀態。

實作 POST /auth/register

前面的概念都了解之後,就可以把它真正放進註冊 API。

假設 API 是:

POST /auth/register

前端送進:

{
  "email": "user@example.com",
  "password": "password123"
}

那後端大致會按照這個順序處理:

收到註冊資料
      ↓
驗證 email / password
      ↓
確認 email 是否已存在
      ↓
使用 bcrypt 處理密碼
      ↓
建立 User
      ↓
儲存到資料庫
      ↓
回傳註冊結果

首先取得使用者送進來的資料:

const { email, password } = req.body;

接著確認這個 Email 有沒有註冊過。如果已經存在,就回傳 409 Conflict:

const existingUser = await userRepository.findOneBy({
  email,
});

if (existingUser) {
  return res.status(409).json({
    message: "Email already exists",
  });
}

確認 Email 沒有重複之後,再把密碼交給 bcrypt 處理:

const passwordHash = await bcrypt.hash(password, 10);

接著建立 User,這裡存進資料庫的是 passwordHash:

const user = userRepository.create({
  email,
  passwordHash,
});

await userRepository.save(user);

把整個流程放在一起,就是:

const { email, password } = req.body;

const existingUser = await userRepository.findOneBy({
  email,
});

if (existingUser) {
  return res.status(409).json({
    message: "Email already exists",
  });
}

const passwordHash = await bcrypt.hash(password, 10);

const user = userRepository.create({
  email,
  passwordHash,
});

await userRepository.save(user);

return res.status(201).json({
  message: "User registered successfully",
});

這時候資料庫裡真正保存的會是:

users

id | email            | password_hash
1  | user@example.com | $2b$10$...

可以看到,資料庫裡已經沒有 password123。即使直接打開資料庫查看,也只能看到 Hash。

另外,註冊 API 的回應也不要把 passwordHash 傳回前端。它雖然已經不是原始密碼,但仍然屬於敏感的驗證資料。註冊成功後,前端知道註冊成功就夠了。

小結

這一天最重要的觀念,就是使用者的密碼不能直接存進資料庫。

註冊時,使用者輸入的密碼會先經過 bcrypt 產生 Hash,再把 passwordHash 存進資料庫。bcrypt 會自動處理 Salt,也會增加計算成本;到了登入時,再使用 bcrypt.compare() 確認使用者輸入的密碼是否符合資料庫裡保存的 Hash。

整個註冊流程現在就變成:

POST /auth/register
        ↓
驗證輸入資料
        ↓
確認 Email 是否存在
        ↓
bcrypt.hash(password)
        ↓
儲存 passwordHash
        ↓
建立 User

做到這裡,註冊 API 已經不只是把一筆 User 寫進資料庫,也開始碰到帳號安全性。資料庫保存的是用來驗證密碼的 Hash,使用者真正的密碼則不會被保存。

接下來有了註冊功能,就會遇到下一個問題:使用者登入成功之後,後端要怎麼知道現在登入的是哪一個 User?下一篇就會進入登入與 JWT。


上一篇
Day 18|資料庫結構也要版本管理:認識 Migration
下一篇
Day 20|從登入到身分驗證:認識 JWT
系列文
出發吧!後端菜鳥:30 天的後端學習紀錄 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言