- Account lockout:連續失敗 5 次鎖定 15 分鐘(Fixed Window,Cache)
- Member / Provider 各自獨立 namespace
- Email 正規化(strtolower+trim)
- 不存在帳號不累計計數(防 Cache 污染)
- companion key 記錄 locked_until(ISO 8601)
- OAuth CSRF 防護:移除 stateless(),手動 state 生成與驗證
- session('oauth_state') + session('state') 雙寫確保 Socialite 內建驗證通過
- state mismatch redirect 至 /login?error=oauth_failed
- throttle 從 5,1 調整為 10,1,避免 IP throttle 在 lockout 前觸發
- NormalizesEmail Trait、config/auth_lockout.php
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6.4 KiB
ADDED Requirements
Requirement: Cache Key Namespace
後端 SHALL 使用以下格式的 Cache key,{role} 僅允許 member 或 provider 兩個值,對應各自的登入端點;{email} 為 strtolower(trim($request->email)) 正規化後的結果。
| Key 用途 | 格式 |
|---|---|
| 失敗計數 | login_failures:{role}:{email} |
| 鎖定到期時間 | login_expires_at:{role}:{email} |
Scenario: role 值與端點對應
- WHEN Member 登入失敗
- THEN 使用 key
login_failures:member:{email}與login_expires_at:member:{email}
Scenario: Provider 與 Member 計數互相隔離
- WHEN 同一個 email 在 Member 端點失敗 4 次,再到 Provider 端點失敗 4 次
- THEN 兩個 role namespace 各自獨立,均未達閾值,均不觸發鎖定
Requirement: 帳號層登入失敗鎖定
後端 SHALL 追蹤每個帳號(以正規化 email 為 key)的登入失敗次數。計數採用 Fixed Window:第一次失敗時建立 Cache key 並設 TTL = decay_minutes * 60 秒,後續失敗只遞增計數而不重設 TTL(window 不延長)。當失敗次數達到閾值(預設 5 次)時,系統 SHALL 鎖定該帳號,鎖定期間任何密碼登入 SHALL 被拒絕並回傳 HTTP 423(含 locked_until ISO 8601 欄位),直到 Cache key 自然過期。locked_until 的值來源為第一次失敗時同步寫入的 companion key login_expires_at:{role}:{email}。
登入端點完整 Response Semantics(按後端處理順序):
| 情境 | 檢查順序 | HTTP | Response Body 重點欄位 |
|---|---|---|---|
| 帳號已鎖定(Cache 計數 ≥ max_attempts) | 1st | 423 | message: "帳號已暫時鎖定...", locked_until: "<ISO8601>" |
| 帳號不存在(DB 查無 email) | 2nd | 401 | message: "電子郵件或密碼錯誤" |
| 帳號存在,密碼錯,失敗 1–4 次 | 3rd | 401 | message: "電子郵件或密碼錯誤" |
| 帳號存在,密碼錯,第 5 次(當場觸發鎖定) | 3rd | 423 | message: "帳號已暫時鎖定...", locked_until: "<ISO8601>" |
| 帳號存在,密碼正確 | 3rd | 200 | token, user |
「帳號不存在」與「密碼錯誤 1–4 次」的 HTTP status 和 message SHALL 完全相同,不得有任何可區分的差異(防帳號枚舉)。
Scenario: 未達閾值的登入失敗
- WHEN 帳號在 15 分鐘內累計失敗次數少於 5 次
- THEN 系統正常回傳 HTTP 401,
failed_attempts計數遞增,帳號不鎖定
Scenario: 第 5 次失敗觸發鎖定
- WHEN 帳號在 15 分鐘內第 5 次登入失敗
- THEN 系統回傳 HTTP 423,body 包含
{ status: false, message: "帳號已暫時鎖定,請於 15 分鐘後再試", locked_until: "<ISO8601 timestamp>" },後續任何密碼登入均被拒絕直到時間到期
Scenario: 鎖定期間嘗試登入
- WHEN 帳號處於鎖定狀態,使用者送出任何 email/password 組合
- THEN 系統不驗證密碼,直接回傳 HTTP 423 並附帶
locked_until欄位
Scenario: 登入成功後清除失敗計數
- WHEN 使用者在未達閾值前成功登入
- THEN 系統清除該帳號的失敗計數,下次失敗從 0 重新累積
Scenario: 鎖定時間到期自動解鎖
- WHEN 帳號鎖定後超過 15 分鐘未有新登入嘗試
- THEN 鎖定計數自動過期(Cache TTL),帳號恢復可登入狀態
Scenario: 鎖定機制覆蓋 Member 與 Provider
- WHEN Member(
/api/member/login)或 Provider(/api/provider/login)觸發連續失敗 - THEN 各自以 email 為 key 獨立計數,互不干擾
Scenario: Fixed window — 失敗不延長鎖定時間
- WHEN 帳號在 window 內第 3 次失敗(尚未達閾值),之後又再失敗 1 次
- THEN TTL 不重設;
locked_until仍為第一次失敗時計算的到期時間
Scenario: locked_until 來自 companion key
- WHEN 系統回傳 HTTP 423
- THEN response body 的
locked_until欄位值等於login_expires_at:{role}:{email}Cache key 的儲存值(ISO 8601),即第一次失敗時計算的now + decay_minutes
Requirement: 不存在帳號不累計失敗計數
後端 SHALL 在遞增失敗計數前先查詢 DB 確認帳號是否存在。若查無此 email,SHALL 直接回傳 HTTP 401,訊息與密碼錯誤相同("電子郵件或密碼錯誤"),不遞增任何 Cache 計數。
Scenario: 不存在帳號登入失敗
- WHEN 使用者以一個 DB 中不存在的 email 嘗試登入
- THEN 系統回傳 HTTP 401,body
{ status: false, message: "電子郵件或密碼錯誤" },Cache 中不建立也不遞增任何此 email 的失敗計數
Scenario: 錯誤訊息無法區分帳號不存在與密碼錯誤
- WHEN 帳號不存在 vs. 帳號存在但密碼錯誤,兩種情境均發生
- THEN 兩者 HTTP status 相同(401),response body 訊息相同,攻擊者無法透過回應差異做帳號枚舉
Requirement: Email 正規化
後端 SHALL 在所有涉及鎖定計數的操作前,對 email 執行 strtolower(trim($email)),確保大小寫變體與前後空白不影響計數的正確性。
Scenario: Email 大小寫變體視為同一帳號
- WHEN 攻擊者用
User@Gmail.com、user@gmail.com、USER@GMAIL.COM依序嘗試登入同一帳號 - THEN 三次嘗試累計到同一個計數 key,共累計 3 次失敗
Requirement: 鎖定閾值可由環境變數設定
後端 SHALL 從 config/auth_lockout.php 讀取鎖定參數,允許透過 .env 覆蓋 LOCKOUT_MAX_ATTEMPTS(預設 5)與 LOCKOUT_DECAY_MINUTES(預設 15)。
Scenario: 使用預設值
- WHEN
.env未設定LOCKOUT_MAX_ATTEMPTS/LOCKOUT_DECAY_MINUTES - THEN 系統使用 5 次 / 15 分鐘作為鎖定參數
Scenario: 透過 env 覆蓋閾值
- WHEN
.env設定LOCKOUT_MAX_ATTEMPTS=10 - THEN 系統以 10 次失敗後才鎖定
Requirement: 前端顯示帳號鎖定訊息
前端 SHALL 在接收到 HTTP 423 時,顯示「帳號已暫時鎖定」的明確提示,而非泛用的「帳密錯誤」訊息。
Scenario: Member 登入頁收到 423
- WHEN
LoginView.vue的登入請求收到 HTTP 423 - THEN 顯示 response body 中的
message欄位內容(如「帳號已暫時鎖定,請於 15 分鐘後再試」)
Scenario: Provider 登入頁收到 423
- WHEN
CoachLoginView.vue的登入請求收到 HTTP 423 - THEN 同上,顯示鎖定提示訊息