test(auth): 新增三角色登入/註冊/登出、P2 帳號鎖定、OAuth state 驗證測試
新增 34 個 Feature 測試(AuthLoginTest 19、AuthLockoutTest 11、 AuthOAuthTest 4)涵蓋 member/provider/admin 的註冊、登入、登出與跨角色 隔離;P2 帳號鎖定的 Fixed Window 計數、companion cache key 與 email 正規化;以及 P2 OAuth state 的 CSRF 驗證與一次性消耗。 同時新增 .gitea/workflows/test.yml 在 push/PR 時於 CI 自動執行測試 (含 GD 等擴充套件安裝),並補上對應的 OpenSpec 變更文件 (proposal/design/spec/tasks,34 項驗收情境與測試 1:1 對應)。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* P2 帳號鎖定機制:同一帳號連續登入失敗達 5 次就鎖定,回傳 423 + locked_until。
|
||||
* 失敗次數記在 cache key `login_failures:{role}:{email}`,
|
||||
* 鎖定到期時間記在 companion key `login_expires_at:{role}:{email}`。
|
||||
* 採 Fixed Window(固定視窗)策略:視窗只在第一次失敗時開啟、寫入到期時間,
|
||||
* 之後的失敗只累加次數、不會重設或延長視窗(區別於會「越測越鎖越久」的 Sliding Window)。
|
||||
*/
|
||||
class AuthLockoutTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
// Cache 不像 DB 有 RefreshDatabase 幫忙重置,上一個測試殘留的失敗計數
|
||||
// 會直接影響下一個測試的鎖定門檻判斷,所以每個測試前都要手動清空。
|
||||
Cache::flush();
|
||||
}
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────
|
||||
|
||||
private function createMember(array $attributes = []): User
|
||||
{
|
||||
return User::factory()->create(array_merge([
|
||||
'role' => 'member',
|
||||
'is_active' => true,
|
||||
'password' => Hash::make('password123'),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
private function createProvider(array $attributes = []): User
|
||||
{
|
||||
return User::factory()->create(array_merge([
|
||||
'role' => 'provider',
|
||||
'is_active' => true,
|
||||
'password' => Hash::make('password123'),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
private function failMemberLogin(string $email, int $times): void
|
||||
{
|
||||
for ($i = 0; $i < $times; $i++) {
|
||||
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'wrong-password']);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fixed Window 計數 ────────────────────────────────────
|
||||
|
||||
public function test_four_failures_do_not_lock_account(): void
|
||||
{
|
||||
$email = 'lockout1@example.com';
|
||||
$this->createMember(['email' => $email]);
|
||||
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'wrong-password'])
|
||||
->assertStatus(401);
|
||||
}
|
||||
|
||||
// 帳號未鎖定:用正確密碼仍可登入(若已鎖定,無論密碼對錯都會回傳 423)
|
||||
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'password123'])
|
||||
->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_fifth_failure_triggers_423_with_locked_until(): void
|
||||
{
|
||||
$email = 'lockout2@example.com';
|
||||
$this->createMember(['email' => $email]);
|
||||
|
||||
$this->failMemberLogin($email, 4);
|
||||
|
||||
$response = $this->postJson('/api/member/login', ['email' => $email, 'password' => 'wrong-password']);
|
||||
|
||||
$response->assertStatus(423)
|
||||
->assertJsonPath('status', false)
|
||||
->assertJsonStructure(['locked_until']);
|
||||
|
||||
$this->assertMatchesRegularExpression(
|
||||
'/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/',
|
||||
$response->json('locked_until'),
|
||||
'locked_until 應為 ISO 8601 格式'
|
||||
);
|
||||
}
|
||||
|
||||
public function test_locked_account_rejects_correct_password(): void
|
||||
{
|
||||
$email = 'lockout3@example.com';
|
||||
$this->createMember(['email' => $email]);
|
||||
|
||||
$this->failMemberLogin($email, 5);
|
||||
|
||||
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'password123'])
|
||||
->assertStatus(423);
|
||||
}
|
||||
|
||||
public function test_nonexistent_email_does_not_increment_counter(): void
|
||||
{
|
||||
// 防的是「用不存在的 email 灌爆 cache」這種資源耗盡攻擊:
|
||||
// 帳號不存在時,系統不該為它建立任何計數 key(因為帳號本來就鎖不了)。
|
||||
$email = 'nosuchaccount@example.com';
|
||||
|
||||
for ($i = 0; $i < 10; $i++) {
|
||||
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'whatever'])
|
||||
->assertStatus(401);
|
||||
}
|
||||
|
||||
$this->assertNull(Cache::get("login_failures:member:{$email}"));
|
||||
}
|
||||
|
||||
public function test_successful_login_clears_failure_counter(): void
|
||||
{
|
||||
// 驗證「失敗計數會在成功登入後歸零」:避免使用者偶爾打錯密碼幾次、
|
||||
// 之後成功登入了,卻因為舊的失敗次數一直累積,下次只是再打錯 1、2 次
|
||||
// 就被鎖定——那會讓鎖定機制變得對正常使用者太敏感。
|
||||
$email = 'lockout4@example.com';
|
||||
$this->createMember(['email' => $email]);
|
||||
|
||||
$this->failMemberLogin($email, 3);
|
||||
|
||||
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'password123'])
|
||||
->assertStatus(200);
|
||||
|
||||
// 計數已從 0 重新累積:再失敗 4 次仍應是 401(未達閾值)
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'wrong-password'])
|
||||
->assertStatus(401);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Email 正規化 ─────────────────────────────────────────
|
||||
//
|
||||
// 如果 cache key 是用「使用者輸入的原始字串」組出來的,攻擊者只要每次
|
||||
// 換個大小寫或多敲一個空白,就能讓系統誤判成不同帳號、產生新的計數 key,
|
||||
// 藉此繞過鎖定機制無限期暴力破解。下面兩個測試確認系統有把 email
|
||||
// 正規化(轉小寫、去頭尾空白)後再記錄,讓變體仍然落在同一個 key 上。
|
||||
|
||||
public function test_email_case_normalization_counts_same_account(): void
|
||||
{
|
||||
$email = 'normalize-case@example.com';
|
||||
$this->createMember(['email' => $email]);
|
||||
|
||||
$this->failMemberLogin($email, 3);
|
||||
|
||||
$this->postJson('/api/member/login', ['email' => 'Normalize-Case@EXAMPLE.COM', 'password' => 'wrong-password'])
|
||||
->assertStatus(401);
|
||||
|
||||
// 累計第 5 次失敗(大小寫變體正規化後計入同一個 cache key)
|
||||
$this->postJson('/api/member/login', ['email' => 'Normalize-Case@EXAMPLE.COM', 'password' => 'wrong-password'])
|
||||
->assertStatus(423);
|
||||
}
|
||||
|
||||
public function test_email_trim_normalization_counts_same_account(): void
|
||||
{
|
||||
$email = 'normalize-trim@example.com';
|
||||
$this->createMember(['email' => $email]);
|
||||
|
||||
$this->failMemberLogin($email, 3);
|
||||
|
||||
$this->postJson('/api/member/login', ['email' => ' normalize-trim@example.com ', 'password' => 'wrong-password'])
|
||||
->assertStatus(401);
|
||||
|
||||
// 累計第 5 次失敗(前後空白變體正規化後計入同一個 cache key)
|
||||
$this->postJson('/api/member/login', ['email' => ' normalize-trim@example.com ', 'password' => 'wrong-password'])
|
||||
->assertStatus(423);
|
||||
}
|
||||
|
||||
// ── 角色隔離 ─────────────────────────────────────────────
|
||||
|
||||
public function test_member_login_attempts_do_not_affect_provider_lockout_for_same_email(): void
|
||||
{
|
||||
// users.email 在 DB 層級全域 unique,無法同時存在同 email 的 member + provider 帳號,
|
||||
// 故改驗證「對錯誤角色 endpoint 嘗試不會汙染或鎖定正確角色帳號」這個對等的安全性質。
|
||||
$email = 'cross-namespace@example.com';
|
||||
$this->createProvider(['email' => $email]);
|
||||
|
||||
// member namespace 中沒有此帳號:失敗不增加計數
|
||||
$this->failMemberLogin($email, 4);
|
||||
$this->assertNull(Cache::get("login_failures:member:{$email}"));
|
||||
|
||||
// provider namespace 中累積 4 次失敗,未達閾值
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$this->postJson('/api/provider/login', ['email' => $email, 'password' => 'wrong-password'])
|
||||
->assertStatus(401);
|
||||
}
|
||||
$this->assertSame(4, Cache::get("login_failures:provider:{$email}"));
|
||||
|
||||
// provider 帳號仍可用正確密碼正常登入(未被鎖定)
|
||||
$this->postJson('/api/provider/login', ['email' => $email, 'password' => 'password123'])
|
||||
->assertStatus(200);
|
||||
}
|
||||
|
||||
// ── 解鎖與 companion key ─────────────────────────────────
|
||||
|
||||
public function test_account_can_login_after_lockout_entry_removed(): void
|
||||
{
|
||||
// 鎖定不是永久的——cache key 帶 TTL,到期後會自動消失。
|
||||
// 測試裡沒辦法真的等待 TTL 倒數,所以用 Cache::forget 直接模擬「TTL 到期、
|
||||
// entry 被清掉」之後的狀態,驗證帳號會自動恢復可登入(不需要任何人手動解鎖)。
|
||||
$email = 'lockout5@example.com';
|
||||
$this->createMember(['email' => $email]);
|
||||
|
||||
$this->failMemberLogin($email, 5);
|
||||
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'password123'])
|
||||
->assertStatus(423);
|
||||
|
||||
// 模擬 TTL 自然過期:移除 lockout cache entry
|
||||
Cache::forget("login_failures:member:{$email}");
|
||||
Cache::forget("login_expires_at:member:{$email}");
|
||||
|
||||
$this->postJson('/api/member/login', ['email' => $email, 'password' => 'password123'])
|
||||
->assertStatus(200);
|
||||
}
|
||||
|
||||
public function test_locked_until_comes_from_companion_cache_key(): void
|
||||
{
|
||||
// 確認 API 回傳給前端的 locked_until,跟後端實際拿來判斷「何時可以解鎖」
|
||||
// 的 companion cache key 是同一個值——避免兩邊各算各的,導致前端顯示的
|
||||
// 倒數時間跟後端真正解鎖的時間對不上。
|
||||
$email = 'lockout6@example.com';
|
||||
$this->createMember(['email' => $email]);
|
||||
|
||||
$this->failMemberLogin($email, 4);
|
||||
|
||||
$response = $this->postJson('/api/member/login', ['email' => $email, 'password' => 'wrong-password']);
|
||||
$response->assertStatus(423);
|
||||
|
||||
$expiresAtFromCache = Cache::get("login_expires_at:member:{$email}");
|
||||
|
||||
$this->assertNotNull($expiresAtFromCache);
|
||||
$this->assertSame($expiresAtFromCache, $response->json('locked_until'));
|
||||
}
|
||||
|
||||
public function test_repeated_failures_do_not_extend_lockout_window(): void
|
||||
{
|
||||
// 這條在區分 Fixed Window 與 Sliding Window 兩種策略:
|
||||
// 如果每次失敗都重新計算 locked_until,攻擊者(或誤觸的使用者)只要持續嘗試,
|
||||
// 鎖定視窗就會被無限往後推遲,等同於永久鎖死。Fixed Window 的作法是
|
||||
// 「視窗在第一次失敗時就固定下來」,之後不論再失敗幾次,到期時間都不變。
|
||||
$email = 'lockout7@example.com';
|
||||
$this->createMember(['email' => $email]);
|
||||
|
||||
$this->failMemberLogin($email, 1);
|
||||
$firstExpiresAt = Cache::get("login_expires_at:member:{$email}");
|
||||
$this->assertNotNull($firstExpiresAt);
|
||||
|
||||
$this->failMemberLogin($email, 3);
|
||||
$expiresAtAfterMoreFailures = Cache::get("login_expires_at:member:{$email}");
|
||||
|
||||
// Fixed Window:companion key 只在第一次失敗時寫入,後續失敗只遞增計數,不重設視窗
|
||||
$this->assertSame($firstExpiresAt, $expiresAtAfterMoreFailures);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthLoginTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────
|
||||
|
||||
private function createMember(array $attributes = []): User
|
||||
{
|
||||
return User::factory()->create(array_merge([
|
||||
'role' => 'member',
|
||||
'is_active' => true,
|
||||
'password' => Hash::make('password123'),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
private function createProvider(array $attributes = []): User
|
||||
{
|
||||
return User::factory()->create(array_merge([
|
||||
'role' => 'provider',
|
||||
'is_active' => true,
|
||||
'password' => Hash::make('password123'),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
private function createAdmin(array $attributes = []): User
|
||||
{
|
||||
return User::factory()->create(array_merge([
|
||||
'role' => 'admin',
|
||||
'is_active' => true,
|
||||
'password' => Hash::make('password123'),
|
||||
], $attributes));
|
||||
}
|
||||
|
||||
// ── member 註冊 ──────────────────────────────────────────
|
||||
|
||||
public function test_member_register_success(): void
|
||||
{
|
||||
$response = $this->postJson('/api/member/register', [
|
||||
'name' => 'Test Member',
|
||||
'email' => 'newmember@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonPath('status', true)
|
||||
->assertJsonStructure(['data' => ['user']]);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'newmember@example.com',
|
||||
'role' => 'member',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_member_register_duplicate_email_returns_422(): void
|
||||
{
|
||||
$this->createMember(['email' => 'dup@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/member/register', [
|
||||
'name' => 'Another Member',
|
||||
'email' => 'dup@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
// ── member 登入/登出 ────────────────────────────────────
|
||||
|
||||
public function test_member_login_success(): void
|
||||
{
|
||||
$this->createMember(['email' => 'member@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/member/login', [
|
||||
'email' => 'member@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('status', true)
|
||||
->assertJsonPath('data.token_type', 'Bearer')
|
||||
->assertJsonStructure(['data' => ['token', 'token_type', 'user']]);
|
||||
}
|
||||
|
||||
public function test_member_login_wrong_password_returns_401(): void
|
||||
{
|
||||
$this->createMember(['email' => 'member@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/member/login', [
|
||||
'email' => 'member@example.com',
|
||||
'password' => 'wrong-password',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonPath('status', false);
|
||||
}
|
||||
|
||||
public function test_member_login_inactive_account_returns_403(): void
|
||||
{
|
||||
$this->createMember(['email' => 'inactive@example.com', 'is_active' => false]);
|
||||
|
||||
$response = $this->postJson('/api/member/login', [
|
||||
'email' => 'inactive@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
// 下面這類「跨角色」測試在防的是:provider/admin 帳號明明存在於 users 表,
|
||||
// 卻拿著正確密碼跑去敲 member 的登入端點——查詢必須有 role 過濾,
|
||||
// 否則系統可能誤判成「帳號密碼正確」而放行,讓使用者用錯誤的角色身分登入。
|
||||
// 回傳 401(而非更精確的「角色不對」訊息)是刻意的:跟「帳號不存在」用同樣的錯誤,
|
||||
// 才不會讓外部攻擊者藉由錯誤訊息差異去探測「這個 email 是不是某個角色的帳號」。
|
||||
public function test_provider_account_cannot_use_member_login(): void
|
||||
{
|
||||
$this->createProvider(['email' => 'cross@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/member/login', [
|
||||
'email' => 'cross@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
// 查詢以 role 過濾,跨角色帳號視同不存在,回傳與「帳號不存在」相同的 401
|
||||
$response->assertStatus(401)
|
||||
->assertJsonPath('status', false);
|
||||
}
|
||||
|
||||
public function test_member_logout_revokes_token(): void
|
||||
{
|
||||
$user = $this->createMember();
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
$tokenId = explode('|', $token, 2)[0];
|
||||
|
||||
$this->withToken($token)->postJson('/api/member/logout')
|
||||
->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseMissing('personal_access_tokens', ['id' => $tokenId]);
|
||||
|
||||
// RequestGuard 會在同一個測試內快取已解析的 user,須先 forgetGuards() 才能讓
|
||||
// 下一個請求重新解析(此時 token 已被刪除,應得 401)
|
||||
Auth::forgetGuards();
|
||||
$this->withToken($token)->getJson('/api/member/profile')
|
||||
->assertStatus(401);
|
||||
}
|
||||
|
||||
// ── provider 註冊 ────────────────────────────────────────
|
||||
|
||||
public function test_provider_register_success(): void
|
||||
{
|
||||
$response = $this->postJson('/api/provider/register', [
|
||||
'name' => 'Test Provider',
|
||||
'email' => 'newprovider@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonPath('status', true)
|
||||
->assertJsonStructure(['data' => ['user']]);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'newprovider@example.com',
|
||||
'role' => 'provider',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_provider_register_duplicate_email_returns_422(): void
|
||||
{
|
||||
$this->createProvider(['email' => 'dup-provider@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/provider/register', [
|
||||
'name' => 'Another Provider',
|
||||
'email' => 'dup-provider@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
// ── provider 登入/登出 ──────────────────────────────────
|
||||
|
||||
public function test_provider_login_success(): void
|
||||
{
|
||||
$this->createProvider(['email' => 'provider@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/provider/login', [
|
||||
'email' => 'provider@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('status', true)
|
||||
->assertJsonPath('data.token_type', 'Bearer')
|
||||
->assertJsonStructure(['data' => ['token', 'token_type', 'user']]);
|
||||
}
|
||||
|
||||
public function test_provider_login_inactive_account_returns_403(): void
|
||||
{
|
||||
$this->createProvider(['email' => 'inactive-provider@example.com', 'is_active' => false]);
|
||||
|
||||
$response = $this->postJson('/api/provider/login', [
|
||||
'email' => 'inactive-provider@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_member_account_cannot_use_provider_login(): void
|
||||
{
|
||||
$this->createMember(['email' => 'cross2@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/provider/login', [
|
||||
'email' => 'cross2@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonPath('status', false);
|
||||
}
|
||||
|
||||
public function test_provider_logout_revokes_token(): void
|
||||
{
|
||||
$user = $this->createProvider();
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
$tokenId = explode('|', $token, 2)[0];
|
||||
|
||||
$this->withToken($token)->postJson('/api/provider/logout')
|
||||
->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseMissing('personal_access_tokens', ['id' => $tokenId]);
|
||||
|
||||
Auth::forgetGuards();
|
||||
$this->withToken($token)->getJson('/api/provider/profile')
|
||||
->assertStatus(401);
|
||||
}
|
||||
|
||||
// ── admin 註冊 ───────────────────────────────────────────
|
||||
|
||||
public function test_admin_register_success(): void
|
||||
{
|
||||
$response = $this->postJson('/api/admin/register', [
|
||||
'name' => 'Test Admin',
|
||||
'email' => 'newadmin@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(201)
|
||||
->assertJsonPath('status', true)
|
||||
->assertJsonStructure(['data' => ['user']]);
|
||||
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'newadmin@example.com',
|
||||
'role' => 'admin',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_admin_register_duplicate_email_returns_422(): void
|
||||
{
|
||||
$this->createAdmin(['email' => 'dup-admin@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/admin/register', [
|
||||
'name' => 'Another Admin',
|
||||
'email' => 'dup-admin@example.com',
|
||||
'password' => 'password123',
|
||||
'password_confirmation' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(422);
|
||||
}
|
||||
|
||||
// ── admin 登入/登出 ─────────────────────────────────────
|
||||
|
||||
public function test_admin_login_success(): void
|
||||
{
|
||||
$this->createAdmin(['email' => 'admin@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/admin/login', [
|
||||
'email' => 'admin@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(200)
|
||||
->assertJsonPath('status', true)
|
||||
->assertJsonPath('data.token_type', 'Bearer')
|
||||
->assertJsonStructure(['data' => ['token', 'token_type', 'user']]);
|
||||
}
|
||||
|
||||
public function test_member_cannot_use_admin_login(): void
|
||||
{
|
||||
$this->createMember(['email' => 'cross3@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/admin/login', [
|
||||
'email' => 'cross3@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonPath('status', false);
|
||||
}
|
||||
|
||||
public function test_provider_cannot_use_admin_login(): void
|
||||
{
|
||||
$this->createProvider(['email' => 'cross4@example.com']);
|
||||
|
||||
$response = $this->postJson('/api/admin/login', [
|
||||
'email' => 'cross4@example.com',
|
||||
'password' => 'password123',
|
||||
]);
|
||||
|
||||
$response->assertStatus(401)
|
||||
->assertJsonPath('status', false);
|
||||
}
|
||||
|
||||
public function test_admin_logout_revokes_token(): void
|
||||
{
|
||||
$user = $this->createAdmin();
|
||||
$token = $user->createToken('auth_token')->plainTextToken;
|
||||
$tokenId = explode('|', $token, 2)[0];
|
||||
|
||||
$this->withToken($token)->postJson('/api/admin/logout')
|
||||
->assertStatus(200);
|
||||
|
||||
$this->assertDatabaseMissing('personal_access_tokens', ['id' => $tokenId]);
|
||||
|
||||
Auth::forgetGuards();
|
||||
$this->withToken($token)->getJson('/api/admin/profile')
|
||||
->assertStatus(401);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Socialite\Facades\Socialite;
|
||||
use Laravel\Socialite\Two\User as SocialiteUser;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AuthOAuthTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
// ── helpers ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 組一個假的 Google 使用者物件,餵給 mock 過的 Socialite。
|
||||
* 測試不應該真的打 Google API(會很慢、不穩定、還要真帳密),
|
||||
* 所以用 Mockery 把 Socialite 換成假的,並回傳這個假資料。
|
||||
*/
|
||||
private function fakeGoogleUser(string $id, string $email, string $name = 'Google Test User'): SocialiteUser
|
||||
{
|
||||
$user = new SocialiteUser();
|
||||
$user->id = $id;
|
||||
$user->name = $name;
|
||||
$user->email = $email;
|
||||
$user->token = 'fake-access-token-' . $id;
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
// ── state 缺失 / 不符 ────────────────────────────────────
|
||||
//
|
||||
// 「state」是 OAuth 流程裡用來防 CSRF 的一次性亂碼:後端發起登入時把它存進
|
||||
// session,Google callback 回來時要原樣帶回,兩邊對得上才繼續,對不上就視為
|
||||
// 攻擊/偽造請求,直接擋下、不跟 Google 要使用者資料。
|
||||
// 下面兩個測試就是在驗證「對不上的時候真的有擋下」這個安全底線。
|
||||
|
||||
public function test_oauth_callback_without_state_redirects_to_error(): void
|
||||
{
|
||||
// shouldReceive('driver')->never():斷言 Socialite::driver(...) 整個流程
|
||||
// 都「不應該被呼叫」。這是防回歸的關鍵——萬一未來有人改壞了驗證順序,
|
||||
// 導致系統在 state 比對「之前」就先去問 Google 拿使用者資料,這裡就會爆紅。
|
||||
Socialite::shouldReceive('driver')->never();
|
||||
|
||||
$response = $this->get('/api/auth/google/callback');
|
||||
|
||||
$response->assertRedirectContains('error=oauth_failed');
|
||||
}
|
||||
|
||||
public function test_oauth_callback_with_wrong_state_redirects_to_error(): void
|
||||
{
|
||||
Socialite::shouldReceive('driver')->never();
|
||||
|
||||
$response = $this->withSession(['oauth_state' => 'correct-state'])
|
||||
->get('/api/auth/google/callback?state=wrong-state');
|
||||
|
||||
$response->assertRedirectContains('error=oauth_failed');
|
||||
}
|
||||
|
||||
// ── state 正確 ───────────────────────────────────────────
|
||||
|
||||
public function test_oauth_callback_with_correct_state_completes_login(): void
|
||||
{
|
||||
// shouldReceive('driver->user'):模擬「Socialite::driver('google')->user()」這條
|
||||
// 鏈式呼叫,讓它回傳上面準備好的假 Google 使用者,藉此驗證「state 對得上時,
|
||||
// 系統會繼續走完登入流程並帶著 token 導回前端」。
|
||||
Socialite::shouldReceive('driver->user')
|
||||
->andReturn($this->fakeGoogleUser('google-state-ok', 'state-ok@example.com'));
|
||||
|
||||
$response = $this->withSession(['oauth_state' => 'matching-state'])
|
||||
->get('/api/auth/google/callback?state=matching-state');
|
||||
|
||||
$response->assertRedirectContains('#token=');
|
||||
}
|
||||
|
||||
public function test_oauth_state_is_consumed_after_successful_callback(): void
|
||||
{
|
||||
// 驗證「state 是一次性的」:用過一次就該從 session 移除,
|
||||
// 避免同一組 state 被重放(replay attack)拿來再次完成登入。
|
||||
Socialite::shouldReceive('driver->user')
|
||||
->andReturn($this->fakeGoogleUser('google-one-time', 'one-time@example.com'));
|
||||
|
||||
// 第一次:state 正確,完成登入;session 中的 oauth_state 在驗證時被 pull() 消耗
|
||||
$this->withSession(['oauth_state' => 'one-time-state'])
|
||||
->get('/api/auth/google/callback?state=one-time-state')
|
||||
->assertRedirectContains('#token=');
|
||||
|
||||
// 第二次:再帶相同 state 呼叫,但 session 已無 oauth_state,視同 state 缺失
|
||||
$response = $this->get('/api/auth/google/callback?state=one-time-state');
|
||||
|
||||
$response->assertRedirectContains('error=oauth_failed');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user