diff --git a/app/Http/Controllers/Api/V1/Player/PlayerPasswordUpdateController.php b/app/Http/Controllers/Api/V1/Player/PlayerPasswordUpdateController.php index 3a8fbcd..e267aa2 100644 --- a/app/Http/Controllers/Api/V1/Player/PlayerPasswordUpdateController.php +++ b/app/Http/Controllers/Api/V1/Player/PlayerPasswordUpdateController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1\Player; use App\Support\ApiResponse; use App\Services\AuditLogger; use Illuminate\Http\JsonResponse; +use Illuminate\Support\Facades\DB; use App\Http\Controllers\Controller; use App\Services\Player\PlayerPasswordService; use App\Http\Requests\Player\PlayerPasswordUpdateRequest; @@ -19,22 +20,26 @@ final class PlayerPasswordUpdateController extends Controller $player = $request->lotteryPlayer(); abort_if($player === null, 500, 'lottery_player missing'); - $updated = $passwords->change( - $player, - (string) $request->validated('current_password'), - (string) $request->validated('password'), - ); + $updated = DB::transaction(function () use ($passwords, $player, $request) { + $updated = $passwords->change( + $player, + (string) $request->validated('current_password'), + (string) $request->validated('password'), + ); - AuditLogger::recordForPlayer( - $updated, - $request, - 'player_account', - 'change_password', - 'player', - (string) $updated->id, - null, - ['native_token_version' => (int) $updated->native_token_version], - ); + AuditLogger::recordForPlayer( + $updated, + $request, + 'player_account', + 'change_password', + 'player', + (string) $updated->id, + null, + ['native_token_version' => (int) $updated->native_token_version], + ); + + return $updated; + }); return ApiResponse::success(['password_changed' => true], request: $request); } diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 7900848..68d086b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -10,7 +10,6 @@ use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; use App\Services\Wallet\MainSiteWalletGateway; use App\Services\Wallet\HttpMainSiteWalletGateway; -use App\Services\Wallet\StubMainSiteWalletGateway; final class AppServiceProvider extends ServiceProvider { @@ -81,5 +80,15 @@ final class AppServiceProvider extends ServiceProvider return Limit::perMinute(15)->by($request->ip()); }); + + RateLimiter::for('player-password-change', function (Request $request) { + if ((bool) env('LOTTERY_E2E', false)) { + return Limit::none(); + } + + $playerId = $request->lotteryPlayer()?->getKey(); + + return Limit::perMinute(5)->by(($playerId ?? 'guest').'|'.$request->ip()); + }); } } diff --git a/app/Support/InvalidPlatformAgentRoleCleanup.php b/app/Support/InvalidPlatformAgentRoleCleanup.php index 1e7e560..a0e4c9a 100644 --- a/app/Support/InvalidPlatformAgentRoleCleanup.php +++ b/app/Support/InvalidPlatformAgentRoleCleanup.php @@ -23,7 +23,9 @@ final class InvalidPlatformAgentRoleCleanup ->whereNotExists(static function ($query): void { $query->selectRaw('1') ->from('admin_user_agents as cleanup_uag') - ->whereColumn('cleanup_uag.admin_user_id', 'admin_user_site_roles.admin_user_id'); + ->join('agent_nodes as cleanup_node', 'cleanup_node.id', '=', 'cleanup_uag.agent_node_id') + ->whereColumn('cleanup_uag.admin_user_id', 'admin_user_site_roles.admin_user_id') + ->whereColumn('cleanup_node.admin_site_id', 'admin_user_site_roles.site_id'); }) ->delete(); } diff --git a/database/migrations/2026_07_21_150000_remove_cross_site_agent_role_assignments.php b/database/migrations/2026_07_21_150000_remove_cross_site_agent_role_assignments.php new file mode 100644 index 0000000..7f8076d --- /dev/null +++ b/database/migrations/2026_07_21_150000_remove_cross_site_agent_role_assignments.php @@ -0,0 +1,17 @@ +group(function (): void { ->name('api.v1.player.') ->group(function (): void { Route::get('me', MeController::class)->name('me'); - Route::put('password', PlayerPasswordUpdateController::class)->name('password.update'); + Route::put('password', PlayerPasswordUpdateController::class) + ->middleware('throttle:player-password-change') + ->name('password.update'); }); // 钱包 diff --git a/tests/Feature/AdminPlatformRoleBoundaryTest.php b/tests/Feature/AdminPlatformRoleBoundaryTest.php index 468569c..80071c8 100644 --- a/tests/Feature/AdminPlatformRoleBoundaryTest.php +++ b/tests/Feature/AdminPlatformRoleBoundaryTest.php @@ -150,15 +150,51 @@ test('agent creation keeps its agent role and cleanup removes only unbound platf 'granted_at' => now(), ]); - expect(InvalidPlatformAgentRoleCleanup::run())->toBe(1); + $otherSiteId = (int) DB::table('admin_sites')->insertGetId([ + 'code' => 'boundary-other-site', + 'name' => 'Boundary Other Site', + 'is_default' => false, + 'created_at' => now(), + 'updated_at' => now(), + ]); + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $agentUser->id, + 'site_id' => $otherSiteId, + 'role_id' => $agentRole->id, + 'granted_at' => now(), + ]); + $ordinaryRole = AdminRole::query()->create([ + 'slug' => 'cross_site_ordinary_role', + 'name' => 'Cross Site Ordinary Role', + 'scope_type' => AdminRole::SCOPE_SYSTEM, + ]); + DB::table('admin_user_site_roles')->insert([ + 'admin_user_id' => $agentUser->id, + 'site_id' => $otherSiteId, + 'role_id' => $ordinaryRole->id, + 'granted_at' => now(), + ]); + + expect(InvalidPlatformAgentRoleCleanup::run())->toBe(2); expect(DB::table('admin_user_site_roles') ->where('admin_user_id', $illegalUser->id) ->where('role_id', $agentRole->id) ->exists())->toBeFalse(); expect(DB::table('admin_user_site_roles') ->where('admin_user_id', $agentUser->id) + ->where('site_id', $siteId) ->where('role_id', $agentRole->id) ->exists())->toBeTrue(); + expect(DB::table('admin_user_site_roles') + ->where('admin_user_id', $agentUser->id) + ->where('site_id', $otherSiteId) + ->where('role_id', $agentRole->id) + ->exists())->toBeFalse(); + expect(DB::table('admin_user_site_roles') + ->where('admin_user_id', $agentUser->id) + ->where('site_id', $otherSiteId) + ->where('role_id', $ordinaryRole->id) + ->exists())->toBeTrue(); expect(AdminUser::query()->whereKey($illegalUser->id)->exists())->toBeTrue(); }); diff --git a/tests/Feature/PlayerNativeAuthTest.php b/tests/Feature/PlayerNativeAuthTest.php index 85b6877..3f13519 100644 --- a/tests/Feature/PlayerNativeAuthTest.php +++ b/tests/Feature/PlayerNativeAuthTest.php @@ -11,6 +11,7 @@ use Database\Seeders\CurrencySeeder; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Cache; use Database\Seeders\LotterySettingsSeeder; +use Illuminate\Support\Facades\RateLimiter; use App\Services\Player\PlayerNativeAuthService; use Illuminate\Foundation\Testing\RefreshDatabase; @@ -397,6 +398,50 @@ test('native player password change validates current password', function (): vo ->and(Hash::check('old-secret', (string) $player->fresh()->password_hash))->toBeTrue(); }); +test('native player password change is rate limited by player and ip', function (): void { + $site = DB::table('admin_sites')->where('is_default', true)->first(); + $rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id'); + + $player = Player::query()->create([ + 'site_code' => (string) $site->code, + 'agent_node_id' => $rootId, + 'site_player_id' => 'native:password-rate-limit', + 'auth_source' => PlayerAuthSource::LOTTERY_NATIVE, + 'funding_mode' => PlayerFundingMode::CREDIT, + 'username' => 'password_rate_limit_user', + 'password_hash' => Hash::make('old-secret'), + 'default_currency' => 'NPR', + 'status' => 0, + ]); + + $ip = '198.51.100.25'; + $token = app(PlayerNativeAuthService::class)->issueToken($player); + RateLimiter::clear($player->id.'|'.$ip); + + for ($attempt = 0; $attempt < 5; $attempt++) { + $this->withServerVariables(['REMOTE_ADDR' => $ip]) + ->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/player/password', [ + 'current_password' => 'wrong-secret', + 'password' => 'new-secret', + 'password_confirmation' => 'new-secret', + ]) + ->assertStatus(422); + } + + $this->withServerVariables(['REMOTE_ADDR' => $ip]) + ->withHeader('Authorization', 'Bearer '.$token) + ->putJson('/api/v1/player/password', [ + 'current_password' => 'wrong-secret', + 'password' => 'new-secret', + 'password_confirmation' => 'new-secret', + ]) + ->assertStatus(429) + ->assertJsonPath('code', ErrorCode::TooManyRequests->value); + + expect(Hash::check('old-secret', (string) $player->fresh()->password_hash))->toBeTrue(); +}); + test('sso player cannot use native password management', function (): void { $site = DB::table('admin_sites')->where('is_default', true)->first();