驗證哪些內容、密碼怎麼存、JWT 怎麼判斷登入?
這是一篇由 NestJS + PostgreSQL 實 作回顧開發過程的踩坑、學習文,主要會聚焦並回答以下幾個問題:
- 應該要驗證哪些內容?
- 密碼怎麼儲存?
- 如何識別使用者登入、登出的狀態?
- 如果用不同裝置登入相同帳號怎麼辦?
後端的驗證
基於過往前端的開發經驗,最初在設計驗證流程時並不是完全沒有想法。站在前端的視角,第一個直覺通常會是:
導入註冊頁面 -> 填寫註冊欄位資料 -> 信箱驗證 -> 完成驗證 -> 登入
前端的驗證主要聚焦在資料格式的正確性,不論是欄位驗證還是 API 資料格式,通常都屬於這個範疇。學習後端之後,我才意識到驗證其實可以拆成不同層次。以這個專案來說,大致包括:
- 格式驗證:類似前端的欄位檢查,主要針對資料型別與結構
- 業務驗證:例如 email 是否允許重複註冊
- 身份驗證:如何證明使用者輸入的信箱、電話真的是他本人的
- 授權驗證:確認身份後,要如何發給他合法憑證
要驗證哪些內容?
上面提到了四個層級的驗證,接著可以回頭看我的專案實作,確認 API 是否真的有把這幾件事補齊。
先看一下 NestJS 常見的請求流程:
Request -> Middleware -> Guard -> Interceptor(前) -> Pipes(ValidationPipe) -> Controller -> Interceptor(後) -> Response
資料格式驗證會在 Pipes 階段觸發,所以請求如果能順利抵達 Controller,通常代表格式驗證已經通過。
接著在 Controller 階段,才會開始呼叫這個 API 所需的方法。以下的 register 就是註冊流程的核心:
async register(dto: RegisterDto) {
// 業務驗證:確認兩次密碼相同(可選)
this.ensurePasswordConfirmed(dto.password, dto.password_confirm);
// 業務驗證:確認信箱的唯一性
const user = await this.users.createUser(dto);
// 身份驗證:寄出信箱驗證碼
await this.issueVerificationCode(user.id, user.email);
return {
data: {
user_id: user.id,
email: user.email,
name: user.name,
email_verified: false,
},
message: 'register successful, please verify your email',
};
}
接著再看 createUser 實際做了哪些事情:
async createUser(input: {
email: string;
password: string;
name: string;
}): Promise<User> {
const email = this.normalizeEmail(input.email);
const exists = await this.users.findOne({ where: { email } });
// 確認信箱唯一性
if (exists) {
throw new ConflictException({
code: 'EMAIL_ALREADY_EXISTS',
message: 'email is already used',
});
}
const hashed = await bcrypt.hash(input.password, this.saltRounds);
const user = this.users.create({
email,
password: hashed,
name: input.name.trim(),
// 注意下面兩行
role: 'user',
emailVerifiedAt: null,
});
return this.users.save(user);
}
仔細比對之後,會發現上面的 API 還少了一塊:授權驗證。
常見做法是後端在註冊或登入成功後,直接簽發 token 給前端處理。但這個 register 的 response 並沒有回傳 token,原因是我想先把信箱驗證放在登入前面。
假設今天的產品情境是:
使用者在未完 成信箱認證的情形下,也可以進行場地時段預約
那麼就可能冒出幾個問題:
- 我們怎麼知道他是不是盜用他人信箱?
- 預約完成後,也無法可靠地寄送確認信給真正的使用者
所以我最後在流程設計上,選擇把回傳驗證 token 這件事延後到信箱驗證完成之後:
導入註冊頁面 -> 填寫註冊欄位資料 -> 信箱驗證 -> 完成驗證 -> 登入(取得 Token)
這段思考對我來說很有趣,因為它沒有絕對標準答案,比較像是根據產品性質做決策。這也讓我更明確地意識到:
驗證流程不只是資安問題,它本質上也是產品問題。
同樣都是註冊登入,訂位服務、社群網站、銀行 App,最後做出的流程選擇很可能完全不同。
密碼怎麼儲存?
密碼欄位是註冊時的必填資料,但後端絕對不能明文儲存密碼。
原因很直接,一旦資料庫外洩,所有使用者的帳號都會立刻暴露,而且很多人會在不同網站重複使用同一組密碼,風險會一起被放大。
所以實務上儲存的不是 password 本身,而是 password hash。
以這個專案來說,我使用的是 bcrypt:
const hashed = await bcrypt.hash(input.password, this.saltRounds);
這段可以拆成兩個重點:
- 把明文密碼轉成雜湊值
- 加入 salt,避免相同密碼產生相同結果
這代表就算兩個使用者都設定 12345678,最後存進資料庫的值也不會長得一樣。
之後登入時,後端也不會把資料庫裡的 hash 還原回密碼,而是用 bcrypt.compare() 去比對:
const matched = await bcrypt.compare(input.password, user.password);
如果比對成功,代表「使用者現在輸入的密碼」和「當初註冊時輸入的密碼」一致。
這裡有一個很重要的觀念:
後端不需要知道使用者原本的密碼,只需要知道它是不是同一組。
這也是為什麼忘記密碼流程通常不是「把原密碼寄給你」,而是「讓你重新設定新密碼」。因為理想情況下,系統本來就不應該能還原你的密碼。