fix(auth): enforce password and agent role boundaries
This commit is contained in:
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api\V1\Player;
|
|||||||
use App\Support\ApiResponse;
|
use App\Support\ApiResponse;
|
||||||
use App\Services\AuditLogger;
|
use App\Services\AuditLogger;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Services\Player\PlayerPasswordService;
|
use App\Services\Player\PlayerPasswordService;
|
||||||
use App\Http\Requests\Player\PlayerPasswordUpdateRequest;
|
use App\Http\Requests\Player\PlayerPasswordUpdateRequest;
|
||||||
@@ -19,22 +20,26 @@ final class PlayerPasswordUpdateController extends Controller
|
|||||||
$player = $request->lotteryPlayer();
|
$player = $request->lotteryPlayer();
|
||||||
abort_if($player === null, 500, 'lottery_player missing');
|
abort_if($player === null, 500, 'lottery_player missing');
|
||||||
|
|
||||||
$updated = $passwords->change(
|
$updated = DB::transaction(function () use ($passwords, $player, $request) {
|
||||||
$player,
|
$updated = $passwords->change(
|
||||||
(string) $request->validated('current_password'),
|
$player,
|
||||||
(string) $request->validated('password'),
|
(string) $request->validated('current_password'),
|
||||||
);
|
(string) $request->validated('password'),
|
||||||
|
);
|
||||||
|
|
||||||
AuditLogger::recordForPlayer(
|
AuditLogger::recordForPlayer(
|
||||||
$updated,
|
$updated,
|
||||||
$request,
|
$request,
|
||||||
'player_account',
|
'player_account',
|
||||||
'change_password',
|
'change_password',
|
||||||
'player',
|
'player',
|
||||||
(string) $updated->id,
|
(string) $updated->id,
|
||||||
null,
|
null,
|
||||||
['native_token_version' => (int) $updated->native_token_version],
|
['native_token_version' => (int) $updated->native_token_version],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return $updated;
|
||||||
|
});
|
||||||
|
|
||||||
return ApiResponse::success(['password_changed' => true], request: $request);
|
return ApiResponse::success(['password_changed' => true], request: $request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ use Illuminate\Cache\RateLimiting\Limit;
|
|||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use App\Services\Wallet\MainSiteWalletGateway;
|
use App\Services\Wallet\MainSiteWalletGateway;
|
||||||
use App\Services\Wallet\HttpMainSiteWalletGateway;
|
use App\Services\Wallet\HttpMainSiteWalletGateway;
|
||||||
use App\Services\Wallet\StubMainSiteWalletGateway;
|
|
||||||
|
|
||||||
final class AppServiceProvider extends ServiceProvider
|
final class AppServiceProvider extends ServiceProvider
|
||||||
{
|
{
|
||||||
@@ -81,5 +80,15 @@ final class AppServiceProvider extends ServiceProvider
|
|||||||
|
|
||||||
return Limit::perMinute(15)->by($request->ip());
|
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());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ final class InvalidPlatformAgentRoleCleanup
|
|||||||
->whereNotExists(static function ($query): void {
|
->whereNotExists(static function ($query): void {
|
||||||
$query->selectRaw('1')
|
$query->selectRaw('1')
|
||||||
->from('admin_user_agents as cleanup_uag')
|
->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();
|
->delete();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Support\InvalidPlatformAgentRoleCleanup;
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
InvalidPlatformAgentRoleCleanup::run();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// 不回滚:恢复跨站非法代理角色会重新扩大权限。
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -24,7 +24,9 @@ Route::middleware('lottery.player')->group(function (): void {
|
|||||||
->name('api.v1.player.')
|
->name('api.v1.player.')
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('me', MeController::class)->name('me');
|
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');
|
||||||
});
|
});
|
||||||
|
|
||||||
// 钱包
|
// 钱包
|
||||||
|
|||||||
@@ -150,15 +150,51 @@ test('agent creation keeps its agent role and cleanup removes only unbound platf
|
|||||||
'granted_at' => now(),
|
'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')
|
expect(DB::table('admin_user_site_roles')
|
||||||
->where('admin_user_id', $illegalUser->id)
|
->where('admin_user_id', $illegalUser->id)
|
||||||
->where('role_id', $agentRole->id)
|
->where('role_id', $agentRole->id)
|
||||||
->exists())->toBeFalse();
|
->exists())->toBeFalse();
|
||||||
expect(DB::table('admin_user_site_roles')
|
expect(DB::table('admin_user_site_roles')
|
||||||
->where('admin_user_id', $agentUser->id)
|
->where('admin_user_id', $agentUser->id)
|
||||||
|
->where('site_id', $siteId)
|
||||||
->where('role_id', $agentRole->id)
|
->where('role_id', $agentRole->id)
|
||||||
->exists())->toBeTrue();
|
->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();
|
expect(AdminUser::query()->whereKey($illegalUser->id)->exists())->toBeTrue();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use Database\Seeders\CurrencySeeder;
|
|||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Database\Seeders\LotterySettingsSeeder;
|
use Database\Seeders\LotterySettingsSeeder;
|
||||||
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use App\Services\Player\PlayerNativeAuthService;
|
use App\Services\Player\PlayerNativeAuthService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
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();
|
->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 {
|
test('sso player cannot use native password management', function (): void {
|
||||||
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
$site = DB::table('admin_sites')->where('is_default', true)->first();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user