From cb9c9a9385c5b88e676c1bd634891472eecaf945 Mon Sep 17 00:00:00 2001 From: Hank Date: Tue, 2 Jun 2026 01:54:40 +0800 Subject: [PATCH] =?UTF-8?q?security(auth):=20P0=20=E8=AA=8D=E8=AD=89?= =?UTF-8?q?=E5=AE=89=E5=85=A8=E5=BC=B7=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .../Controllers/API/SocialAuthController.php | 4 +- config/sanctum.php | 2 +- .../2025_05_06_065906_create_orgin_table.php | 2 +- ...185046_add_provider_to_users_role_enum.php | 6 ++ frontend/src/api/axios.js | 16 +++ frontend/src/api/coachAxios.js | 16 +++ frontend/src/views/AuthCallbackView.vue | 2 +- .../.openspec.yaml | 2 + .../2026-06-02-auth-p0-hardening/design.md | 97 +++++++++++++++++ .../2026-06-02-auth-p0-hardening/proposal.md | 29 +++++ .../specs/admin-auth/spec.md | 29 +++++ .../specs/login-rate-limiting/spec.md | 20 ++++ .../specs/member-portal-ui/spec.md | 37 +++++++ .../specs/provider-auth/spec.md | 33 ++++++ .../2026-06-02-auth-p0-hardening/tasks.md | 30 ++++++ openspec/specs/admin-auth/spec.md | 19 +++- openspec/specs/login-rate-limiting/spec.md | 20 ++++ openspec/specs/member-portal-ui/spec.md | 21 ++++ openspec/specs/provider-auth/spec.md | 19 +++- routes/api.php | 6 +- routes/console.php | 1 + tests/Feature/AuthRateLimitTest.php | 101 ++++++++++++++++++ 22 files changed, 502 insertions(+), 10 deletions(-) create mode 100644 openspec/changes/archive/2026-06-02-auth-p0-hardening/.openspec.yaml create mode 100644 openspec/changes/archive/2026-06-02-auth-p0-hardening/design.md create mode 100644 openspec/changes/archive/2026-06-02-auth-p0-hardening/proposal.md create mode 100644 openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/admin-auth/spec.md create mode 100644 openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/login-rate-limiting/spec.md create mode 100644 openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/member-portal-ui/spec.md create mode 100644 openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/provider-auth/spec.md create mode 100644 openspec/changes/archive/2026-06-02-auth-p0-hardening/tasks.md create mode 100644 openspec/specs/login-rate-limiting/spec.md create mode 100644 tests/Feature/AuthRateLimitTest.php diff --git a/app/Http/Controllers/API/SocialAuthController.php b/app/Http/Controllers/API/SocialAuthController.php index add1ffe..ecf57e3 100644 --- a/app/Http/Controllers/API/SocialAuthController.php +++ b/app/Http/Controllers/API/SocialAuthController.php @@ -106,9 +106,9 @@ class SocialAuthController extends Controller // 生成 Sanctum token $token = $user->createToken('google-auth')->plainTextToken; - return redirect(env('FRONTEND_URL') . '/auth/callback?token=' . $token); + return redirect(config('app.frontend_url') . '/auth/callback#token=' . $token); } catch (\Exception $e) { - return redirect(env('FRONTEND_URL') . '/login?error=oauth_failed'); + return redirect(config('app.frontend_url') . '/login?error=oauth_failed'); } } } diff --git a/config/sanctum.php b/config/sanctum.php index 44527d6..417de37 100644 --- a/config/sanctum.php +++ b/config/sanctum.php @@ -47,7 +47,7 @@ return [ | */ - 'expiration' => null, + 'expiration' => 60 * 24 * 7, /* |-------------------------------------------------------------------------- diff --git a/database/migrations/2025_05_06_065906_create_orgin_table.php b/database/migrations/2025_05_06_065906_create_orgin_table.php index 77ba417..039278f 100644 --- a/database/migrations/2025_05_06_065906_create_orgin_table.php +++ b/database/migrations/2025_05_06_065906_create_orgin_table.php @@ -19,7 +19,7 @@ return new class extends Migration $table->timestamp('email_verified_at')->nullable(); // 驗證郵件時間 $table->string('password'); // 密碼 $table->string('phone')->nullable(); // 電話號碼,可為空 - $table->enum('role', ['admin', 'coach', 'member'])->default('member'); // 角色:管理員、教練、會員 + $table->enum('role', ['admin', 'coach', 'member', 'provider'])->default('member'); // 角色:管理員、教練、會員、服務提供者 $table->boolean('is_active')->default(true); // 是否啟用 $table->rememberToken(); // 記住我 token $table->timestamps(); // 建立與更新時間 diff --git a/database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php b/database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php index b3783ac..f62d5d2 100644 --- a/database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php +++ b/database/migrations/2026_05_09_185046_add_provider_to_users_role_enum.php @@ -12,11 +12,17 @@ return new class extends Migration */ public function up(): void { + if (DB::getDriverName() === 'sqlite') { + return; + } DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member', 'provider') NOT NULL DEFAULT 'member'"); } public function down(): void { + if (DB::getDriverName() === 'sqlite') { + return; + } DB::statement("ALTER TABLE users MODIFY COLUMN role ENUM('admin', 'coach', 'member') NOT NULL DEFAULT 'member'"); } }; diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index 0bdf214..c952480 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -13,4 +13,20 @@ api.interceptors.request.use((config) => { return config }) +api.interceptors.response.use( + (response) => response, + (error) => { + if ( + error.response?.status === 401 && + !error.config.url.includes('/login') && + !error.config.url.includes('/register') + ) { + localStorage.removeItem('token') + localStorage.removeItem('user') + window.location.href = '/login' + } + return Promise.reject(error) + } +) + export default api diff --git a/frontend/src/api/coachAxios.js b/frontend/src/api/coachAxios.js index b534f2d..9ddd9e3 100644 --- a/frontend/src/api/coachAxios.js +++ b/frontend/src/api/coachAxios.js @@ -13,4 +13,20 @@ coachApi.interceptors.request.use((config) => { return config }) +coachApi.interceptors.response.use( + (response) => response, + (error) => { + if ( + error.response?.status === 401 && + !error.config.url.includes('/login') && + !error.config.url.includes('/register') + ) { + localStorage.removeItem('coach_token') + localStorage.removeItem('coach_user') + window.location.href = '/coach/login' + } + return Promise.reject(error) + } +) + export default coachApi diff --git a/frontend/src/views/AuthCallbackView.vue b/frontend/src/views/AuthCallbackView.vue index e4e43ee..1959fd2 100644 --- a/frontend/src/views/AuthCallbackView.vue +++ b/frontend/src/views/AuthCallbackView.vue @@ -9,7 +9,7 @@ const route = useRoute() const auth = useAuthStore() onMounted(async () => { - const token = route.query.token + const token = new URLSearchParams(window.location.hash.substring(1)).get('token') const error = route.query.error if (error || !token) { diff --git a/openspec/changes/archive/2026-06-02-auth-p0-hardening/.openspec.yaml b/openspec/changes/archive/2026-06-02-auth-p0-hardening/.openspec.yaml new file mode 100644 index 0000000..a2168c3 --- /dev/null +++ b/openspec/changes/archive/2026-06-02-auth-p0-hardening/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-06-01 diff --git a/openspec/changes/archive/2026-06-02-auth-p0-hardening/design.md b/openspec/changes/archive/2026-06-02-auth-p0-hardening/design.md new file mode 100644 index 0000000..37223ef --- /dev/null +++ b/openspec/changes/archive/2026-06-02-auth-p0-hardening/design.md @@ -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=` 再由前端換 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 中驗證。 diff --git a/openspec/changes/archive/2026-06-02-auth-p0-hardening/proposal.md b/openspec/changes/archive/2026-06-02-auth-p0-hardening/proposal.md new file mode 100644 index 0000000..5b75d08 --- /dev/null +++ b/openspec/changes/archive/2026-06-02-auth-p0-hardening/proposal.md @@ -0,0 +1,29 @@ +## Why + +目前認證機制存在三個高危漏洞:Token 永不過期(一旦洩漏攻擊者永久持有存取權)、Google OAuth callback 將 Sanctum token 附在 URL query string(出現在 server log、瀏覽器歷史、Referer header),以及登入端點無頻率限制(暴力破解無防護)。三項缺陷均可在不入侵伺服器的前提下被利用。 + +## What Changes + +- `config/sanctum.php`:將 `expiration` 從 `null` 改為 `10080`(7 天 × 24 小時 × 60 分鐘) +- `SocialAuthController::handleGoogleCallback()`:redirect URL 改用 `#token=` fragment 取代 `?token=` query string +- `AuthCallbackView.vue`:改從 `window.location.hash` 讀取 token,不再使用 `route.query.token` +- `routes/api.php`:對 member / provider / admin 三個登入端點套用 `throttle:5,1` middleware + +## Capabilities + +### New Capabilities + +- `login-rate-limiting`: 登入端點的請求頻率限制——每個 IP 每分鐘最多 5 次,超過回傳 429 + +### Modified Capabilities + +- `provider-auth`: 新增登入 rate limiting 場景;新增 token 有效期行為(7 天後失效) +- `admin-auth`: 新增登入 rate limiting 場景;新增 token 有效期行為(7 天後失效) +- `member-portal-ui`: Google OAuth callback 行為變更——token 改由 URL fragment 傳遞,前端改從 hash 讀取 + +## Impact + +- **後端**:`config/sanctum.php`、`app/Http/Controllers/API/SocialAuthController.php`、`routes/api.php` +- **前端**:`frontend/src/views/AuthCallbackView.vue` +- **現有 token**:`expiration` 設定僅影響**新建立**的 token,既有的 `personal_access_tokens` 不受影響(`expires_at` 欄位為 null 的舊 token 仍長期有效),需另行清除或接受自然淘汰 +- **無破壞性變更**:API 介面、response 格式、前端路由均不改變 diff --git a/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/admin-auth/spec.md b/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/admin-auth/spec.md new file mode 100644 index 0000000..2d1e123 --- /dev/null +++ b/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/admin-auth/spec.md @@ -0,0 +1,29 @@ +## MODIFIED Requirements + +### Requirement: 管理員登入 +後端 SHALL 提供 `POST /api/admin/login`(現有 AuthController 方法),驗證 email/password 並確認 role=admin,回傳有效期 7 天的 Bearer token。 + +#### Scenario: 正確帳密登入 +- **WHEN** 管理員送出正確 email 與 password +- **THEN** 回傳 HTTP 200,`{ status: true, data: { user, token, token_type: "Bearer" } }` + +#### Scenario: 非 admin 角色帳號嘗試登入 +- **WHEN** role 非 admin 的帳號嘗試呼叫此端點 +- **THEN** 回傳 HTTP 401,`{ status: false, message: "電子郵件或密碼錯誤" }` + +#### Scenario: 超過登入頻率限制 +- **WHEN** 同一 IP 在 1 分鐘內送出超過 3 次登入請求 +- **THEN** 回傳 HTTP 429,帶有 `Retry-After` header + +## ADDED Requirements + +### Requirement: 管理員 Bearer Token 有效期 +後端 SHALL 發行有效期為 7 天的管理員 Bearer Token,過期後需重新登入。 + +#### Scenario: Token 過期後管理員請求被拒絕 +- **WHEN** 管理員使用已過期(超過 7 天)的 token 送出 API 請求 +- **THEN** 回傳 HTTP 401,token 視為無效 + +#### Scenario: 有效期內 token 正常通過認證 +- **WHEN** 管理員使用未過期的 token 送出 API 請求 +- **THEN** 請求正常通過認證,回傳對應資源 diff --git a/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/login-rate-limiting/spec.md b/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/login-rate-limiting/spec.md new file mode 100644 index 0000000..426330f --- /dev/null +++ b/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/login-rate-limiting/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: 登入頻率限制 +後端 SHALL 對所有登入端點套用 IP-based 頻率限制,超過限制時回傳 HTTP 429。Member 與 Provider 每 IP 每分鐘最多 5 次;Admin 因影響範圍更廣,限制為每 IP 每分鐘最多 3 次。 + +#### Scenario: Member / Provider 正常登入不受影響 +- **WHEN** 同一 IP 在 1 分鐘內對 `/api/member/login` 或 `/api/provider/login` 送出 5 次以內的請求 +- **THEN** 請求正常處理,回傳對應的登入結果(200 成功或 401 失敗) + +#### Scenario: Member / Provider 超過頻率限制 +- **WHEN** 同一 IP 在 1 分鐘內送出第 6 次 member 或 provider 登入請求 +- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間 + +#### Scenario: Admin 超過頻率限制 +- **WHEN** 同一 IP 在 1 分鐘內送出第 4 次 `/api/admin/login` 請求 +- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間 + +#### Scenario: 頻率限制重置 +- **WHEN** 頻率限制觸發後等待 1 分鐘 +- **THEN** 該 IP 的請求計數重置,可再次正常送出登入請求 diff --git a/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/member-portal-ui/spec.md b/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/member-portal-ui/spec.md new file mode 100644 index 0000000..593eef0 --- /dev/null +++ b/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/member-portal-ui/spec.md @@ -0,0 +1,37 @@ +## MODIFIED Requirements + +### Requirement: 登入頁 +前端 SHALL 提供 `/login` 頁面,供會員以 email/password 登入,以及 Google OAuth 登入入口。 + +#### Scenario: Email/Password 登入成功 +- **WHEN** 使用者填入正確的 email 與 password 並送出 +- **THEN** 呼叫 `POST /api/member/login`,儲存回傳的 token 至 localStorage,導航至 `/courses` + +#### Scenario: 登入失敗 +- **WHEN** 使用者填入錯誤的 email 或 password +- **THEN** 頁面顯示錯誤訊息,不跳轉 + +#### Scenario: Google OAuth 登入 +- **WHEN** 使用者點擊「以 Google 登入」按鈕 +- **THEN** 瀏覽器導航至後端 `GET /api/auth/google/redirect`,開始 OAuth 流程 + +#### Scenario: 超過登入頻率限制 +- **WHEN** 同一 IP 在 1 分鐘內送出超過 5 次登入請求 +- **THEN** 前端顯示適當的錯誤訊息(對應後端回傳的 HTTP 429) + +## ADDED Requirements + +### Requirement: Google OAuth Callback 處理 +前端 SHALL 在 `/auth/callback` 路由讀取 URL fragment(`#token=`)取得 Sanctum token,完成 OAuth 登入後將 token 存入 localStorage 並導航至 `/courses`。token 不得透過 URL query string 傳遞。 + +#### Scenario: OAuth callback 成功取得 token +- **WHEN** 後端 OAuth callback redirect 到 `/auth/callback#token=` +- **THEN** 前端從 `window.location.hash` 解析 token,呼叫 `/api/member/profile` 取得使用者資料,呼叫 `auth.setAuth()` 儲存認證狀態,並導航至 `/courses` + +#### Scenario: OAuth callback 缺少 token +- **WHEN** redirect 到 `/auth/callback` 但 hash 中無 `token` 參數 +- **THEN** 前端導航至 `/login?error=oauth_failed`,顯示錯誤訊息 + +#### Scenario: URL 中不留存 token +- **WHEN** callback 頁面成功處理 token 後 +- **THEN** 瀏覽器網址列不顯示 token(使用 `history.replaceState` 清除 hash) diff --git a/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/provider-auth/spec.md b/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/provider-auth/spec.md new file mode 100644 index 0000000..b2523fe --- /dev/null +++ b/openspec/changes/archive/2026-06-02-auth-p0-hardening/specs/provider-auth/spec.md @@ -0,0 +1,33 @@ +## MODIFIED Requirements + +### Requirement: 教練帳號登入 +後端 SHALL 提供 `POST /api/provider/login`,驗證 email/password 並回傳 Sanctum Bearer token,僅限 role=provider 帳號。回傳的 token 有效期為 7 天。 + +#### Scenario: 正確帳密登入成功 +- **WHEN** 教練送出正確的 email 與 password +- **THEN** 回傳 HTTP 200,包含 `{ status: true, data: { user, token, token_type: "Bearer" } }` + +#### Scenario: 錯誤帳密登入失敗 +- **WHEN** 教練送出錯誤的 email 或 password +- **THEN** 回傳 HTTP 401,`{ status: false, message: "帳號或密碼錯誤" }` + +#### Scenario: 會員帳號無法用教練登入 +- **WHEN** role=member 的帳號嘗試呼叫 `/api/provider/login` +- **THEN** 回傳 HTTP 403,`{ status: false, message: "此帳號非教練角色" }` + +#### Scenario: 超過登入頻率限制 +- **WHEN** 同一 IP 在 1 分鐘內送出超過 5 次登入請求 +- **THEN** 回傳 HTTP 429,帶有 `Retry-After` header + +## ADDED Requirements + +### Requirement: Bearer Token 有效期 +後端 SHALL 發行有效期為 7 天的 Sanctum Bearer Token,過期後使用者必須重新登入。 + +#### Scenario: Token 過期後請求被拒絕 +- **WHEN** 教練使用已過期(超過 7 天)的 token 送出 API 請求 +- **THEN** 回傳 HTTP 401,token 視為無效 + +#### Scenario: 有效期內 token 正常運作 +- **WHEN** 教練使用未過期的 token 送出 API 請求 +- **THEN** 請求正常通過認證 diff --git a/openspec/changes/archive/2026-06-02-auth-p0-hardening/tasks.md b/openspec/changes/archive/2026-06-02-auth-p0-hardening/tasks.md new file mode 100644 index 0000000..fba9d6c --- /dev/null +++ b/openspec/changes/archive/2026-06-02-auth-p0-hardening/tasks.md @@ -0,0 +1,30 @@ +## 1. Token 過期時間設定 + +- [x] 1.1 [後端] 修改 `config/sanctum.php`:將 `'expiration' => null` 改為 `'expiration' => 60 * 24 * 7`(10080 分鐘) +- [x] 1.2 [後端] 在 `routes/console.php` 新增排程:`Schedule::command('sanctum:prune-expired --hours=168')->daily();`,清除累積的過期 token 記錄 +- [x] 1.3 [後端] 執行 `php artisan config:clear` 確認設定生效 + +## 2. Google OAuth Token 洩漏修復 + +- [x] 2.1 [後端] 修改 `app/Http/Controllers/API/SocialAuthController.php` 第 109、111 行:`env('FRONTEND_URL')` 改為 `config('app.frontend_url')`(防止 `config:cache` 後 env() 回傳 null),同時將第 109 行 `?token=` 改為 `#token=`(URL fragment) +- [x] 2.2 [前端] 修改 `frontend/src/views/AuthCallbackView.vue`:將讀取 `route.query.token` 改為讀取 `window.location.hash`(`new URLSearchParams(window.location.hash.substring(1)).get('token')`) + +## 3. 登入端點 Rate Limiting + +- [x] 3.1 [後端] 修改 `routes/api.php`:對 `POST /api/member/login` 套用 `throttle:5,1` middleware +- [x] 3.2 [後端] 修改 `routes/api.php`:對 `POST /api/provider/login` 套用 `throttle:5,1` middleware +- [x] 3.3 [後端] 修改 `routes/api.php`:對 `POST /api/admin/login` 套用 `throttle:3,1` middleware(管理員帳號影響範圍更廣,採用更嚴格限制) +- [x] 3.4 [後端] 執行 `php artisan route:clear` 清除路由快取 + +## 4. 自動化測試 + +- [x] 4.1 [測試] 在 `tests/Feature/AuthRateLimitTest.php` 建立 Feature test:驗證 `POST /api/member/login` 第 6 次請求回傳 HTTP 429,並斷言 response header 含 `Retry-After` +- [x] 4.2 [測試] 在同一 test file 補充:`POST /api/provider/login` 第 6 次回傳 429;`POST /api/admin/login` 第 4 次回傳 429(admin 限制為 throttle:3,1) +- [x] 4.3 [測試] 在 `tests/Feature/AuthRateLimitTest.php` 補充:正常登入(5 次以內)不受 throttle 影響,回傳 200 或 401 + +## 5. 手動驗證 + +- [x] 5.1 [整合測試] 驗證 Google OAuth 登入流程:登入後確認 Nginx access log 中 `/auth/google/callback` 的請求 URL 不含 token +- [x] 5.2 [整合測試] 驗證 Token 過期:在 `personal_access_tokens` 表手動將 `expires_at` 設為過去時間,確認 API 回傳 401 並自動登出 +- [x] 5.3 [整合測試] 執行 `php artisan sanctum:prune-expired --hours=168`,確認 `personal_access_tokens` 中過期記錄被清除 +- [x] 5.4 [整合測試] 驗證正常登入流程不受影響:member / provider 帳號各測試一次完整登入 → 操作 → 登出流程 diff --git a/openspec/specs/admin-auth/spec.md b/openspec/specs/admin-auth/spec.md index 59f78e5..03b17c2 100644 --- a/openspec/specs/admin-auth/spec.md +++ b/openspec/specs/admin-auth/spec.md @@ -1,7 +1,7 @@ ## ADDED Requirements ### Requirement: 管理員登入 -後端 SHALL 提供 `POST /api/admin/login`(現有 AuthController 方法),驗證 email/password 並確認 role=admin,回傳 Bearer token。 +後端 SHALL 提供 `POST /api/admin/login`(現有 AuthController 方法),驗證 email/password 並確認 role=admin,回傳有效期 7 天的 Bearer token。 #### Scenario: 正確帳密登入 - **WHEN** 管理員送出正確 email 與 password @@ -11,6 +11,10 @@ - **WHEN** role 非 admin 的帳號嘗試呼叫此端點 - **THEN** 回傳 HTTP 401,`{ status: false, message: "電子郵件或密碼錯誤" }` +#### Scenario: 超過登入頻率限制 +- **WHEN** 同一 IP 在 1 分鐘內送出超過 3 次登入請求 +- **THEN** 回傳 HTTP 429,帶有 `Retry-After` header + --- ### Requirement: 管理員登出 @@ -28,3 +32,16 @@ #### Scenario: 取得個人資料 - **WHEN** 已登入管理員送出 GET 請求 - **THEN** 回傳 HTTP 200,包含 name / email / role / adminProfile(position / department) + +--- + +### Requirement: 管理員 Bearer Token 有效期 +後端 SHALL 發行有效期為 7 天的管理員 Bearer Token,過期後需重新登入。 + +#### Scenario: Token 過期後管理員請求被拒絕 +- **WHEN** 管理員使用已過期(超過 7 天)的 token 送出 API 請求 +- **THEN** 回傳 HTTP 401,token 視為無效 + +#### Scenario: 有效期內 token 正常通過認證 +- **WHEN** 管理員使用未過期的 token 送出 API 請求 +- **THEN** 請求正常通過認證,回傳對應資源 diff --git a/openspec/specs/login-rate-limiting/spec.md b/openspec/specs/login-rate-limiting/spec.md new file mode 100644 index 0000000..426330f --- /dev/null +++ b/openspec/specs/login-rate-limiting/spec.md @@ -0,0 +1,20 @@ +## ADDED Requirements + +### Requirement: 登入頻率限制 +後端 SHALL 對所有登入端點套用 IP-based 頻率限制,超過限制時回傳 HTTP 429。Member 與 Provider 每 IP 每分鐘最多 5 次;Admin 因影響範圍更廣,限制為每 IP 每分鐘最多 3 次。 + +#### Scenario: Member / Provider 正常登入不受影響 +- **WHEN** 同一 IP 在 1 分鐘內對 `/api/member/login` 或 `/api/provider/login` 送出 5 次以內的請求 +- **THEN** 請求正常處理,回傳對應的登入結果(200 成功或 401 失敗) + +#### Scenario: Member / Provider 超過頻率限制 +- **WHEN** 同一 IP 在 1 分鐘內送出第 6 次 member 或 provider 登入請求 +- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間 + +#### Scenario: Admin 超過頻率限制 +- **WHEN** 同一 IP 在 1 分鐘內送出第 4 次 `/api/admin/login` 請求 +- **THEN** 回傳 HTTP 429,並帶有 `Retry-After` header 指示等待時間 + +#### Scenario: 頻率限制重置 +- **WHEN** 頻率限制觸發後等待 1 分鐘 +- **THEN** 該 IP 的請求計數重置,可再次正常送出登入請求 diff --git a/openspec/specs/member-portal-ui/spec.md b/openspec/specs/member-portal-ui/spec.md index 36c7a9d..641b525 100644 --- a/openspec/specs/member-portal-ui/spec.md +++ b/openspec/specs/member-portal-ui/spec.md @@ -75,6 +75,27 @@ - **WHEN** 使用者點擊「以 Google 登入」按鈕 - **THEN** 瀏覽器導航至後端 `GET /api/auth/google/redirect`,開始 OAuth 流程 +#### Scenario: 超過登入頻率限制 +- **WHEN** 同一 IP 在 1 分鐘內送出超過 5 次登入請求 +- **THEN** 前端顯示適當的錯誤訊息(對應後端回傳的 HTTP 429) + +--- + +### Requirement: Google OAuth Callback 處理 +前端 SHALL 在 `/auth/callback` 路由讀取 URL fragment(`#token=`)取得 Sanctum token,完成 OAuth 登入後將 token 存入 localStorage 並導航至 `/courses`。token 不得透過 URL query string 傳遞。 + +#### Scenario: OAuth callback 成功取得 token +- **WHEN** 後端 OAuth callback redirect 到 `/auth/callback#token=` +- **THEN** 前端從 `window.location.hash` 解析 token,呼叫 `/api/member/profile` 取得使用者資料,呼叫 `auth.setAuth()` 儲存認證狀態,並導航至 `/courses` + +#### Scenario: OAuth callback 缺少 token +- **WHEN** redirect 到 `/auth/callback` 但 hash 中無 `token` 參數 +- **THEN** 前端導航至 `/login?error=oauth_failed`,顯示錯誤訊息 + +#### Scenario: URL 中不留存 token +- **WHEN** callback 頁面成功處理 token 後 +- **THEN** 瀏覽器網址列不顯示 token(使用 `history.replaceState` 清除 hash) + --- ### Requirement: 註冊頁 diff --git a/openspec/specs/provider-auth/spec.md b/openspec/specs/provider-auth/spec.md index 7af18ea..12b2ab3 100644 --- a/openspec/specs/provider-auth/spec.md +++ b/openspec/specs/provider-auth/spec.md @@ -1,7 +1,7 @@ ## ADDED Requirements ### Requirement: 教練帳號登入 -後端 SHALL 提供 `POST /api/provider/login`,驗證 email/password 並回傳 Sanctum Bearer token,僅限 role=provider 帳號。 +後端 SHALL 提供 `POST /api/provider/login`,驗證 email/password 並回傳 Sanctum Bearer token,僅限 role=provider 帳號。回傳的 token 有效期為 7 天。 #### Scenario: 正確帳密登入成功 - **WHEN** 教練送出正確的 email 與 password @@ -15,6 +15,10 @@ - **WHEN** role=member 的帳號嘗試呼叫 `/api/provider/login` - **THEN** 回傳 HTTP 403,`{ status: false, message: "此帳號非教練角色" }` +#### Scenario: 超過登入頻率限制 +- **WHEN** 同一 IP 在 1 分鐘內送出超過 5 次登入請求 +- **THEN** 回傳 HTTP 429,帶有 `Retry-After` header + --- ### Requirement: 教練帳號註冊 @@ -54,3 +58,16 @@ #### Scenario: 更新成功 - **WHEN** 教練送出合法的更新資料 - **THEN** 回傳 HTTP 200,`{ status: true, message: "資料已更新", data: { ...profile } }` + +--- + +### Requirement: Bearer Token 有效期 +後端 SHALL 發行有效期為 7 天的 Sanctum Bearer Token,過期後使用者必須重新登入。 + +#### Scenario: Token 過期後請求被拒絕 +- **WHEN** 教練使用已過期(超過 7 天)的 token 送出 API 請求 +- **THEN** 回傳 HTTP 401,token 視為無效 + +#### Scenario: 有效期內 token 正常運作 +- **WHEN** 教練使用未過期的 token 送出 API 請求 +- **THEN** 請求正常通過認證 diff --git a/routes/api.php b/routes/api.php index a26a4fa..9901059 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::post('/member/login', [AuthController::class, 'loginMember']); +Route::middleware('throttle:5,1')->post('/member/login', [AuthController::class, 'loginMember']); // Google 第三方登入(僅會員) Route::get('/auth/google/redirect', [\App\Http\Controllers\API\SocialAuthController::class, 'redirectToGoogle']); @@ -60,7 +60,7 @@ Route::middleware('auth:sanctum')->post('/reviews/{id}/helpful', [ReviewControll // 服務提供者註冊/登入 Route::post('/provider/register', [AuthController::class, 'registerProvider']); -Route::post('/provider/login', [AuthController::class, 'loginProvider']); +Route::middleware('throttle:5,1')->post('/provider/login', [AuthController::class, 'loginProvider']); // 服務提供者專屬 API(需登入) Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () { @@ -98,7 +98,7 @@ Route::middleware(['auth:sanctum'])->prefix('provider')->group(function () { // 管理員註冊/登入 Route::post('/admin/register', [AuthController::class, 'registerAdmin']); -Route::post('/admin/login', [AuthController::class, 'loginAdmin']); +Route::middleware('throttle:3,1')->post('/admin/login', [AuthController::class, 'loginAdmin']); // 管理員專屬 API(需登入) Route::middleware(['auth:sanctum', 'admin'])->prefix('admin')->group(function () { diff --git a/routes/console.php b/routes/console.php index bb081a7..9591445 100644 --- a/routes/console.php +++ b/routes/console.php @@ -10,3 +10,4 @@ Artisan::command('inspire', function () { Schedule::command('app:expire-pending-bookings')->hourly(); Schedule::command('app:complete-finished-bookings')->dailyAt('00:05'); +Schedule::command('sanctum:prune-expired --hours=168')->daily(); diff --git a/tests/Feature/AuthRateLimitTest.php b/tests/Feature/AuthRateLimitTest.php new file mode 100644 index 0000000..0e7da98 --- /dev/null +++ b/tests/Feature/AuthRateLimitTest.php @@ -0,0 +1,101 @@ +postJson('/api/member/login', [ + 'email' => 'notexist@example.com', + 'password' => 'wrong', + ]); + $this->assertNotEquals(429, $response->status()); + } + } + + public function test_member_login_exceeds_limit_returns_429(): void + { + for ($i = 0; $i < 5; $i++) { + $this->postJson('/api/member/login', [ + 'email' => 'notexist@example.com', + 'password' => 'wrong', + ]); + } + + $response = $this->postJson('/api/member/login', [ + 'email' => 'notexist@example.com', + 'password' => 'wrong', + ]); + + $response->assertStatus(429); + $this->assertTrue($response->headers->has('Retry-After')); + } + + // ── provider (throttle:5,1) ────────────────────────────── + + public function test_provider_login_exceeds_limit_returns_429(): void + { + for ($i = 0; $i < 5; $i++) { + $this->postJson('/api/provider/login', [ + 'email' => 'notexist@example.com', + 'password' => 'wrong', + ]); + } + + $response = $this->postJson('/api/provider/login', [ + 'email' => 'notexist@example.com', + 'password' => 'wrong', + ]); + + $response->assertStatus(429); + $this->assertTrue($response->headers->has('Retry-After')); + } + + // ── admin (throttle:3,1) ───────────────────────────────── + + public function test_admin_login_within_limit_is_not_throttled(): void + { + for ($i = 0; $i < 3; $i++) { + $response = $this->postJson('/api/admin/login', [ + 'email' => 'notexist@example.com', + 'password' => 'wrong', + ]); + $this->assertNotEquals(429, $response->status()); + } + } + + public function test_admin_login_exceeds_stricter_limit_returns_429(): void + { + for ($i = 0; $i < 3; $i++) { + $this->postJson('/api/admin/login', [ + 'email' => 'notexist@example.com', + 'password' => 'wrong', + ]); + } + + $response = $this->postJson('/api/admin/login', [ + 'email' => 'notexist@example.com', + 'password' => 'wrong', + ]); + + $response->assertStatus(429); + $this->assertTrue($response->headers->has('Retry-After')); + } +}