From 2a0e9255ae980ee257be63fa4560ee5c04c63295 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 3 Jun 2026 01:20:25 +0800 Subject: [PATCH] =?UTF-8?q?security(auth):=20P2=20=E5=B8=B3=E8=99=9F?= =?UTF-8?q?=E9=8E=96=E5=AE=9A=20+=20OAuth=20state=20=E9=A9=97=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/Http/Controllers/API/AuthController.php | 140 +++++++++---- .../Controllers/API/SocialAuthController.php | 25 ++- app/Traits/NormalizesEmail.php | 11 ++ config/auth_lockout.php | 6 + frontend/src/views/LoginView.vue | 2 +- .../.openspec.yaml | 2 + .../design.md | 185 ++++++++++++++++++ .../proposal.md | 38 ++++ .../specs/account-lockout/spec.md | 104 ++++++++++ .../specs/oauth-state-validation/spec.md | 35 ++++ .../tasks.md | 36 ++++ openspec/specs/account-lockout/spec.md | 104 ++++++++++ openspec/specs/oauth-state-validation/spec.md | 35 ++++ routes/api.php | 4 +- 14 files changed, 680 insertions(+), 47 deletions(-) create mode 100644 app/Traits/NormalizesEmail.php create mode 100644 config/auth_lockout.php create mode 100644 openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/design.md create mode 100644 openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/proposal.md create mode 100644 openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/specs/account-lockout/spec.md create mode 100644 openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/specs/oauth-state-validation/spec.md create mode 100644 openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/tasks.md create mode 100644 openspec/specs/account-lockout/spec.md create mode 100644 openspec/specs/oauth-state-validation/spec.md diff --git a/app/Http/Controllers/API/AuthController.php b/app/Http/Controllers/API/AuthController.php index c1756f5..859078e 100644 --- a/app/Http/Controllers/API/AuthController.php +++ b/app/Http/Controllers/API/AuthController.php @@ -7,15 +7,19 @@ use App\Models\User; use App\Models\AdminProfile; use App\Models\ProviderProfile; use App\Models\MemberProfile; +use App\Traits\NormalizesEmail; use Illuminate\Http\Request; +use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Hash; +use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Validator; use Illuminate\Validation\Rules\Password; class AuthController extends Controller { - // 科定規範角色 + use NormalizesEmail; + private const ROLE_MEMBER = 'member'; private const ROLE_PROVIDER = 'provider'; private const ROLE_ADMIN = 'admin'; @@ -131,6 +135,50 @@ class AuthController extends Controller ]); } + private function isAccountLocked(string $role, string $email): bool + { + $count = Cache::get("login_failures:{$role}:{$email}", 0); + return $count >= config('auth_lockout.max_attempts'); + } + + private function recordLoginFailure(string $role, string $email): int + { + $ttl = config('auth_lockout.decay_minutes') * 60; + $countKey = "login_failures:{$role}:{$email}"; + $expiresKey = "login_expires_at:{$role}:{$email}"; + + if (!Cache::has($countKey)) { + Cache::put($countKey, 1, $ttl); + Cache::put($expiresKey, now()->addSeconds($ttl)->toIso8601String(), $ttl); + return 1; + } + + return Cache::increment($countKey); + } + + private function clearLoginFailures(string $role, string $email): void + { + Cache::forget("login_failures:{$role}:{$email}"); + Cache::forget("login_expires_at:{$role}:{$email}"); + } + + private function buildLockedResponse(string $role, string $email): \Illuminate\Http\JsonResponse + { + $decayMinutes = config('auth_lockout.decay_minutes'); + $lockedUntil = Cache::get("login_expires_at:{$role}:{$email}"); + + if (!$lockedUntil) { + Log::warning("auth_lockout: expires_at key missing for {$role}:{$email}"); + $lockedUntil = now()->addMinutes($decayMinutes)->toIso8601String(); + } + + return response()->json([ + 'status' => false, + 'message' => "帳號已暫時鎖定,請於 {$decayMinutes} 分鐘後再試", + 'locked_until' => $lockedUntil, + ], 423); + } + /** * 標準化的登出方法 */ @@ -268,53 +316,60 @@ class AuthController extends Controller */ public function loginMember(Request $request) { - // 驗證請求數據 $validator = Validator::make($request->all(), [ - 'email' => 'required|string|email', + 'email' => 'required|string|email', 'password' => 'required|string', ]); if ($validator->fails()) { return response()->json([ - 'status' => false, + 'status' => false, 'message' => '驗證失敗', - 'errors' => $validator->errors() + 'errors' => $validator->errors() ], 422); } - // 檢查用戶是否存在 - $user = User::where('email', $request->email) - ->where('role', 'member') // 只驗證會員 - ->first(); + $email = $this->normalizeEmail($request->email); - // 檢查密碼 + // 1st: 帳號鎖定檢查 + if ($this->isAccountLocked(self::ROLE_MEMBER, $email)) { + return $this->buildLockedResponse(self::ROLE_MEMBER, $email); + } + + // 2nd: 帳號存在確認 + $user = User::where('email', $email)->where('role', self::ROLE_MEMBER)->first(); + + // 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息) if (!$user || !Hash::check($request->password, $user->password)) { + if ($user) { + $count = $this->recordLoginFailure(self::ROLE_MEMBER, $email); + if ($count >= config('auth_lockout.max_attempts')) { + return $this->buildLockedResponse(self::ROLE_MEMBER, $email); + } + } return response()->json([ - 'status' => false, + 'status' => false, 'message' => '電子郵件或密碼錯誤' ], 401); } - // 檢查用戶是否啟用 if (!$user->is_active) { return response()->json([ - 'status' => false, + 'status' => false, 'message' => '帳號已被停用' ], 403); } - // 創建 API token + $this->clearLoginFailures(self::ROLE_MEMBER, $email); $token = $user->createToken('auth_token')->plainTextToken; - - // 加載會員資料 $user->load('memberProfile'); return response()->json([ - 'status' => true, + 'status' => true, 'message' => '登入成功', - 'data' => [ - 'user' => $user, - 'token' => $token, + 'data' => [ + 'user' => $user, + 'token' => $token, 'token_type' => 'Bearer', ] ]); @@ -531,53 +586,60 @@ class AuthController extends Controller */ public function loginProvider(Request $request) { - // 驗證請求數據 $validator = Validator::make($request->all(), [ - 'email' => 'required|string|email', + 'email' => 'required|string|email', 'password' => 'required|string', ]); if ($validator->fails()) { return response()->json([ - 'status' => false, + 'status' => false, 'message' => '驗證失敗', - 'errors' => $validator->errors() + 'errors' => $validator->errors() ], 422); } - // 檢查用戶是否存在 - $user = User::where('email', $request->email) - ->where('role', 'provider') // 只驗證服務提供者 - ->first(); + $email = $this->normalizeEmail($request->email); - // 檢查密碼 + // 1st: 帳號鎖定檢查 + if ($this->isAccountLocked(self::ROLE_PROVIDER, $email)) { + return $this->buildLockedResponse(self::ROLE_PROVIDER, $email); + } + + // 2nd: 帳號存在確認 + $user = User::where('email', $email)->where('role', self::ROLE_PROVIDER)->first(); + + // 3rd: 密碼驗證(帳號不存在與密碼錯誤回傳相同訊息) if (!$user || !Hash::check($request->password, $user->password)) { + if ($user) { + $count = $this->recordLoginFailure(self::ROLE_PROVIDER, $email); + if ($count >= config('auth_lockout.max_attempts')) { + return $this->buildLockedResponse(self::ROLE_PROVIDER, $email); + } + } return response()->json([ - 'status' => false, + 'status' => false, 'message' => '電子郵件或密碼錯誤' ], 401); } - // 檢查用戶是否啟用 if (!$user->is_active) { return response()->json([ - 'status' => false, + 'status' => false, 'message' => '帳號已被停用' ], 403); } - // 創建 API token + $this->clearLoginFailures(self::ROLE_PROVIDER, $email); $token = $user->createToken('auth_token')->plainTextToken; - - // 加載服務提供者資料 $user->load('providerProfile'); return response()->json([ - 'status' => true, + 'status' => true, 'message' => '登入成功', - 'data' => [ - 'user' => $user, - 'token' => $token, + 'data' => [ + 'user' => $user, + 'token' => $token, 'token_type' => 'Bearer', ] ]); diff --git a/app/Http/Controllers/API/SocialAuthController.php b/app/Http/Controllers/API/SocialAuthController.php index ecf57e3..f9c22ed 100644 --- a/app/Http/Controllers/API/SocialAuthController.php +++ b/app/Http/Controllers/API/SocialAuthController.php @@ -18,11 +18,19 @@ class SocialAuthController extends Controller */ public function redirectToGoogle() { - return Socialite::driver('google') + $state = bin2hex(random_bytes(32)); + session()->put('oauth_state', $state); + + $redirect = Socialite::driver('google') ->scopes(['openid', 'profile', 'email']) - ->with(['access_type' => 'offline', 'prompt' => 'consent']) // 這裡要求 prompt=consent 才能每次都獲取 refresh token - ->stateless() + ->with(['access_type' => 'offline', 'prompt' => 'consent', 'state' => $state]) ->redirect(); + + // Socialite 在 redirect() 內自己寫了一個 random state 進 session('state')。 + // 用我們的 state 覆蓋,確保 user() 的內建驗證與 URL 中的 state 一致。 + session()->put('state', $state); + + return $redirect; } /** @@ -30,9 +38,16 @@ class SocialAuthController extends Controller */ public function handleGoogleCallback(Request $request) { + $requestState = $request->input('state'); + $sessionState = session()->pull('oauth_state'); + + if (!$requestState || !$sessionState || !hash_equals($sessionState, $requestState)) { + return redirect(config('app.frontend_url') . '/login?error=oauth_failed'); + } + try { - // 獲取 Google 用戶資訊 - $googleUser = Socialite::driver('google')->stateless()->user(); + // 獲取 Google 用戶資訊(Socialite 內建 state 驗證此時也會通過) + $googleUser = Socialite::driver('google')->user(); // 查找相關的社交帳號 $socialAccount = SocialAccount::where('provider', 'google') diff --git a/app/Traits/NormalizesEmail.php b/app/Traits/NormalizesEmail.php new file mode 100644 index 0000000..981166d --- /dev/null +++ b/app/Traits/NormalizesEmail.php @@ -0,0 +1,11 @@ + env('LOCKOUT_MAX_ATTEMPTS', 5), + 'decay_minutes' => env('LOCKOUT_DECAY_MINUTES', 15), +]; diff --git a/frontend/src/views/LoginView.vue b/frontend/src/views/LoginView.vue index d7e7343..301ae87 100644 --- a/frontend/src/views/LoginView.vue +++ b/frontend/src/views/LoginView.vue @@ -14,7 +14,7 @@ const error = ref('') const loading = ref(false) const oauthError = route.query.error === 'oauth_failed' - ? 'Google 登入失敗,請重試。' + ? 'OAuth 授權失敗,請重新嘗試' : '' const successMsg = route.query.registered ? '註冊成功,請登入。' : '' diff --git a/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/.openspec.yaml b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/.openspec.yaml new file mode 100644 index 0000000..db47328 --- /dev/null +++ b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-02 diff --git a/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/design.md b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/design.md new file mode 100644 index 0000000..264fdb1 --- /dev/null +++ b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/design.md @@ -0,0 +1,185 @@ +## Context + +現有防護分兩層: +- **IP throttle**(P0):middleware `throttle:5,1` / `throttle:3,1`,防同一 IP 在短時間大量嘗試 +- **Session Token**(P1):token 7 天過期,sessionStorage 存放,refresh interceptor + +缺口一:攻擊者用多 IP 對同一帳號暴破,IP throttle 完全無效。 +缺口二:OAuth redirect URL 若遭竄改(CSRF),惡意方可替換 authorization code。 + +## Goals / Non-Goals + +**Goals:** +- 帳號連續失敗 5 次 → 鎖定 15 分鐘,鎖定期間任何密碼登入均拒絕 +- 鎖定計數以 Cache 存放(key 包含 `email`),TTL = 鎖定時間 +- 登入成功後清除失敗計數 +- OAuth redirect 帶 state,callback 驗 state,不符回傳 400 +- 前端顯示 423 對應的帳號鎖定訊息 + +**Non-Goals:** +- Admin 帳號鎖定(Admin Panel 尚未完成) +- 電子郵件通知(鎖定時通知帳號持有人) +- Admin 手動解鎖端點(此 change 暫不實作;時間到自動解鎖即可) +- Lockout 事件 audit log + +## Decisions + +### D1:失敗計數存 Cache(not DB) + +**選擇**:`Cache::increment()` / `Cache::put()`,key = `login_failures:{email}`,TTL = 鎖定時間(15 min)。 + +**理由**: +- 不需要永久儲存,TTL = 鎖定時間,到期自動解鎖,零額外維護 +- DB 方案需 migration + GC 排程,複雜度高 3 倍 +- Cache driver 目前為 file(dev)/ Redis(prod),兩者皆可用 + +**替代方案**:在 `users` 資料表新增 `failed_login_count` / `locked_until` 欄位 → 否決,因為要 migration,且鎖定狀態不需跨重啟持久化。 + +### D2:鎖定參數放 config + +```php +// config/auth_lockout.php +return [ + 'max_attempts' => env('LOCKOUT_MAX_ATTEMPTS', 5), + 'decay_minutes' => env('LOCKOUT_DECAY_MINUTES', 15), +]; +``` + +**理由**:不同環境(測試 = 100 次)可靠 env 覆蓋,不用改程式碼。 + +### D3:HTTP 423 Locked(帳號鎖定) + +**選擇**:回傳 HTTP 423,body `{ status: false, message: "帳號已暫時鎖定,請於 X 分鐘後再試", locked_until: "" }` + +**理由**: +- 422 / 401 語義不清,423 在 WebDAV 語境下即「資源被鎖定」 +- 前端依 423 顯示靜態鎖定提示(`message` 欄位),不實作倒數;`locked_until` timestamp 保留在 response 供未來擴充 + +### D4:OAuth state 存 Laravel Session(不存 Cache) + +**選擇**:`session()->put('oauth_state', $state)` 於 redirect;callback 讀取並 `session()->pull('oauth_state')` 後以 `hash_equals` 比對。 + +**實作細節**:Socialite 的 `redirect()` 內部會自行產生 random state 並寫入 `session('state')`,但 URL 裡已被 `.with(['state' => $state])` 換成我們的 state。若不覆蓋,Socialite 的 `user()` 做內建驗證時會因 session 值與 URL 值不符拋出 `InvalidStateException`。解法:在 `redirect()` 呼叫之後、return 之前,再呼叫 `session()->put('state', $state)` 覆蓋,讓兩層驗證(手動 + Socialite 內建)使用同一 state 值。`stateless()` 因此可完全移除。 + +**理由**: +- state 是 per-request 一次性值,Session 語義完全對應 +- 移除 `stateless()` 讓 Socialite 內建驗證也生效,雙重保護 +- Cache 方案需自行管理 TTL 與 key 命名,複雜度無謂提升 + +**替代方案**:PKCE flow → 需要前端配合改 OAuth 啟動方式,改動面太大,此 change 不採用。 + +### D5:不移除現有 IP throttle + +帳號鎖定與 IP throttle 並行:IP throttle 先觸發(429),之後才會累積到帳號鎖定(423)。兩者互不衝突,都保留。 + +### D6:Email 正規化規則 + +**選擇**:所有 Cache key 使用前,先對 email 執行 `strtolower(trim($email))`。 + +**理由**:`User@Gmail.com` 與 `user@gmail.com` 在 RFC 5321 語境下是同一信箱,不正規化會導致攻擊者對同一帳號用大小寫變體繞過計數上限。Trim 防止前後空白導致 key 差異。 + +**範圍**:同規則套用到計數 key、expires_at key、以及查詢 DB 前的 email 比對。Laravel Eloquent `where('email', ...)` 若 DB collation 為 `utf8mb4_unicode_ci` 則大小寫不敏感,但 Cache key 必須額外正規化。 + +### D7:不存在帳號不累計失敗計數(已確認) + +**選擇**:若 DB 查無此 email,**不**遞增 Cache 計數,但仍回傳 HTTP 401 並使用與密碼錯誤**相同**的泛用訊息(`"電子郵件或密碼錯誤"`),不揭露帳號是否存在。 + +**理由**: +- 鎖定機制目的是保護真實帳號;不存在的帳號無帳可鎖 +- 為不存在帳號累計計數會造成 Cache 污染,且攻擊者可製造大量假 email 讓 Cache 暴增 +- 統一 401 訊息確保攻擊者無法透過錯誤回應的差異做帳號枚舉(account enumeration) +- 已知殘留風險:攻擊者對真實帳號觸發 5 次失敗後可從 423 推斷帳號存在,但此操作本身已受 IP throttle 限速,實際攻擊成本高 + +**邊界對照**:帳號不存在 → 401(不遞增);帳號存在但密碼錯 → 401(遞增);帳號已鎖定 → 423;兩種 401 的 response body 訊息相同。 + +### D8:TTL / Window 語義 — Fixed Window from First Failure + +**選擇**:採用 **Fixed window**,起算點為「**第一次失敗**」。 + +具體規則: +1. 查 Cache key `login_failures:{role}:{email}`(email 已正規化) +2. 若 key **不存在**(count = 0,或 window 已過期): + - `Cache::put('login_failures:{role}:{email}', 1, $decay_minutes * 60)` + - 同時寫入 `login_expires_at:{role}:{email}` = `now()->addMinutes($decay_minutes)->toIso8601String()`,TTL 相同 +3. 若 key **存在**(count ≥ 1): + - `Cache::increment('login_failures:{role}:{email}')`(**不重設 TTL**,window 不延長) +4. 若遞增後 count ≥ max_attempts:回傳 HTTP 423 + +**理由**:Sliding window(每次失敗延長 TTL)對合法使用者懲罰過重——誤輸密碼後,每一次重試都重置等待時間,實際上永遠解不了鎖。Fixed window 可預測:「最多等 decay_minutes 分鐘」,對使用者友善。 + +**替代方案**:Sliding window → 否決,原因同上。 + +### D9:`locked_until` 的資料來源 — Companion Key + +**選擇**:第一次失敗時同步寫入 companion key `login_expires_at:{role}:{email}`,值為 ISO 8601 字串,TTL 與計數 key 相同。每次回傳 HTTP 423 時讀取此 key 作為 `locked_until` 欄位值。 + +**理由**: +- Laravel Cache 不保證所有 driver 提供 `getExpiry()` API(file driver 無此方法) +- Companion key 方案不依賴 driver 實作細節,跨 file / Redis driver 行為一致 +- 兩個 key 的 TTL 相同,到期時同步消失,無需額外清理 + +**Fallback**:若 `login_expires_at` key 因 driver 不一致或 race condition 缺失,fallback 為 `now()->addMinutes($decay_minutes)->toIso8601String()` 並寫 `Log::warning`。 + +### D10:Cache Key Namespace 正式定義 + +**完整 key 格式**: + +| Key 用途 | 格式 | 有效 role 值 | +|---------|------|------------| +| 失敗計數 | `login_failures:{role}:{normalized_email}` | `member`、`provider` | +| 鎖定到期時間 | `login_expires_at:{role}:{normalized_email}` | `member`、`provider` | + +`{role}` 的值與 API 路徑對應:`/api/member/login` → `member`;`/api/provider/login` → `provider`。`admin` 明確不在此 change 範圍,不建立任何相關 key。 + +`{normalized_email}` = `strtolower(trim($request->email))`,於 controller 頂部完成正規化,後續所有 Cache 操作一律使用正規化後的值。 + +### D11:登入端點 Response Semantics 完整對照表 + +以下為 Member / Provider 登入端點所有可能回應的**完整定義**,用於消除實作端的歧義: + +| 情境 | 檢查順序 | HTTP | Response Body | +|------|---------|------|---------------| +| 帳號已在目前 lockout window 內(lockout active) | 1st | 423 | `{ status: false, message: "帳號已暫時鎖定,請於 N 分鐘後再試", locked_until: "" }` | +| 帳號不存在(DB 查無 email) | 2nd | 401 | `{ status: false, message: "電子郵件或密碼錯誤" }` | +| 帳號存在,密碼錯誤,失敗 1–4 次(未達閾值) | 3rd | 401 | `{ status: false, message: "電子郵件或密碼錯誤" }` | +| 帳號存在,密碼錯誤,**第 5 次**(觸發鎖定) | 3rd | 423 | `{ status: false, message: "帳號已暫時鎖定,請於 N 分鐘後再試", locked_until: "" }` | +| 帳號存在,密碼正確 | 3rd | 200 | `{ status: true, data: { token: "...", user: {...} } }` | + +**檢查順序說明**: +1. 先查 Cache 判斷是否已鎖定(最快路徑,不進 DB) +2. 再查 DB 確認帳號存在(帳號不存在 → 401,不遞增計數) +3. 最後驗證密碼(錯誤 → 遞增計數 → 若達閾值回 423,否則 401;正確 → 200 + 清計數) + +`message` 中的 `N` 為靜態設定值(`decay_minutes`),不是即時計算的剩餘分鐘數。前端**不實作**剩餘時間倒數;`locked_until` 欄位保留於 response 以備未來若需精確倒數時使用。 + +### D12:OAuth 多分頁並發 Flow 的預期行為 + +**選擇**:同一瀏覽器同一 session 下,**只有最後一個啟動的 OAuth flow 能成功**;先啟動的 tab 的 callback 因 state 不符而收到 HTTP 400,前端引導使用者重新操作。此行為為**設計預期**,不視為 bug。 + +**理由**: +- Laravel session 在同一瀏覽器共用(相同 session cookie),後寫的 `oauth_state` 覆蓋先寫的 +- 支援多個並發 state 需在 session 中維護 state 陣列(push/find/remove),複雜度不符現階段需求 +- 使用者實際上幾乎不會在兩個 tab 同時進行 Google OAuth;若發生,400 → redirect `/login?error=oauth_failed` 是合理的 UX 引導 + +**替代方案**:Session 存 state 陣列(每次 redirect 前 push,callback 時 find & remove)→ 否決,目前無真實使用場景,過度設計。 + +## Risks / Trade-offs + +- **[DoS 風險] 攻擊者刻意鎖定合法帳號** → 攻擊者只要知道 email 就能讓帳號鎖 15 分鐘。緩解:IP throttle 已先擋,15 分鐘鎖定時間短,影響有限;金流上線前再評估是否加 CAPTCHA。 +- **[Account Enumeration Trade-off] 不存在帳號不累計失敗計數** → 可避免攻擊者以大量假 email 汙染 Cache,但攻擊者理論上仍可透過 repeated attempts 是否最終進入 423,間接推測帳號存在與否。本 change 接受此風險,先以統一 401 訊息與既有 IP throttle 緩解;若未來威脅模型提高,再評估改為對不存在帳號也累計或加入 CAPTCHA。 +- **[Cache 遺失] file cache 重啟後失效** → 鎖定計數不持久,重啟即解鎖。生產環境預設使用 Redis(`.env` `CACHE_DRIVER=redis`),計數跨容器共享且重啟不失效;file cache 可運作但效果較差(計數不跨容器、重啟清零),僅適用於 dev 環境。 +- **[Session 相容] 移除 stateless() 後需 session driver 正常運作** → Docker 環境 session driver 使用 file,確認 storage/framework/sessions 有寫入權限即可。 + +## Migration Plan + +1. 部署時無 DB migration(Cache 方案不需 migration) +2. **生產環境** `.env` 確認 `CACHE_DRIVER=redis`(Redis 為生產預設,確保計數跨容器共享) +3. `.env` 可選加:`LOCKOUT_MAX_ATTEMPTS=5`、`LOCKOUT_DECAY_MINUTES=15`(有預設值,不加也可) +4. OAuth state 改動:session driver 已在 dev 正常使用,無需額外設定 +5. Rollback:移除兩個 feature 的 controller 邏輯,恢復 `stateless()`;不需 DB rollback + +## Open Questions + +~~是否在前端 LoginView 顯示「剩餘鎖定時間」倒數?~~ → **已決定**:不實作倒數,只顯示靜態 `message`;`locked_until` 保留於 response 供未來擴充。 + +~~生產環境 Cache driver 是否確定為 Redis?~~ → **已決定**:生產環境預設 Redis(`CACHE_DRIVER=redis`);file cache 可運作但效果較差,不建議用於生產。 diff --git a/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/proposal.md b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/proposal.md new file mode 100644 index 0000000..f190af1 --- /dev/null +++ b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/proposal.md @@ -0,0 +1,38 @@ +## Why + +IP-based throttle(P0 已實作)只能防單一 IP 暴力攻擊,無法阻擋分散式密碼暴破(攻擊者用多 IP 對同一帳號嘗試);OAuth 流程目前以 `stateless()` 繞過 CSRF 驗證,存在被偽造授權碼的風險。在金流整合上線前先補齊這兩個帳號層防護,可大幅降低帳號接管與 OAuth CSRF 風險。 + +## What Changes + +- **新增帳號層鎖定機制**:Member / Provider 登入連續失敗 N 次後,鎖定該帳號(而非 IP),鎖定期間回傳 HTTP 423;鎖定計數以 Laravel Cache 儲存,N 分鐘後自動解鎖。 +- **新增 OAuth state 驗證**:`SocialAuthController` 產生隨機 state 字串存入 session,callback 時驗證 state 是否吻合;不吻合時拒絕並回傳 HTTP 400;移除 `stateless()` 呼叫。 + +## Capabilities + +### New Capabilities + +- `account-lockout`:帳號層連續失敗鎖定,覆蓋 Member / Provider 登入流程(Admin 登入失敗計數另行討論,暫不列入) +- `oauth-state-validation`:OAuth CSRF 防護,補 state parameter 生成與驗證,適用 Member Google OAuth 流程 + +### Modified Capabilities + +- `login-rate-limiting`:無需變更需求(IP throttle 與帳號鎖定並行,各自獨立,Requirement 不衝突) + +## Impact + +**後端** +- `app/Http/Controllers/API/AuthController.php`(Member login) +- `app/Http/Controllers/API/ProviderAuthController.php`(Provider login) +- `app/Http/Controllers/API/SocialAuthController.php`(OAuth redirect / callback) +- `routes/api.php`(可能新增解鎖端點) +- `config/auth_lockout.php`(鎖定參數:最大失敗次數、鎖定時間) +- Laravel Cache(驗證失敗計數,driver 使用現有 Redis 或 file) + +**前端** +- `src/views/LoginView.vue` / `CoachLoginView.vue`:顯示「帳號已鎖定,請稍後再試」錯誤訊息(HTTP 423 對應) +- `src/plugins/echo.js` / `src/api/axios.js`:無需變更(OAuth callback 不走 Axios) +- `src/views/AuthCallbackView.vue`:無需變更(後端回傳 400 時 redirect 到 login 並帶錯誤訊息) + +**不影響** +- 現有 Sanctum token、sessionStorage、refresh interceptor +- Admin Panel(Admin 登入鎖定不在此 change 範圍) diff --git a/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/specs/account-lockout/spec.md b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/specs/account-lockout/spec.md new file mode 100644 index 0000000..d7061ae --- /dev/null +++ b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/specs/account-lockout/spec.md @@ -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: ""` | +| 帳號不存在(DB 查無 email) | 2nd | 401 | `message: "電子郵件或密碼錯誤"` | +| 帳號存在,密碼錯,失敗 1–4 次 | 3rd | 401 | `message: "電子郵件或密碼錯誤"` | +| 帳號存在,密碼錯,**第 5 次**(當場觸發鎖定) | 3rd | 423 | `message: "帳號已暫時鎖定..."`, `locked_until: ""` | +| 帳號存在,密碼正確 | 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 423,body 包含 `{ status: false, message: "帳號已暫時鎖定,請於 15 分鐘後再試", locked_until: "" }`,後續任何密碼登入均被拒絕直到時間到期 + +#### 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 401,body `{ 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** 同上,顯示鎖定提示訊息 diff --git a/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/specs/oauth-state-validation/spec.md b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/specs/oauth-state-validation/spec.md new file mode 100644 index 0000000..8c75c58 --- /dev/null +++ b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/specs/oauth-state-validation/spec.md @@ -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 值存入 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 先啟動 OAuth(session 寫入 state_A),Tab B 後啟動 OAuth(session 覆寫為 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 錯誤 diff --git a/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/tasks.md b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/tasks.md new file mode 100644 index 0000000..faf4f4d --- /dev/null +++ b/openspec/changes/archive/2026-06-03-auth-p2-lockout-oauth-state/tasks.md @@ -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 key(TTL 相同);若 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 key(failures + 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 正常 diff --git a/openspec/specs/account-lockout/spec.md b/openspec/specs/account-lockout/spec.md new file mode 100644 index 0000000..d7061ae --- /dev/null +++ b/openspec/specs/account-lockout/spec.md @@ -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: ""` | +| 帳號不存在(DB 查無 email) | 2nd | 401 | `message: "電子郵件或密碼錯誤"` | +| 帳號存在,密碼錯,失敗 1–4 次 | 3rd | 401 | `message: "電子郵件或密碼錯誤"` | +| 帳號存在,密碼錯,**第 5 次**(當場觸發鎖定) | 3rd | 423 | `message: "帳號已暫時鎖定..."`, `locked_until: ""` | +| 帳號存在,密碼正確 | 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 423,body 包含 `{ status: false, message: "帳號已暫時鎖定,請於 15 分鐘後再試", locked_until: "" }`,後續任何密碼登入均被拒絕直到時間到期 + +#### 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 401,body `{ 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** 同上,顯示鎖定提示訊息 diff --git a/openspec/specs/oauth-state-validation/spec.md b/openspec/specs/oauth-state-validation/spec.md new file mode 100644 index 0000000..8c75c58 --- /dev/null +++ b/openspec/specs/oauth-state-validation/spec.md @@ -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 值存入 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 先啟動 OAuth(session 寫入 state_A),Tab B 後啟動 OAuth(session 覆寫為 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 錯誤 diff --git a/routes/api.php b/routes/api.php index 915a675..c1e8870 100644 --- a/routes/api.php +++ b/routes/api.php @@ -28,7 +28,7 @@ Route::get('/diving-offers/{id}/reviews', [ReviewController::class, 'publicList // 會員註冊/登入 Route::post('/member/register', [AuthController::class, 'registerMember']); -Route::middleware('throttle:5,1')->post('/member/login', [AuthController::class, 'loginMember']); +Route::middleware('throttle:10,1')->post('/member/login', [AuthController::class, 'loginMember']); // Google 第三方登入(僅會員) Route::get('/auth/google/redirect', [\App\Http\Controllers\API\SocialAuthController::class, 'redirectToGoogle']); @@ -62,7 +62,7 @@ Route::middleware('auth:sanctum')->post('/reviews/{id}/helpful', [ReviewControll // 服務提供者註冊/登入 Route::post('/provider/register', [AuthController::class, 'registerProvider']); -Route::middleware('throttle:5,1')->post('/provider/login', [AuthController::class, 'loginProvider']); +Route::middleware('throttle:10,1')->post('/provider/login', [AuthController::class, 'loginProvider']); // 服務提供者專屬 API(需登入) Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () { -- 2.52.0