跳至主要内容

驗證哪些內容、密碼怎麼存、JWT 怎麼判斷登入?

這是一篇由 NestJS + PostgreSQL 實作回顧開發過程的踩坑、學習文,主要會聚焦並回答以下幾個問題:

  1. 應該要驗證哪些內容?
  2. 密碼怎麼儲存?
  3. 如何識別使用者登入、登出的狀態?
  4. 如果用不同裝置登入相同帳號怎麼辦?

後端的驗證

基於過往前端的開發經驗,最初在設計驗證流程時並不是完全沒有想法。站在前端的視角,第一個直覺通常會是:

導入註冊頁面 -> 填寫註冊欄位資料 -> 信箱驗證 -> 完成驗證 -> 登入

前端的驗證主要聚焦在資料格式的正確性,不論是欄位驗證還是 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,原因是我想先把信箱驗證放在登入前面。

假設今天的產品情境是:

使用者在未完成信箱認證的情形下,也可以進行場地時段預約

那麼就可能冒出幾個問題:

  1. 我們怎麼知道他是不是盜用他人信箱?
  2. 預約完成後,也無法可靠地寄送確認信給真正的使用者

所以我最後在流程設計上,選擇把回傳驗證 token 這件事延後到信箱驗證完成之後:

導入註冊頁面 -> 填寫註冊欄位資料 -> 信箱驗證 -> 完成驗證 -> 登入(取得 Token)

這段思考對我來說很有趣,因為它沒有絕對標準答案,比較像是根據產品性質做決策。這也讓我更明確地意識到:

驗證流程不只是資安問題,它本質上也是產品問題。

同樣都是註冊登入,訂位服務、社群網站、銀行 App,最後做出的流程選擇很可能完全不同。

密碼怎麼儲存?

密碼欄位是註冊時的必填資料,但後端絕對不能明文儲存密碼

原因很直接,一旦資料庫外洩,所有使用者的帳號都會立刻暴露,而且很多人會在不同網站重複使用同一組密碼,風險會一起被放大。

所以實務上儲存的不是 password 本身,而是 password hash

以這個專案來說,我使用的是 bcrypt

const hashed = await bcrypt.hash(input.password, this.saltRounds);

這段可以拆成兩個重點:

  1. 把明文密碼轉成雜湊值
  2. 加入 salt,避免相同密碼產生相同結果

這代表就算兩個使用者都設定 12345678,最後存進資料庫的值也不會長得一樣。

之後登入時,後端也不會把資料庫裡的 hash 還原回密碼,而是用 bcrypt.compare() 去比對:

const matched = await bcrypt.compare(input.password, user.password);

如果比對成功,代表「使用者現在輸入的密碼」和「當初註冊時輸入的密碼」一致。

這裡有一個很重要的觀念:

後端不需要知道使用者原本的密碼,只需要知道它是不是同一組。

這也是為什麼忘記密碼流程通常不是「把原密碼寄給你」,而是「讓你重新設定新密碼」。因為理想情況下,系統本來就不應該能還原你的密碼。

要怎麼識別使用者的狀態?

當使用者完成登入之後,下一個問題就是:

後端要怎麼知道這個請求是誰發出的?

在 NestJS 專案裡,常見做法是使用 JWT(JSON Web Token)

登入成功後,後端會簽發一個 token 給前端;前端之後每次呼叫受保護的 API,都會帶上這個 token,後端再透過 JwtAuthGuard 驗證它是否合法。

整體概念大致如下:

登入成功 -> 後端簽發 token -> 前端保存 token
-> 呼叫 API 時帶上 Authorization: Bearer <token>
-> JwtAuthGuard 驗證 token
-> 驗證成功後,request.user 會有當前使用者資訊

也就是說,所謂的「登入狀態」,本質上不是後端記住了一個布林值,而是:

使用者是否持有一個仍然有效、且可以被驗證的憑證。

這也剛好可以對應到 NestJS 常見的設計分工:

  • Guard:負責驗證 token
  • @CurrentUser():從 request 取出目前登入使用者
  • @Public():標記哪些 API 不需要登入就能存取

所以對後端來說,「你現在是不是登入中」通常不是去查一個 is_login = true 欄位,而是看:

  1. 這個 token 是不是合法簽發的
  2. 有沒有過期
  3. 有沒有被撤銷
  4. 對應的使用者是否仍然有效

如果用不同裝置登入相同帳號怎麼辦?

這個問題會把「登入狀態」從單純的 token 驗證,拉到更接近產品設計的層次。

如果同一個帳號可以在手機、平板、電腦同時登入,那就不能只用「帳號目前是否登入」這種單一狀態去理解,而要改成:

同一個使用者,是否擁有多個獨立 session。

常見做法是把登入拆成兩種 token:

  • access token:有效期短,用來存取 API
  • refresh token:有效期長,用來換新的 access token

這樣做的好處是:

  • access token 就算外洩,風險時間也比較短
  • refresh token 可以和裝置、session 綁定
  • 登出某個裝置時,可以只撤銷該裝置的 refresh token,不影響其他裝置

如果再往下細分,通常有兩種產品策略:

  1. 允許多裝置同時登入 每個裝置都有自己的 refresh token / session,彼此互不影響。

  2. 只允許單一裝置登入 新裝置登入時,主動讓舊裝置的 session 失效。

這邊也再次回到一開始那個很重要的觀察:

登入狀態怎麼設計,不只是技術問題,也是產品策略問題。

小結

如果要用一句話總結這次實作,我現在對註冊登入系統的理解會是:

驗證流程其實是產品設計的一部分。

因為產品的性質,會直接影響流程與資料表的設計。像是是否要支援多裝置登入,就會影響 session 表要不要存在;選擇 session-based 還是 token-based,背後也是截然不同的取捨。

這次實作對我來說最有價值的地方,不只是把功能做出來,而是讓我開始能用更完整的視角思考一個產品。從前端既有的使用者體驗觀點,再往外延伸到後端驗證、資料設計、權限邏輯與整體產品策略,這是一段很棒的學習過程。