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>
This commit is contained in:
2026-06-03 01:20:25 +08:00
parent b2e20767d8
commit 2a0e9255ae
14 changed files with 680 additions and 47 deletions
@@ -0,0 +1,104 @@
## 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** 同上,顯示鎖定提示訊息
@@ -0,0 +1,35 @@
## ADDED Requirements
### Requirement: OAuth 授權流程帶 state 參數防 CSRF
後端 SHALL 在 OAuth redirect 前產生隨機 state 字串(至少 32 bytes hex),存入 `session('oauth_state')`,並附加於 OAuth redirect URL 的 `state` query parameter。`stateless()` 呼叫 SHALL 被移除;Socialite 在 `redirect()` 內會自行寫入 `session('state')`,實作 SHALL 在 `redirect()` 呼叫後以同一 state 值覆蓋 `session('state')`,確保 Socialite 內建驗證與手動驗證使用相同值。callback 時 SHALL 從 request 讀取 `state` 並與 `session('oauth_state')` 比對(`hash_equals`);比對成功後 SHALL 立即清除;不符或缺少時 SHALL redirect 至 `/login?error=oauth_failed`
#### Scenario: 正常 OAuth 登入流程
- **WHEN** 使用者點擊「Google 登入」,後端發出 redirect
- **THEN** redirect URL 包含 `state=<random>` 參數,且相同 random 值存入 session;使用者在 Google 完成授權後,callback 帶回相同 state,後端驗證通過,繼續正常登入流程
#### Scenario: state 缺失(直接訪問 callback
- **WHEN** 攻擊者或使用者直接訪問 `/auth/google/callback` 而不帶 `state` 參數
- **THEN** 後端 redirect 瀏覽器至 `{frontend_url}/login?error=oauth_failed`,不繼續登入流程
#### Scenario: state 不符(CSRF 攻擊)
- **WHEN** callback 帶的 `state``session('oauth_state')` 中的值不一致
- **THEN** 後端 redirect 瀏覽器至 `{frontend_url}/login?error=oauth_failed`,不繼續登入流程
#### Scenario: state 只能使用一次
- **WHEN** callback 驗證 state 成功後(`session()->pull('oauth_state')` 已清除),再次以相同 state 訪問 callback
- **THEN** session 中已無 `oauth_state`,視為 state 缺失,同樣 redirect 至 `/login?error=oauth_failed`
#### Scenario: 登入頁顯示 OAuth 失敗訊息
- **WHEN** 瀏覽器被 redirect 至 `/login?error=oauth_failed`
- **THEN** `LoginView.vue` 偵測到 `route.query.error === 'oauth_failed'`,顯示「OAuth 授權失敗,請重新嘗試」提示;`AuthCallbackView.vue` 不涉及此錯誤流程
#### Scenario: 多分頁並發 OAuth flow — 只有最後啟動的成功(預期行為)
- **WHEN** 使用者在同一瀏覽器開兩個 tab,Tab A 先啟動 OAuthsession 寫入 state_A),Tab B 後啟動 OAuthsession 覆寫為 state_B),Tab A 的 callback 先到達後端
- **THEN** Tab A 的 callback 帶 state_A,與 session 中的 state_B 不符,後端 redirect 至 `/login?error=oauth_failed`Tab B 的 callback 帶 state_B,比對成功,正常完成登入
### Requirement: OAuth session driver 正常運作
後端 SHALL 確保 session driver 在 Docker 環境中可寫入(`storage/framework/sessions` 目錄存在且有寫入權限),以支援 state 存取。
#### Scenario: Session 寫入成功
- **WHEN** OAuth redirect 觸發時
- **THEN** state 值成功寫入 session,無 500 錯誤