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
@@ -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');
}
}
}
+1 -1
View File
@@ -47,7 +47,7 @@ return [
|
*/
'expiration' => null,
'expiration' => 60 * 24 * 7,
/*
|--------------------------------------------------------------------------
@@ -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(); // 建立與更新時間
@@ -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'");
}
};
+16
View File
@@ -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
+16
View File
@@ -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
+1 -1
View File
@@ -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) {
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-06-01
@@ -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 中驗證。
@@ -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 格式、前端路由均不改變
@@ -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 401token 視為無效
#### Scenario: 有效期內 token 正常通過認證
- **WHEN** 管理員使用未過期的 token 送出 API 請求
- **THEN** 請求正常通過認證,回傳對應資源
@@ -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 的請求計數重置,可再次正常送出登入請求
@@ -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=<value>`)取得 Sanctum token,完成 OAuth 登入後將 token 存入 localStorage 並導航至 `/courses`。token 不得透過 URL query string 傳遞。
#### Scenario: OAuth callback 成功取得 token
- **WHEN** 後端 OAuth callback redirect 到 `/auth/callback#token=<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
@@ -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 401token 視為無效
#### Scenario: 有效期內 token 正常運作
- **WHEN** 教練使用未過期的 token 送出 API 請求
- **THEN** 請求正常通過認證
@@ -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 次回傳 429admin 限制為 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 帳號各測試一次完整登入 → 操作 → 登出流程
+18 -1
View File
@@ -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 / adminProfileposition / department
---
### Requirement: 管理員 Bearer Token 有效期
後端 SHALL 發行有效期為 7 天的管理員 Bearer Token,過期後需重新登入。
#### Scenario: Token 過期後管理員請求被拒絕
- **WHEN** 管理員使用已過期(超過 7 天)的 token 送出 API 請求
- **THEN** 回傳 HTTP 401token 視為無效
#### Scenario: 有效期內 token 正常通過認證
- **WHEN** 管理員使用未過期的 token 送出 API 請求
- **THEN** 請求正常通過認證,回傳對應資源
@@ -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 的請求計數重置,可再次正常送出登入請求
+21
View File
@@ -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=<value>`)取得 Sanctum token,完成 OAuth 登入後將 token 存入 localStorage 並導航至 `/courses`。token 不得透過 URL query string 傳遞。
#### Scenario: OAuth callback 成功取得 token
- **WHEN** 後端 OAuth callback redirect 到 `/auth/callback#token=<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: 註冊頁
+18 -1
View File
@@ -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 401token 視為無效
#### Scenario: 有效期內 token 正常運作
- **WHEN** 教練使用未過期的 token 送出 API 請求
- **THEN** 請求正常通過認證
+3 -3
View File
@@ -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 () {
+1
View File
@@ -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();
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Tests\TestCase;
class AuthRateLimitTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Cache::flush();
}
// ── member (throttle:5,1) ────────────────────────────────
public function test_member_login_within_limit_is_not_throttled(): void
{
for ($i = 0; $i < 5; $i++) {
$response = $this->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'));
}
}