前面已經建立好 users Table,也透過 ORM 開始操作資料庫。到了這裡,User 的資料結構算是準備好了,接下來就可以開始處理使用者註冊。
註冊看起來其實不複雜,使用者送進 email 和 password,後端驗證資料,確認沒問題後再把 User 存進資料庫。不過真正開始寫之前,有一件事情得先想清楚:使用者的密碼到底要怎麼存?
最直覺的做法,可能就是把使用者輸入的密碼直接寫進資料庫:
email: user@example.com
password: 12345678
這種方式就是直接保存明文密碼。問題在於,只要資料庫外洩,攻擊者拿到的就已經是可以直接使用的密碼。如果使用者又習慣在不同網站使用相同密碼,一個資料庫外洩,也可能牽連到其他帳號。
所以在設計註冊功能時,第一件要注意的事情就是:原始密碼不能直接存進資料庫。
仔細想想,使用者登入的時候,其實也不需要把當初設定的密碼找回來。後端只需要確認一件事:
這次輸入的密碼,和註冊時設定的是不是同一個?
既然登入只需要完成這個確認,就可以在密碼存進資料庫之前先做一次轉換,只留下轉換後的結果,這就是 Hash。
例如:
password123
↓
Hash
↓
Hash 結果
Hash 可以理解成一種單向的轉換方式,產生結果之後,無法直接從結果還原出原本的資料。因此資料庫裡保存的是 passwordHash,使用者真正輸入的密碼則不會被保存。
這和一般資料加密的使用情境不太一樣。像信用卡資料這類資訊,之後可能還需要取得原始內容,就會需要能夠透過金鑰解開的加密方式;密碼則只需要拿來確認使用者輸入是否正確,所以使用 Hash 就很適合。
可以先這樣理解:
加密
→ 之後還需要取得原始資料
Hash
→ 留下轉換後的結果,用來驗證
如果只是把密碼直接拿去 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 是專門用來處理密碼 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 就把其他請求卡住。
假設使用者註冊時輸入:
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
↓
確認密碼
↓
成功 → 登入
失敗 → 帳號或密碼錯誤
這也是帳號登入功能中很常見的一個安全細節:錯誤訊息要提供足夠的資訊讓使用者知道登入失敗,但不要透露太多帳號狀態。
前面的概念都了解之後,就可以把它真正放進註冊 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。