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:
@@ -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
@@ -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'");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 401,token 視為無效
|
||||
|
||||
#### Scenario: 有效期內 token 正常通過認證
|
||||
- **WHEN** 管理員使用未過期的 token 送出 API 請求
|
||||
- **THEN** 請求正常通過認證,回傳對應資源
|
||||
+20
@@ -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 401,token 視為無效
|
||||
|
||||
#### 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 次回傳 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 帳號各測試一次完整登入 → 操作 → 登出流程
|
||||
@@ -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** 請求正常通過認證,回傳對應資源
|
||||
|
||||
@@ -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 的請求計數重置,可再次正常送出登入請求
|
||||
@@ -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: 註冊頁
|
||||
|
||||
@@ -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** 請求正常通過認證
|
||||
|
||||
+3
-3
@@ -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 () {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user