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,36 @@
## 1. 帳號鎖定設定檔與共用 Helper
- [x] 1.1 [後端] 建立 `config/auth_lockout.php`,定義 `max_attempts`env `LOCKOUT_MAX_ATTEMPTS`, 預設 5)與 `decay_minutes`env `LOCKOUT_DECAY_MINUTES`, 預設 15
- [x] 1.2 [後端] 建立 `app/Traits/NormalizesEmail.php` Trait,定義 `normalizeEmail(string $email): string``strtolower(trim($email))`),在 `AuthController``ProviderAuthController` 均 use 此 Trait
## 2. Member 帳號鎖定邏輯
- [x] 2.1 [後端] 修改 `app/Http/Controllers/API/AuthController.php``loginMember()`:對 request email 執行 `strtolower(trim())` 正規化後,先查 Cache key `login_failures:member:{email}`,若已達閾值讀取 `login_expires_at:member:{email}` 回傳 HTTP 423(含 `locked_until`
- [x] 2.2 [後端] DB 查無帳號時直接回傳 HTTP 401(訊息與密碼錯誤相同),**不**遞增計數
- [x] 2.3 [後端] 登入失敗(帳號存在但密碼錯)時:若 `login_failures` key 不存在則用 `Cache::put()` 設初始值 1 並同步寫入 `login_expires_at` companion keyTTL 相同);若 key 已存在則只 `Cache::increment()`(不重設 TTL);回傳 423 時讀取 `login_expires_at` key,若缺失則 fallback 為 `now()->addMinutes($decay_minutes)` 並寫 `Log::warning`
- [x] 2.4 [後端] 登入成功時 `Cache::forget('login_failures:member:{email}')``Cache::forget('login_expires_at:member:{email}')`
## 3. Provider 帳號鎖定邏輯
- [x] 3.1 [後端] 修改 `app/Http/Controllers/API/ProviderAuthController.php``loginProvider()`:同 Member 邏輯(email 正規化、不存在帳號不遞增、companion key、fixed window),Cache key 前綴改為 `login_failures:provider:` / `login_expires_at:provider:`
- [x] 3.2 [後端] 登入成功時清除兩個 Cache keyfailures + expires_at
## 4. OAuth State 驗證
- [x] 4.1 [後端] 修改 `app/Http/Controllers/API/SocialAuthController.php``redirectToProvider()`:移除 `stateless()`,產生 `bin2hex(random_bytes(32))``session('oauth_state')`,附加 `state` 於 redirect URL
- [x] 4.2 [後端] 修改 `handleProviderCallback()`:移除 `stateless()`,讀取 `request->state``session('oauth_state')` 比對;不符或缺失時回傳 HTTP 400;比對後立即 `session()->forget('oauth_state')`
## 5. 前端錯誤處理
- [x] 5.1 [前端] 修改 `frontend/src/views/LoginView.vue`axios catch 中判斷 `error.response?.status === 423`,顯示 `response.data.message`(帳號鎖定提示),而非泛用錯誤
- [x] 5.2 [前端] 修改 `frontend/src/views/CoachLoginView.vue`:同上,處理 423 狀態碼
- [x] 5.3 [前端] 修改 `frontend/src/views/AuthCallbackView.vue`:當後端 OAuth callback 回傳 400 時,redirect 至 `/login?error=oauth_failed`
- [x] 5.4 [前端] 修改 `frontend/src/views/LoginView.vue`:若 `route.query.error === 'oauth_failed'`,顯示「OAuth 授權失敗,請重新嘗試」提示
## 6. 整合驗證
- [x] 6.1 [整合測試] 手動測試 Member 登入:連續失敗 5 次後收到 423,等待 15 分鐘後或改正確密碼(需清除 Cache)後可登入
- [x] 6.2 [整合測試] 手動測試 Provider 登入:同上
- [x] 6.3 [整合測試] 手動測試 OAuth:正常流程可完成;直接訪問 callback URL(無 state)收到 400 並 redirect 至 `/login?error=oauth_failed`
- [x] 6.4 [整合測試] 確認 `storage/framework/sessions` 目錄在 Docker 容器中有寫入權限(session 存 state 不報錯)
- [x] 6.5 [部署] 確認生產環境 `.env` 設定 `CACHE_DRIVER=redis`;驗證方式:`php artisan tinker``Cache::put('test', 1, 10); Cache::get('test');` 回傳 1 即 Redis 正常