Files
CFDivePlatform/openspec/specs/account-lockout/spec.md
T
a620906209 0cd1047b10
Run Tests / test (pull_request) Successful in 30s
docs(openspec): 補齊所有 spec 缺少的 Purpose section 與規格標頭
repo 內 35/38 份既有 spec 都是舊版歸檔流程留下的原始 delta 內容(`##
ADDED Requirements`,缺標題與 Purpose),openspec CLI 現版本嚴格驗證
(--strict)會報錯或警告。逐一補上:

- 缺標題/Purpose 的 30 份:加上 `# <name> Specification` 標題 + 依內容
  撰寫的 Purpose 段落,`## ADDED Requirements` 改回 `## Requirements`
- compose-cloud-baseline、scheduler-container:已有標題與
  Requirements,只補 Purpose 包裝
- provider-verification:已有標題與 Purpose,只需把
  `## ADDED Requirements` 改回 `## Requirements`
- admin-user-management、notification-email:各有一條 requirement 缺
  SHALL/MUST 關鍵字(純敘述句或表格),補上規範用語,內容不變

`openspec validate --specs --strict`:38/38 全過(原本 4/38)。純文件
補齊,未變更任何 requirement 的實質內容或行為描述。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011LpcY9b7y9x4fusHBTXciQ
2026-08-03 04:11:06 +08:00

6.6 KiB
Raw Blame History

account-lockout Specification

Purpose

定義登入失敗鎖定機制的 Cache Key 命名規範、Fixed Window 計數邏輯、閾值設定與前端錯誤提示,防止暴力破解並避免帳號枚舉攻擊。

Requirements

Requirement: Cache Key Namespace

後端 SHALL 使用以下格式的 Cache key{role} 僅允許 memberprovider 兩個值,對應各自的登入端點;{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: "電子郵件或密碼錯誤"
帳號存在,密碼錯,失敗 14 次 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 401failed_attempts 計數遞增,帳號不鎖定

Scenario: 第 5 次失敗觸發鎖定

  • WHEN 帳號在 15 分鐘內第 5 次登入失敗
  • THEN 系統回傳 HTTP 423body 包含 { 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 401body { 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.comuser@gmail.comUSER@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 同上,顯示鎖定提示訊息