跳至主要內容
Skip to content

忘記密碼流程設計

忘記密碼不是「寄一封信讓使用者改密碼」這麼簡單。它本質上是一條帳號恢復流程:如果設計不好,攻擊者不需要知道舊密碼,只要拿到 reset token,就可能直接接管帳號。

一句話回答:忘記密碼流程要使用通用回應避免帳號枚舉,對請求端做 rate limit,產生高熵、短效、單次使用的 reset token,資料庫只存 token hash,重設頁避免 token 泄漏到 referrer 或 log;新密碼要套用密碼政策與安全 hash,成功後撤銷舊 session / refresh token、使 reset token 失效、寫 audit log 並通知使用者。


一、 必考觀念

1. 忘記密碼是帳號恢復流程

登入流程是:

text
使用者知道密碼
-> 驗證密碼
-> 建立登入狀態

忘記密碼流程是:

text
使用者無法完成原本的身份驗證
-> 系統透過已綁定聯絡方式或恢復因素確認控制權
-> 允許使用者建立新密碼

這代表忘記密碼流程繞過了原本的密碼驗證,所以安全等級必須非常謹慎。

2. Reset token 幾乎等於暫時登入能力

收到 reset link 的人通常可以:

text
打開重設頁
-> 設定新密碼
-> 取得帳號控制權

所以 reset token 要當成敏感憑證處理:

  • 高熵、不可猜。
  • 短效。
  • 單次使用。
  • 綁定用途。
  • 綁定帳號。
  • 存 hash,不存明文。
  • 成功後立即失效。

它的風險比一般 email verification token 更高。

3. 忘記密碼不是修改密碼

兩者差異:

流程使用者狀態要求
修改密碼已登入驗證舊密碼或 step-up authentication
忘記密碼未登入或無法登入驗證恢復管道,使用 reset token

修改密碼通常要求目前密碼或 MFA;忘記密碼則依賴 email、phone、recovery code、客服 KYC 或其他恢復機制。

延伸讀:信箱驗證與手機驗證


二、 流程總覽

1. Sequence Diagram

2. 兩支 API

通常會拆成:

text
POST /password-reset-requests
POST /password-resets

第一支負責「請求重設」:

json
{
  "email": "user@example.com"
}

第二支負責「使用 token 設定新密碼」:

json
{
  "token": "raw-reset-token",
  "newPassword": "new-user-password"
}

不要把請求重設和真正改密碼混在一支 API 裡。


三、 請求重設密碼

1. 對外回應要一致

不好的回應:

text
這個 email 不存在

這會造成帳號枚舉。

更好的回應:

text
如果這個信箱已註冊,我們會寄出重設密碼指示。

OWASP Forgot Password Cheat Sheet 建議忘記密碼流程對存在與不存在的帳號回覆一致訊息,並維持一致的回應時間,避免攻擊者推測帳號是否存在。

2. 內部仍要記錄真實狀態

對外通用,不代表內部不能記錄。

Audit log 可以記:

text
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 不應該是:

text
123456
userId + timestamp
base64(email)
JWT with weak secret

應該使用安全亂數產生:

typescript
function createResetToken() {
  return crypto.randomBytes(32).toString('base64url')
}

32 bytes 隨機值已經比 6 位數 OTP 強很多,適合放在 reset link。

2. 資料庫只存 token hash

寄出的 link 帶 raw token:

text
https://example.com/reset-password?token=raw-token

資料庫存:

typescript
type PasswordResetToken = {
  id: string
  userId: string
  tokenHash: string
  expiresAt: string
  consumedAt?: string
  createdAt: string
  requestIp?: string
}

建立:

typescript
const rawToken = createResetToken()
const tokenHash = await hashToken(rawToken)

await passwordResetRepository.create({
  userId: user.id,
  tokenHash,
  expiresAt: addMinutes(new Date(), 30),
})

驗證時:

typescript
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

typescript
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
重設後要求重新登入安全語意清楚多一步操作

高風險系統通常更傾向:

text
密碼重設成功
-> 撤銷舊 session
-> 要求使用新密碼重新登入

如果要自動登入,也應該:

  • 建立全新 session。
  • 撤銷舊 session。
  • 寫 audit log。
  • 通知使用者。
  • 對高風險操作仍要求 step-up。

六、 Session 與 Token 撤銷

1. 密碼重設後要撤銷舊登入狀態

忘記密碼可能代表:

text
使用者遺失密碼

帳號可能已被他人掌握

所以重設成功後通常要:

  • 撤銷其他 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. 通知使用者

密碼重設成功後,應通知使用者:

text
你的密碼已於 YYYY-MM-DD HH:mm 完成重設。
如果這不是你本人操作,請立即聯絡客服或啟動帳號保護。

通知要寄到既有聯絡方式,尤其是:

  • 原 email。
  • 新 email,如果 email 也被修改。
  • 已綁定安全通知管道。

不要在通知信中附上新密碼。


七、 常見錯誤

1. Token 可重複使用

如果 reset token 使用後仍有效,攻擊者可能在使用者重設後再次重設。

修正:

text
成功後立即 consumed
同一 token 第二次使用失敗

2. Token 永不過期

使用者可能多年後 email 被入侵,攻擊者搜尋舊信找到 reset link。

修正:

text
短效過期
新 token 產生時讓舊 token 失效

3. 資料庫存明文 token

如果 token table 外洩,攻擊者可以直接使用 reset token。

修正:

text
只存 token hash
raw token 只出現在 email link

常見來源:

  • 前端送 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. 用安全問題恢復帳號

例如:

text
你的第一隻寵物叫什麼?
你的國小名稱?
你的母親生日?

這類答案常常可以從社群資料猜到,或使用者在多站重複使用。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。
  • 對外永遠回通用訊息。
typescript
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。
  • 通知使用者。
typescript
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、人工審核和重設後暫時限制敏感操作。

延伸閱讀