Files
CFDivePlatform/openspec/specs/account-lockout/spec.md
T
a620906209 2a0e9255ae security(auth): P2 帳號鎖定 + OAuth state 驗證
- 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>
2026-06-03 01:20:25 +08:00

105 lines
6.4 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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 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.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** 同上,顯示鎖定提示訊息