security(auth): P0 認證安全強化

- Token 過期時間設為 7 天(sanctum.php expiration)
- prune-expired 每日排程清除過期 token 記錄
- Google OAuth redirect 改用 URL fragment(#token=)避免 token 出現在 server log
- SocialAuthController env() 改 config() 修正 config:cache 下 redirect 失效問題
- 登入端點加 rate limiting:member/provider throttle:5,1,admin throttle:3,1
- axios/coachAxios 加 401 response interceptor,token 過期自動登出
- 修正 migration SQLite 相容性(MODIFY COLUMN),Feature tests 從 7 通過恢復至 41
- 新增 AuthRateLimitTest(5 tests)驗證各角色頻率限制行為

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 01:54:40 +08:00
parent 8d1ea49abc
commit cb9c9a9385
22 changed files with 502 additions and 10 deletions
@@ -0,0 +1,97 @@
## Context
CFDivePlatform 使用 Sanctum Bearer Token 認證,token 儲存於前端 localStorage。安全評估發現三個在當前架構下可直接修復的高危漏洞,不需要切換認證機制。此 change 針對性修補,範圍刻意控制在最小改動。
## Goals / Non-Goals
**Goals:**
- Token 設定有限期,降低洩漏後的持續暴露時間
- 消除 Google OAuth redirect URL 中的 token 洩漏路徑
- 對登入端點加入基本的暴力破解防護
**Non-Goals:**
- 不切換至 HttpOnly Cookie(需要 HTTPS、CSRF token、OAuth 重構,成本過高)
- 不修改既有 token 的儲存位置(localStorage → sessionStorage 留 P1
- 不實作 token refresh / silent renew 機制(留 P1
- 不清除資料庫中現存的永不過期 token(`expires_at = null` 的舊記錄接受自然淘汰;`prune-expired` 只刪 `expires_at` 已逾期的記錄,兩者不衝突)
## Decisions
### 1. Token 過期時間設為 7 天
**選擇**`sanctum.expiration = 10080`(分鐘)
**原因**:7 天符合「使用者每週至少登入一次」的合理假設,過短(如 1 天)會顯著提高重新登入頻率影響 UX,過長則縮減不了多少攻擊視窗。
**替代方案考慮**
- 1 天:洩漏視窗最小,但 UX 衝擊大
- 30 天:常見電商慣例,但本平台含金流元素不適用
- 無限:現狀,排除
**副作用**`sanctum.expiration` 只影響新建立的 token,既有 `personal_access_tokens``expires_at = null` 的記錄不受影響。可接受——舊 token 在使用者下次登入後自然被新 token 取代。
**清理機制**:啟用 expiration 後過期 token 不會自動從資料庫刪除,需搭配 `sanctum:prune-expired --hours=168` 排程(`routes/console.php`)每日清理,避免 `personal_access_tokens` 表持續膨脹。
---
### 2. Google OAuth redirect 改用 URL Fragment
**選擇**`redirect(config('app.frontend_url') . '/auth/callback#token=' . $token)`
**原因**
- URL fragment`#` 後的部分)依 HTTP 規範不會被瀏覽器送到 server,因此**不會出現在 Nginx/server log**
- Fragment 不會包含在 `Referer` header 中
- Fragment 不會出現在瀏覽器歷史的完整記錄中(部分瀏覽器歷史仍記錄 fragment,但不在伺服器端)
- 前端改動僅一行:`route.query.token``new URLSearchParams(window.location.hash.substring(1)).get('token')`
**替代方案考慮**
- **後端 POST 回 token**OAuth provider 只能 redirect,無法直接 POST
- **server-side session 中繼**:後端 redirect 到 `/auth/callback?code=<random>` 再由前端換 token——更安全但需要 server-side session store,架構複雜度提高
- **保持 query string**:現狀,洩漏路徑未消除,排除
**選擇 fragment 而非 server-side session 的原因**fragment 方案完全消除 server log 洩漏問題,且改動最小,對此平台規模足夠。
---
### 3. Rate Limiting 使用 Laravel 內建 throttle middleware
**選擇**
- Member / Provider 登入:throttle:5,1
- Admin 登入:throttle:3,1(帳號權限較高,適當收緊)
**原因**
- Laravel 11 throttle middleware 基於 IP,不需要額外套件
- 觸發時自動回傳 HTTP 429 並帶 `Retry-After` header
- 5次/分鐘對正常使用者幾乎無感,但讓暴力破解的時間成本從秒級提高到分鐘級
**替代方案考慮**
- `throttle:10,1`:太寬鬆,對所有角色排除
- `throttle:3,1` for member/provider:可能誤傷快速填錯密碼的正常使用者,故 member/provider 維持 5 次;admin 因帳號影響範圍大,3 次的誤傷成本可接受
- Progressive delay(每次失敗增加等待時間):需要額外實作,P0 不引入複雜度
**僅套用登入端點**:register 端點不加(新帳號建立的攻擊面不同,留 P1)
## Risks / Trade-offs
- **既有 token 不過期**`expiration` 改動不回溯,需接受現有洩漏的舊 token 持續有效直到使用者重新登入。風險可接受,因為:(1) 此為首次設定,無歷史洩漏事件;(2) 使用者正常使用就會刷新。
- **`personal_access_tokens` 表膨脹**:啟用 expiration 後過期記錄不自動清除,需排程 `sanctum:prune-expired` → 已列入 tasks。
- **`env()` 在 config cache 下失效**`SocialAuthController` 原本使用 `env('FRONTEND_URL')`,執行 `php artisan config:cache` 後(production 必做)`env()` 回傳 null,導致 redirect 到錯誤 URL。此 change 一併修正為 `config('app.frontend_url')``config/app.php` 已有此鍵)。
- **fragment 在部分瀏覽器歷史仍可見**Chrome DevTools Application → Session History 仍記錄完整 URL 含 fragment,但這是本地攻擊面(攻擊者需存取使用者設備),遠低於 server log 洩漏的遠端攻擊面。
- **IP-based throttle 可被繞過**:攻擊者可換 IP 或使用代理。此方案擋的是基本掃描攻擊,不是針對性攻擊。P1 可加 account-level lockout。
## Migration Plan
1. 套用 `config/sanctum.php` 修改,重啟 PHP-FPM(或 Docker container
2. 在 `routes/console.php` 新增 `Schedule::command('sanctum:prune-expired --hours=168')->daily();`(無需重啟,下次排程週期生效)
3. 套用 `routes/api.php` 修改,無需重啟(路由快取需清除:`php artisan route:clear`
4. 套用後端 `SocialAuthController.php` 修改
5. 套用前端 `AuthCallbackView.vue` 修改,重新 build frontend`npm run build`
6. 驗證:手動測試 Google OAuth 登入流程、登入頻率限制觸發、手動執行 `php artisan sanctum:prune-expired --hours=168` 確認排程指令可正常執行
**Rollback**:各項變更相互獨立,可逐項還原。Token 過期設定還原後,新建 token 重新改為不過期(舊的已過期的 token 無法恢復)。
## Open Questions
- **Coach 登入端點 `/api/provider/login` 是否也需要 rate limiting**(已包含在此 change 中,與 member 同風險級別,統一採用 `throttle:5,1`
- **Admin 登入是否需要更嚴格的限制?****決策:改為 `throttle:3,1`,在此 change 實作。** 管理員帳號一旦被暴力破解,影響範圍遠大於一般使用者(可存取所有用戶資料、審核功能),更嚴格的限制可接受,正常管理員操作不太可能在 1 分鐘內連續嘗試 3 次以上。
- **是否需要對 Google OAuth callback 加入 state parameter 驗證(CSRF for OAuth)?****確認留 P1。** 風險評估:`stateless()` 模式下缺少 state 驗證,理論上存在 CSRF-for-OAuth 攻擊面(攻擊者偽造 callback 讓受害者綁定攻擊者的 Google 帳號)。緩解因素:(1) Google OAuth 本身要求使用者主動授權,不能靜默觸發;(2) 攻擊者需控制受害者瀏覽器的 callback 請求,難度高;(3) 平台目前無高價值資產(金流未上線)。P1 實作時加入 `state` parameter 並在 session 中驗證。