忘記密碼流程設計
忘記密碼不是「寄一封信讓使用者改密碼」這麼簡單。它本質上是一條帳號恢復流程:如果設計不好,攻擊者不需要知道舊密碼,只要拿到 reset token,就可能直接接管帳號。
一句話回答:忘記密碼流程要使用通用回應避免帳號枚舉,對請求端做 rate limit,產生高熵、短效、單次使用的 reset token,資料庫只存 token hash,重設頁避免 token 泄漏到 referrer 或 log;新密碼要套用密碼政策與安全 hash,成功後撤銷舊 session / refresh token、使 reset token 失效、寫 audit log 並通知使用者。
一、 必考觀念
1. 忘記密碼是帳號恢復流程
登入流程是:
使用者知道密碼
-> 驗證密碼
-> 建立登入狀態忘記密碼流程是:
使用者無法完成原本的身份驗證
-> 系統透過已綁定聯絡方式或恢復因素確認控制權
-> 允許使用者建立新密碼這代表忘記密碼流程繞過了原本的密碼驗證,所以安全等級必須非常謹慎。
2. Reset token 幾乎等於暫時登入能力
收到 reset link 的人通常可以:
打開重設頁
-> 設定新密碼
-> 取得帳號控制權所以 reset token 要當成敏感憑證處理:
- 高熵、不可猜。
- 短效。
- 單次使用。
- 綁定用途。
- 綁定帳號。
- 存 hash,不存明文。
- 成功後立即失效。
它的風險比一般 email verification token 更高。
3. 忘記密碼不是修改密碼
兩者差異:
| 流程 | 使用者狀態 | 要求 |
|---|---|---|
| 修改密碼 | 已登入 | 驗證舊密碼或 step-up authentication |
| 忘記密碼 | 未登入或無法登入 | 驗證恢復管道,使用 reset token |
修改密碼通常要求目前密碼或 MFA;忘記密碼則依賴 email、phone、recovery code、客服 KYC 或其他恢復機制。
延伸讀:信箱驗證與手機驗證。
二、 流程總覽
1. Sequence Diagram
2. 兩支 API
通常會拆成:
POST /password-reset-requests
POST /password-resets第一支負責「請求重設」:
{
"email": "user@example.com"
}第二支負責「使用 token 設定新密碼」:
{
"token": "raw-reset-token",
"newPassword": "new-user-password"
}不要把請求重設和真正改密碼混在一支 API 裡。
三、 請求重設密碼
1. 對外回應要一致
不好的回應:
這個 email 不存在這會造成帳號枚舉。
更好的回應:
如果這個信箱已註冊,我們會寄出重設密碼指示。OWASP Forgot Password Cheat Sheet 建議忘記密碼流程對存在與不存在的帳號回覆一致訊息,並維持一致的回應時間,避免攻擊者推測帳號是否存在。
2. 內部仍要記錄真實狀態
對外通用,不代表內部不能記錄。
Audit log 可以記:
password_reset_requested
target_exists=true
rate_limited=false
ip=...
userAgent=...
requestId=...但要避免記錄:
- 原始密碼。
- reset token 明文。
- 完整 email 若不必要。
- refresh token。
- session cookie。
可以用 identifierHash 代替明文 identifier。
3. 請求端要 rate limit
忘記密碼請求也會被濫用:
- 大量寄信騷擾使用者。
- 測試哪些帳號存在。
- 消耗 Email provider 配額。
- 針對高價值帳號製造社交工程入口。
建議限制:
| 維度 | 用途 |
|---|---|
| IP | 防同來源大量請求 |
| email / identifier | 防單一帳號被狂寄 |
| IP + identifier | 更精準 |
| user account | 帳號層級冷卻 |
| tenant / organization | 多租戶防濫用 |
對外仍可以回通用成功訊息,但內部不要真的寄出。
四、 Reset Token 設計
1. Token 要高熵
Reset token 不應該是:
123456
userId + timestamp
base64(email)
JWT with weak secret應該使用安全亂數產生:
function createResetToken() {
return crypto.randomBytes(32).toString('base64url')
}32 bytes 隨機值已經比 6 位數 OTP 強很多,適合放在 reset link。
2. 資料庫只存 token hash
寄出的 link 帶 raw token:
https://example.com/reset-password?token=raw-token資料庫存:
type PasswordResetToken = {
id: string
userId: string
tokenHash: string
expiresAt: string
consumedAt?: string
createdAt: string
requestIp?: string
}建立:
const rawToken = createResetToken()
const tokenHash = await hashToken(rawToken)
await passwordResetRepository.create({
userId: user.id,
tokenHash,
expiresAt: addMinutes(new Date(), 30),
})驗證時:
const tokenHash = await hashToken(input.token)
const resetToken = await passwordResetRepository.findByHash(tokenHash)如果 reset token table 外洩,攻擊者不能直接拿資料庫裡的 hash 當 reset link 使用。
3. Token 要短效、單次使用
Reset token 應該:
- 通常幾分鐘到幾十分鐘有效。
- 使用成功後立即 consumed。
- 過期後不能使用。
- 新 token 產生時可讓舊 token 失效。
- 綁定
password_reset用途,不能拿來驗證 email 或登入。
OWASP 也建議 password reset URL token 要 single use,並在適當時間後失效。
4. Token 要避免洩漏到 Referrer
使用者點 reset link 後,如果頁面載入第三方資源,URL 裡的 token 可能透過 referrer 洩漏。
建議:
- 重設頁加
Referrer-Policy: no-referrer或合適策略。 - 避免在 reset 頁載入不必要第三方 script。
- 前端拿到 token 後不要送 analytics。
- 不要把 token 打進 log。
- 成功載入後可把 token 換成 server-side reset session 或清掉 URL。
OWASP Forgot Password Cheat Sheet 也提醒 reset password page 要避免 referrer leakage。
五、 重設新密碼
1. 新密碼要走同一套密碼政策
重設密碼不是例外。
要檢查:
- 長度。
- 是否常見密碼。
- 是否已外洩。
- 是否和 email / username 太相似。
- 是否和舊密碼相同,視產品政策。
- 是否符合高風險系統要求。
延伸讀:密碼如何安全儲存。
2. 更新 password hash
async function resetPassword(input: ResetPasswordInput) {
const tokenHash = await hashToken(input.token)
const resetToken = await passwordResetRepository.findByHash(tokenHash)
if (!resetToken || resetToken.consumedAt || resetToken.expiresAt < new Date()) {
throw new InvalidResetTokenError()
}
await passwordPolicy.validate(input.newPassword)
const passwordHash = await passwordHasher.hash(input.newPassword)
await userRepository.updatePassword({
userId: resetToken.userId,
passwordHash,
passwordChangedAt: new Date(),
})
await passwordResetRepository.consume(resetToken.id)
}注意:
- token invalid 和 expired 對外可用相同訊息。
- 更新密碼和 consume token 最好在 transaction 內。
- 不要把新密碼寫進 log。
3. 成功後要不要自動登入?
這是產品與安全取捨。
| 做法 | 優點 | 缺點 |
|---|---|---|
| 重設後自動登入 | 體驗好 | 若 token 被盜,攻擊者立即拿到 session |
| 重設後要求重新登入 | 安全語意清楚 | 多一步操作 |
高風險系統通常更傾向:
密碼重設成功
-> 撤銷舊 session
-> 要求使用新密碼重新登入如果要自動登入,也應該:
- 建立全新 session。
- 撤銷舊 session。
- 寫 audit log。
- 通知使用者。
- 對高風險操作仍要求 step-up。
六、 Session 與 Token 撤銷
1. 密碼重設後要撤銷舊登入狀態
忘記密碼可能代表:
使用者遺失密碼
或
帳號可能已被他人掌握所以重設成功後通常要:
- 撤銷其他 session。
- 撤銷 refresh token。
- 讓舊 access token 盡快失效。
- 清除 remember me token。
- 記錄 passwordChangedAt。
如果不撤銷舊 session,攻擊者即使舊密碼不能用了,仍可能保持登入。
2. Access token 怎麼失效?
如果 access token 是短效 JWT,已發出的 token 可能直到過期前都可用。
常見補救:
- access token 設短效。
- token 裡帶
sessionId,後端檢查 session 是否仍有效。 - token 裡帶
iat,後端比對passwordChangedAt。 - 使用 token denylist,但要考慮成本。
- 高風險 API 強制查 session 狀態。
Session-based 系統撤銷通常更直接,刪除 session 即可。
延伸讀:HTTP 專題:Session 與 Token 的抉擇。
3. 通知使用者
密碼重設成功後,應通知使用者:
你的密碼已於 YYYY-MM-DD HH:mm 完成重設。
如果這不是你本人操作,請立即聯絡客服或啟動帳號保護。通知要寄到既有聯絡方式,尤其是:
- 原 email。
- 新 email,如果 email 也被修改。
- 已綁定安全通知管道。
不要在通知信中附上新密碼。
七、 常見錯誤
1. Token 可重複使用
如果 reset token 使用後仍有效,攻擊者可能在使用者重設後再次重設。
修正:
成功後立即 consumed
同一 token 第二次使用失敗2. Token 永不過期
使用者可能多年後 email 被入侵,攻擊者搜尋舊信找到 reset link。
修正:
短效過期
新 token 產生時讓舊 token 失效3. 資料庫存明文 token
如果 token table 外洩,攻擊者可以直接使用 reset token。
修正:
只存 token hash
raw token 只出現在 email link4. Reset link 出現在 analytics
常見來源:
- 前端送 page view 時帶完整 URL。
- server access log 記完整 query string。
- 第三方 script 收到 referrer。
- error tracking 收集完整 location.href。
修正:
- 重設頁不要送完整 URL。
- log scrub query token。
- 設 Referrer-Policy。
- 盡快把 token 從 URL 移除或交換成短效 reset session。
5. 用安全問題恢復帳號
例如:
你的第一隻寵物叫什麼?
你的國小名稱?
你的母親生日?這類答案常常可以從社群資料猜到,或使用者在多站重複使用。NIST 近年的指南已不鼓勵依賴這類知識型問題做帳號恢復;OWASP 也提醒安全問題若使用,必須非常謹慎。
更好的方向:
- 已驗證 email / phone。
- Recovery codes。
- MFA 備援。
- 客服人工審核。
- 高信任身份驗證。
八、 追問題庫
Q1:忘記密碼為什麼不能提示 email 不存在?
因為會造成帳號枚舉。攻擊者可以批量測試哪些 email 有帳號。應該對存在與不存在的帳號回通用訊息,例如「如果這個信箱已註冊,我們會寄出重設指示」。
Q2:Reset token 應該存明文嗎?
不應該。寄給使用者的是 raw token,資料庫只存 token hash。驗證時把使用者提供的 token hash 後查詢或比對,降低 token table 外洩時的風險。
Q3:Reset token 有效多久?
沒有固定答案。通常是幾分鐘到幾十分鐘,依產品風險調整。高風險系統應更短,且要單次使用、限制重寄、限制嘗試次數。
Q4:重設密碼後要不要自動登入?
安全取向通常建議重設成功後要求重新登入。若為了體驗自動登入,也要建立全新 session、撤銷舊 session、寫 audit log 並通知使用者。
Q5:重設密碼後要不要讓其他裝置登出?
通常應該撤銷其他 session 和 refresh token。忘記密碼可能代表帳號已被他人掌握,保留舊 session 會讓攻擊者繼續使用帳號。
Q6:可以用安全問題重設密碼嗎?
不建議作為主要方式。安全問題答案常可被猜到或從公開資料取得,也可能在多站重複。更好的方式是 recovery code、MFA 備援、已驗證聯絡方式或人工審核。
九、 實作題
題目:設計 /password-reset-requests
需求:
- 不暴露 email 是否存在。
- 對 IP 和 email 做 rate limit。
- 若 user 存在,寄出 reset link。
- 對外永遠回通用訊息。
async function requestPasswordReset(input: PasswordResetRequest, request: Request) {
const identifier = normalizeIdentifier(input.email)
await resetRateLimiter.check({
ip: request.ip,
identifier,
})
const user = await userRepository.findByEmail(identifier)
if (user && user.status === 'active') {
const rawToken = createResetToken()
const tokenHash = await hashToken(rawToken)
await passwordResetRepository.create({
userId: user.id,
tokenHash,
expiresAt: addMinutes(new Date(), 30),
requestIp: request.ip,
})
await mailer.sendPasswordReset({
to: user.email,
link: `https://example.com/reset-password?token=${rawToken}`,
})
}
await auditLog.write({
type: 'password_reset_requested',
identifierHash: hashIdentifier(identifier),
targetExists: Boolean(user),
ip: request.ip,
})
return {
message: '如果這個信箱已註冊,我們會寄出重設密碼指示。',
}
}題目:設計 /password-resets
需求:
- 驗證 token。
- 設定新密碼。
- consume token。
- 撤銷 session。
- 通知使用者。
async function resetPassword(input: ResetPasswordInput, request: Request) {
const tokenHash = await hashToken(input.token)
const resetToken = await passwordResetRepository.findValidByHash(tokenHash)
if (!resetToken) {
throw new InvalidResetTokenError()
}
await passwordPolicy.validate(input.newPassword)
const passwordHash = await passwordHasher.hash(input.newPassword)
await transaction(async trx => {
await userRepository.updatePassword(
{
userId: resetToken.userId,
passwordHash,
passwordChangedAt: new Date(),
},
trx,
)
await passwordResetRepository.consume(resetToken.id, trx)
await sessionRepository.revokeAllForUser(resetToken.userId, trx)
await refreshTokenRepository.revokeAllForUser(resetToken.userId, trx)
})
await auditLog.write({
type: 'password_reset_success',
userId: resetToken.userId,
ip: request.ip,
})
await mailer.sendPasswordChangedNotification(resetToken.userId)
return {
status: 'success',
}
}這題的重點是:reset token、password hash、session revoke、audit log 要在同一個安全流程裡考慮。
十、 資深視角
1. 帳號恢復通常比登入更危險
攻擊者如果不知道密碼,可能會攻擊:
- 忘記密碼。
- 修改 email。
- 客服恢復流程。
- SMS OTP。
- 安全問題。
- MFA reset。
所以帳號恢復流程的安全等級不能比登入低太多。
2. 恢復流程要有風險分級
低風險產品可以用 email reset。
高風險產品可能需要:
- MFA recovery code。
- 已信任裝置確認。
- 客服人工審核。
- 文件或實名驗證。
- 延遲生效。
- 高風險操作暫時凍結。
例如金融或醫療服務,使用者重設密碼後,可能仍要限制短時間內的敏感操作。
3. 所有恢復操作都要可稽核
至少要追:
- 誰請求重設。
- 來源 IP / userAgent。
- token 是否成功使用。
- 密碼何時改變。
- 哪些 session 被撤銷。
- 是否發送通知。
- 是否有連續失敗或濫用。
當使用者回報帳號被盜時,這些紀錄會非常重要。
總結
| 問題 | 回答重點 |
|---|---|
| 對外訊息 | 通用回應,避免帳號枚舉 |
| Reset token | 高熵、短效、單次使用、存 hash |
| Token 洩漏 | 避免 referrer、log、analytics 收集完整 URL |
| 新密碼 | 套用密碼政策與安全 password hash |
| 成功後 | consume token、撤銷 session / refresh token |
| 通知 | 通知使用者密碼已變更 |
| 稽核 | request、success、failed、rate limited 都要記 |
| 高風險 | 可能需要 MFA、recovery code、人工審核 |
一句完整的面試回答可以是:
忘記密碼流程我會把它當帳號恢復流程設計。使用者輸入 email 後,後端不暴露帳號是否存在,永遠回通用訊息,並對 IP、identifier 做 rate limit。若帳號存在,產生高熵 reset token,資料庫只存 token hash,token 要短效、單次使用並綁定 password_reset 用途。Reset link 頁面要避免 token 進入 referrer、log 或 analytics。使用者提交新密碼時,後端驗證 token、檢查新密碼政策、用 Argon2id / bcrypt 等 password hash 更新密碼,成功後 consume token、撤銷舊 session 和 refresh token、寫 audit log 並通知使用者。高風險產品還要考慮 MFA recovery、人工審核和重設後暫時限制敏感操作。