feat(admin): 增加玩家密码管理与角色边界

This commit is contained in:
wchino
2026-07-21 21:11:07 +08:00
parent cec2fd7c4f
commit ba54444979
32 changed files with 845 additions and 34 deletions

View File

@@ -0,0 +1,214 @@
<?php
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Models\AgentNode;
use App\Lottery\ErrorCode;
use Illuminate\Support\Facades\DB;
use App\Support\PlatformSystemRoles;
use Illuminate\Support\Facades\Hash;
use App\Services\Agent\AgentNodeService;
use App\Support\InvalidPlatformAgentRoleCleanup;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function makePlatformRoleBoundaryToken(string $username): string
{
$admin = AdminUser::query()->create([
'username' => $username,
'name' => 'Role Boundary Manager',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$role = AdminRole::query()->create([
'slug' => 'boundary_'.$username,
'name' => 'Boundary Manager',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$role->syncLegacyPermissionSlugs(['prd.admin_user.manage', 'prd.admin_role.manage']);
$admin->roles()->sync([
(int) $role->id => [
'site_id' => AdminUser::requireDefaultAdminSiteId(),
'granted_at' => now(),
],
]);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
function createPlatformRoleBoundaryAgent(string $username): AdminUser
{
$siteId = AdminUser::requireDefaultAdminSiteId();
$root = AgentNode::query()
->where('admin_site_id', $siteId)
->where('depth', 0)
->firstOrFail();
$super = AdminUser::query()->create([
'username' => 'super_'.$username,
'name' => 'Super',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($super);
$node = app(AgentNodeService::class)->createChild($super, [
'parent_id' => (int) $root->id,
'code' => 'node-'.$username,
'name' => 'Agent '.$username,
'username' => $username,
'password' => 'secret-strong',
]);
$adminUserId = DB::table('admin_user_agents')
->where('agent_node_id', $node->id)
->where('is_primary', true)
->value('admin_user_id');
return AdminUser::query()->findOrFail((int) $adminUserId);
}
test('permission catalog keeps all roles but only exposes platform assignable roles', function (): void {
$token = makePlatformRoleBoundaryToken('catalog_boundary');
$data = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-user-permission-catalog')
->assertOk()
->json('data');
expect(collect($data['roles'])->pluck('slug')->all())
->toContain(PlatformSystemRoles::SLUG_AGENT, PlatformSystemRoles::SLUG_SUPER_ADMIN);
expect(collect($data['assignable_roles'])->pluck('slug')->all())
->not->toContain(PlatformSystemRoles::SLUG_AGENT, PlatformSystemRoles::SLUG_SUPER_ADMIN);
});
test('platform account create and role update reject the agent role', function (): void {
$token = makePlatformRoleBoundaryToken('assignment_boundary');
$siteId = AdminUser::requireDefaultAdminSiteId();
$this->withHeader('Authorization', 'Bearer '.$token)
->withHeader('X-Locale', 'zh')
->postJson('/api/v1/admin/admin-users', [
'username' => 'illegal_agent_platform',
'nickname' => 'Illegal Agent Platform',
'email' => null,
'password' => 'secret-strong',
'status' => 0,
'admin_site_id' => $siteId,
'role_slugs' => [PlatformSystemRoles::SLUG_AGENT],
])
->assertStatus(422)
->assertJsonPath('code', ErrorCode::ValidationFailed->value)
->assertJsonPath('msg', '平台账号不能分配“代理”角色;请在“代理管理”中创建或绑定代理账号。');
expect(AdminUser::query()->where('username', 'illegal_agent_platform')->exists())->toBeFalse();
$target = AdminUser::query()->create([
'username' => 'platform_role_target',
'name' => 'Platform Role Target',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/admin-users/'.$target->id.'/roles', [
'admin_site_id' => $siteId,
'role_slugs' => [PlatformSystemRoles::SLUG_AGENT],
])
->assertStatus(422)
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
});
test('agent creation keeps its agent role and cleanup removes only unbound platform assignments', function (): void {
$agentUser = createPlatformRoleBoundaryAgent('legal_agent_boundary');
$agentRole = AdminRole::query()
->where('scope_type', AdminRole::SCOPE_SYSTEM)
->where('slug', PlatformSystemRoles::SLUG_AGENT)
->firstOrFail();
$siteId = AdminUser::requireDefaultAdminSiteId();
expect(DB::table('admin_user_agents')->where('admin_user_id', $agentUser->id)->exists())->toBeTrue();
expect(DB::table('admin_user_site_roles')
->where('admin_user_id', $agentUser->id)
->where('role_id', $agentRole->id)
->exists())->toBeTrue();
$illegalUser = AdminUser::query()->create([
'username' => 'unbound_agent_role',
'name' => 'Unbound Agent Role',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $illegalUser->id,
'site_id' => $siteId,
'role_id' => $agentRole->id,
'granted_at' => now(),
]);
expect(InvalidPlatformAgentRoleCleanup::run())->toBe(1);
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('role_id', $agentRole->id)
->exists())->toBeTrue();
expect(AdminUser::query()->whereKey($illegalUser->id)->exists())->toBeTrue();
});
test('role counts classify platform and agent accounts and role filter never leaks agents', function (): void {
$token = makePlatformRoleBoundaryToken('count_boundary');
$siteId = AdminUser::requireDefaultAdminSiteId();
$role = AdminRole::query()->create([
'slug' => 'counted_role',
'name' => 'Counted Role',
'scope_type' => AdminRole::SCOPE_SYSTEM,
]);
$platformUser = AdminUser::query()->create([
'username' => 'counted_platform',
'name' => 'Counted Platform',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
$platformUser->roles()->sync([
(int) $role->id => [
'site_id' => $siteId,
'granted_at' => now(),
],
]);
$agentUser = createPlatformRoleBoundaryAgent('counted_agent');
DB::table('admin_user_site_roles')->insert([
'admin_user_id' => $agentUser->id,
'site_id' => $siteId,
'role_id' => $role->id,
'granted_at' => now(),
]);
$roleRow = collect($this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-roles')
->assertOk()
->json('data.items'))
->firstWhere('slug', 'counted_role');
expect($roleRow)
->not->toBeNull()
->and($roleRow['platform_user_count'])->toBe(1)
->and($roleRow['agent_user_count'])->toBe(1)
->and($roleRow['user_count'])->toBe(2);
$items = $this->withHeader('Authorization', 'Bearer '.$token)
->getJson('/api/v1/admin/admin-users?role_slug=counted_role')
->assertOk()
->json('data.items');
expect(collect($items)->pluck('username')->all())
->toBe(['counted_platform'])
->not->toContain('counted_agent');
});

View File

@@ -1,21 +1,28 @@
<?php
use App\Models\AgentProfile;
use App\Models\Player;
use App\Models\AuditLog;
use App\Models\AdminRole;
use App\Models\AdminUser;
use App\Lottery\ErrorCode;
use App\Models\AgentProfile;
use App\Models\PlayerWallet;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\DB;
use App\Services\Agent\AgentNodeService;
use App\Services\Player\PlayerNativeAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
beforeEach(function (): void {
config([
'lottery.player_auth.native.secret' => 'test-native-jwt-secret-32bytes!!',
'lottery.player_auth.native.ttl_seconds' => 3600,
]);
$this->seed(CurrencySeeder::class);
$this->artisan('lottery:admin-auth-sync')->assertExitCode(0);
});
@@ -251,7 +258,7 @@ test('agent players list can filter direct players without including downline pl
]);
grantSuperAdminRole($super);
$child = app(\App\Services\Agent\AgentNodeService::class)->createChild($super, [
$child = app(AgentNodeService::class)->createChild($super, [
'parent_id' => $rootId,
'code' => 'direct-child',
'name' => 'Direct Child',
@@ -355,7 +362,7 @@ test('admin cannot change credit player default currency', function (): void {
'default_currency' => 'USD',
])
->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
$this->assertDatabaseHas('players', [
'id' => $player->id,
@@ -444,14 +451,14 @@ test('wallet player update rejects credit limit and rebate', function (): void {
'credit_limit' => 1000,
])
->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/admin/players/'.$player->id, [
'rebate_rate' => 1,
])
->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
});
test('native player create rejects chinese username', function (): void {
@@ -467,7 +474,7 @@ test('native player create rejects chinese username', function (): void {
'default_currency' => 'NPR',
])
->assertStatus(422)
->assertJsonPath('code', \App\Lottery\ErrorCode::ValidationFailed->value);
->assertJsonPath('code', ErrorCode::ValidationFailed->value);
});
test('partial rebate update preserves the other rebate field', function (): void {
@@ -555,3 +562,99 @@ test('partial rebate update preserves the other rebate field', function (): void
'extra_rebate_rate' => 0.001,
]);
});
test('admin can reset native player password and invalidate active tokens', function (): void {
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $rootId,
'site_player_id' => 'native-admin-reset',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'native_admin_reset',
'password_hash' => Hash::make('old-secret'),
'default_currency' => 'NPR',
'status' => 0,
]);
$oldToken = app(PlayerNativeAuthService::class)->issueToken($player);
$adminToken = playerManageAdminToken();
$this->withHeader('Authorization', 'Bearer '.$adminToken)
->putJson('/api/v1/admin/players/'.$player->id.'/password', [
'password' => 'reset-secret',
'password_confirmation' => 'reset-secret',
])
->assertOk()
->assertJsonPath('data.password_reset', true)
->assertJsonPath('data.player_id', $player->id);
$player->refresh();
expect(Hash::check('reset-secret', (string) $player->password_hash))->toBeTrue()
->and($player->native_token_version)->toBe(1);
$this->withHeader('Authorization', 'Bearer '.$oldToken)
->getJson('/api/v1/player/me')
->assertStatus(401)
->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value);
$this->assertDatabaseHas('audit_logs', [
'operator_type' => 'admin',
'module_code' => 'player_service',
'action_code' => 'reset',
'target_id' => (string) $player->id,
]);
});
test('admin cannot reset password for sso player', function (): void {
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
$player = Player::query()->create([
'site_code' => $siteCode,
'site_player_id' => 'sso-admin-reset-blocked',
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
'funding_mode' => PlayerFundingMode::WALLET,
'username' => 'sso_admin_reset_blocked',
'default_currency' => 'NPR',
'status' => 0,
]);
$this->withHeader('Authorization', 'Bearer '.playerManageAdminToken())
->putJson('/api/v1/admin/players/'.$player->id.'/password', [
'password' => 'reset-secret',
'password_confirmation' => 'reset-secret',
])
->assertStatus(422)
->assertJsonPath('msg', 'Main-site SSO players do not use a lottery password.');
});
test('player view permission cannot reset native player password', function (): void {
$siteCode = DB::table('admin_sites')->where('is_default', true)->value('code');
$siteCode = is_string($siteCode) && $siteCode !== '' ? $siteCode : 'default_site';
$rootId = (int) DB::table('agent_nodes')->where('depth', 0)->value('id');
$player = Player::query()->create([
'site_code' => $siteCode,
'agent_node_id' => $rootId,
'site_player_id' => 'native-view-reset-blocked',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'native_view_reset_blocked',
'password_hash' => Hash::make('old-secret'),
'default_currency' => 'NPR',
'status' => 0,
]);
$viewToken = playerPermissionAdminToken('player_password_view_only', ['prd.users.view']);
playerPermissionRequest($this, $viewToken)
->putJson('/api/v1/admin/players/'.$player->id.'/password', [
'password' => 'reset-secret',
'password_confirmation' => 'reset-secret',
])
->assertStatus(403);
expect(Hash::check('old-secret', (string) $player->fresh()->password_hash))->toBeTrue();
});

View File

@@ -1,15 +1,18 @@
<?php
use App\Models\Player;
use App\Lottery\ErrorCode;
use Illuminate\Support\Str;
use App\Models\PlayerWallet;
use App\Support\PlayerAuthSource;
use App\Support\PlayerFundingMode;
use Database\Seeders\CurrencySeeder;
use Database\Seeders\LotterySettingsSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Database\Seeders\CurrencySeeder;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Cache;
use Database\Seeders\LotterySettingsSeeder;
use App\Services\Player\PlayerNativeAuthService;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
@@ -257,7 +260,7 @@ test('credit player wallet transfer in is rejected', function (): void {
'status' => 0,
]);
$auth = app(\App\Services\Player\PlayerNativeAuthService::class);
$auth = app(PlayerNativeAuthService::class);
$token = $auth->issueToken($player);
$response = $this->withHeader('Authorization', 'Bearer '.$token)
@@ -293,7 +296,7 @@ test('sso wallet player balance does not use credit when site credit mode on', f
'status' => 0,
]);
\App\Models\PlayerWallet::query()->create([
PlayerWallet::query()->create([
'player_id' => $player->id,
'wallet_type' => 'lottery',
'currency_code' => 'NPR',
@@ -311,3 +314,108 @@ test('sso wallet player balance does not use credit when site credit mode on', f
->assertJsonPath('data.funding_mode', PlayerFundingMode::WALLET)
->assertJsonPath('data.available_balance', 12000);
});
test('native player can change password and previous tokens are invalidated', 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-change',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'password_change_user',
'password_hash' => Hash::make('old-secret'),
'default_currency' => 'NPR',
'status' => 0,
]);
$oldToken = app(PlayerNativeAuthService::class)->issueToken($player);
$this->withHeader('Authorization', 'Bearer '.$oldToken)
->putJson('/api/v1/player/password', [
'current_password' => 'old-secret',
'password' => 'new-secret',
'password_confirmation' => 'new-secret',
])
->assertOk()
->assertJsonPath('data.password_changed', true);
$player->refresh();
expect(Hash::check('new-secret', (string) $player->password_hash))->toBeTrue()
->and($player->native_token_version)->toBe(1);
$this->withHeader('Authorization', 'Bearer '.$oldToken)
->getJson('/api/v1/player/me')
->assertStatus(401)
->assertJsonPath('code', ErrorCode::PlayerTokenInvalid->value);
$this->postJson('/api/v1/player/auth/login', array_merge([
'username' => 'password_change_user',
'password' => 'old-secret',
], playerLoginCaptcha()))
->assertStatus(401)
->assertJsonPath('code', ErrorCode::PlayerCredentialsInvalid->value);
$this->postJson('/api/v1/player/auth/login', array_merge([
'username' => 'password_change_user',
'password' => 'new-secret',
], playerLoginCaptcha()))
->assertOk()
->assertJsonPath('data.player.id', $player->id);
});
test('native player password change validates current password', 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-current',
'auth_source' => PlayerAuthSource::LOTTERY_NATIVE,
'funding_mode' => PlayerFundingMode::CREDIT,
'username' => 'password_current_user',
'password_hash' => Hash::make('old-secret'),
'default_currency' => 'NPR',
'status' => 0,
]);
$token = app(PlayerNativeAuthService::class)->issueToken($player);
$this->withHeader('Authorization', 'Bearer '.$token)
->putJson('/api/v1/player/password', [
'current_password' => 'wrong-secret',
'password' => 'new-secret',
'password_confirmation' => 'new-secret',
])
->assertStatus(422)
->assertJsonPath('msg', 'The current password is incorrect.');
expect($player->fresh()->native_token_version)->toBe(0)
->and(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();
$player = Player::query()->create([
'site_code' => (string) $site->code,
'site_player_id' => 'sso-no-password',
'auth_source' => PlayerAuthSource::MAIN_SITE_SSO,
'funding_mode' => PlayerFundingMode::WALLET,
'username' => 'sso_no_password',
'default_currency' => 'NPR',
'status' => 0,
]);
$this->withHeader('Authorization', 'Bearer dev:'.$player->id)
->putJson('/api/v1/player/password', [
'current_password' => 'old-secret',
'password' => 'new-secret',
'password_confirmation' => 'new-secret',
])
->assertStatus(422)
->assertJsonPath('msg', 'Main-site SSO players do not use a lottery password.');
});