From ca023c23f8dd8723a9e3700f45bd79eba1051d30 Mon Sep 17 00:00:00 2001 From: Boen_Shi Date: Thu, 28 May 2026 10:59:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=94=A8?= =?UTF-8?q?=E6=88=B7=E7=AE=A1=E7=90=86):=20=E5=AE=8C=E6=88=90=E4=BA=86?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=99=A8=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E7=9A=84fastapi=E5=B0=81=E8=A3=85=E7=9A=84=E4=BF=AE=E6=94=B9?= =?UTF-8?q?=E5=92=8C=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Api/ServerResourceController.php | 57 +++- .../Api/ServerSystemUserController.php | 311 ++++++++++++++++++ app/Http/Controllers/Api/UserController.php | 118 ++++++- .../Requests/StoreServerResourceRequest.php | 2 + app/Http/Requests/StoreUserRequest.php | 7 + .../Requests/UpdateServerResourceRequest.php | 2 + app/Http/Requests/UpdateUserRequest.php | 7 + app/Models/ServerResource.php | 12 + app/Models/ServerUserBinding.php | 40 +++ app/Models/User.php | 5 + app/Services/ServerUserManagementClient.php | 139 ++++++++ bootstrap/app.php | 7 + config/services.php | 6 + ...nagement_api_to_server_resources_table.php | 23 ++ ...0002_create_server_user_bindings_table.php | 30 ++ .../ServerSystemUserManagementTest.php | 167 ++++++++++ user_manage_api/logs/user_manage_api.jsonl | 13 + user_manage_api/logs/user_manage_api.log | 13 + 18 files changed, 949 insertions(+), 10 deletions(-) create mode 100644 app/Http/Controllers/Api/ServerSystemUserController.php create mode 100644 app/Models/ServerUserBinding.php create mode 100644 app/Services/ServerUserManagementClient.php create mode 100644 database/migrations/2026_05_27_000001_add_user_management_api_to_server_resources_table.php create mode 100644 database/migrations/2026_05_27_000002_create_server_user_bindings_table.php create mode 100644 tests/Feature/ServerSystemUserManagementTest.php create mode 100644 user_manage_api/logs/user_manage_api.jsonl create mode 100644 user_manage_api/logs/user_manage_api.log diff --git a/app/Http/Controllers/Api/ServerResourceController.php b/app/Http/Controllers/Api/ServerResourceController.php index 505edc6..d5bf05a 100644 --- a/app/Http/Controllers/Api/ServerResourceController.php +++ b/app/Http/Controllers/Api/ServerResourceController.php @@ -9,6 +9,7 @@ use App\Models\AccessLog; use App\Models\BastionAccount; use App\Models\OpsProtocol; use App\Models\ServerResource; +use App\Models\ServerUserBinding; use App\Models\User; use App\Models\UserServerPermission; use hg\apidoc\annotation as Apidoc; @@ -66,7 +67,10 @@ class ServerResourceController extends Controller }); } - return response()->json(['code' => 0, 'message' => 'ok', 'data' => $query->paginate(500)]); + $resources = $query->paginate(500); + $this->appendServerUserManagementMeta($resources->getCollection(), $user); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $resources]); } private function resolveResourceIdsFromPermissions(User $user): Collection @@ -125,6 +129,8 @@ class ServerResourceController extends Controller if ($isResource) { $parent = ServerResource::query()->findOrFail($targetParentId); $data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip; + $data['user_api_base_url'] = null; + $data['user_api_token'] = null; $data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), null)]; $data['display_name'] = $data['display_name'] ?? $data['name']; $data['asset_id'] = $parent->asset_id; @@ -144,6 +150,9 @@ class ServerResourceController extends Controller $data['protocols'] = []; $data['account_id'] = null; $data['display_name'] = $data['display_name'] ?? $data['name']; + if (trim((string) ($data['user_api_token'] ?? '')) === '') { + $data['user_api_token'] = null; + } } unset($data['protocol']); @@ -158,7 +167,10 @@ class ServerResourceController extends Controller #[Apidoc\Title('资源详情'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}')] public function show(int $id): JsonResponse { - return response()->json(['code' => 0, 'message' => 'ok', 'data' => ServerResource::query()->with('parent')->findOrFail($id)]); + $resource = ServerResource::query()->with('parent')->findOrFail($id); + $this->appendServerUserManagementMeta(collect([$resource]), auth('api')->user()); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $resource]); } #[Apidoc\Title('更新资源'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}')] @@ -175,6 +187,8 @@ class ServerResourceController extends Controller if ($isResource) { $parent = ServerResource::query()->findOrFail($targetParentId); $data['internal_ip'] = $data['internal_ip'] ?? $parent->internal_ip; + $data['user_api_base_url'] = null; + $data['user_api_token'] = null; $data['protocols'] = [$this->resolveResourceProtocol((string) ($data['protocol'] ?? ''), (string) ($server->protocols[0] ?? ''))]; $data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']); $data['asset_id'] = $parent->asset_id; @@ -194,6 +208,9 @@ class ServerResourceController extends Controller $data['protocols'] = []; $data['account_id'] = null; $data['display_name'] = $data['display_name'] ?? ($server->display_name ?: $data['name']); + if (trim((string) ($data['user_api_token'] ?? '')) === '') { + unset($data['user_api_token']); + } } unset($data['protocol']); @@ -493,6 +510,7 @@ class ServerResourceController extends Controller 'protocol' => $protocol, 'resource_id' => $resource->id, 'resource_name' => $resource->display_name ?: $resource->name, + 'server_username' => $this->boundServerUsername($user, $resource), 'bastion_account_id' => $bastionAccount->id, 'client_type' => (string) data_get($result, 'data.client_type', ''), 'response' => $result, @@ -502,6 +520,41 @@ class ServerResourceController extends Controller ]); } + private function appendServerUserManagementMeta(Collection $resources, ?User $user): void + { + $serverIds = $resources + ->map(fn (ServerResource $resource): int => (int) ($resource->parent_id ?: $resource->id)) + ->unique() + ->values(); + + $bindings = collect(); + if ($user && $serverIds->isNotEmpty()) { + $bindings = ServerUserBinding::query() + ->where('user_id', $user->id) + ->whereIn('server_resource_id', $serverIds) + ->get() + ->keyBy('server_resource_id'); + } + + foreach ($resources as $resource) { + $server = $resource->parent_id ? $resource->parent : $resource; + $serverId = (int) ($server?->id ?: $resource->id); + $binding = $bindings->get($serverId); + $resource->setAttribute('user_api_configured', trim((string) ($server?->user_api_base_url ?? $resource->user_api_base_url ?? '')) !== ''); + $resource->setAttribute('server_username', $binding?->username); + } + } + + private function boundServerUsername(User $user, ServerResource $resource): ?string + { + $serverId = (int) ($resource->parent_id ?: $resource->id); + + return ServerUserBinding::query() + ->where('user_id', $user->id) + ->where('server_resource_id', $serverId) + ->value('username'); + } + private function extractSsoTokenFromUrl(string $ssoUrl): ?string { if (! str_starts_with($ssoUrl, 'sso://')) { diff --git a/app/Http/Controllers/Api/ServerSystemUserController.php b/app/Http/Controllers/Api/ServerSystemUserController.php new file mode 100644 index 0000000..1e95c29 --- /dev/null +++ b/app/Http/Controllers/Api/ServerSystemUserController.php @@ -0,0 +1,311 @@ +middleware('auth:api'); + $this->middleware('permission:platform.servers.manage,api')->except(['updateBoundPassword']); + } + + #[Apidoc\Title('修改当前用户绑定服务器账号密码'), Apidoc\Method('PATCH'), Apidoc\Url('/servers/{id}/bound-system-user/password')] + public function updateBoundPassword(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'password' => ['required', 'string', 'min:6', 'max:255'], + ]); + + $server = $this->server($id); + $resource = ServerResource::query()->with('parent')->findOrFail($id); + + /** @var User|null $user */ + $user = auth('api')->user(); + if (! $user || ! $this->canUpdateBoundPassword($user, $resource)) { + return response()->json([ + 'code' => 403, + 'message' => '无权限修改该服务器账号密码', + 'data' => null, + ], 403); + } + + $binding = ServerUserBinding::query() + ->where('user_id', $user->id) + ->where('server_resource_id', $server->id) + ->first(); + + if (! $binding || trim((string) $binding->username) === '') { + throw ValidationException::withMessages([ + 'server' => ['当前用户未绑定该服务器账号。'], + ]); + } + + $result = $this->client->updatePassword($server, (string) $binding->username, $this->linuxPasswordHash((string) $validated['password'])); + $binding->update([ + 'remote_exists' => true, + 'last_synced_at' => now(), + ]); + + $this->auditLog($request, 'server_bound_user_password_update', [ + 'server_resource_id' => $server->id, + 'metadata' => ['username' => $binding->username], + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]); + } + + #[Apidoc\Title('服务器用户管理元数据'), Apidoc\Method('GET'), Apidoc\Url('/servers/{id}/system-users/meta')] + public function meta(int $id): JsonResponse + { + $server = $this->server($id); + $users = $this->client->users($server); + $groups = $this->client->groups($server); + $userGroups = []; + + foreach ($users as $user) { + $username = (string) ($user['username'] ?? ''); + if ($username === '') { + continue; + } + + try { + $userGroups[$username] = $this->client->userGroups($server, $username)['groups'] ?? []; + } catch (ValidationException) { + $userGroups[$username] = []; + } + } + + $bindings = ServerUserBinding::query() + ->with('user:id,nickname,email,phone') + ->where('server_resource_id', $server->id) + ->orderBy('username') + ->get(); + + return response()->json([ + 'code' => 0, + 'message' => 'ok', + 'data' => [ + 'server' => $server, + 'users' => $users, + 'groups' => $groups, + 'user_groups' => $userGroups, + 'bindings' => $bindings, + ], + ]); + } + + #[Apidoc\Title('创建服务器用户'), Apidoc\Method('POST'), Apidoc\Url('/servers/{id}/system-users')] + public function storeUser(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'password_hash' => ['nullable', 'string', 'min:10', 'max:512'], + 'password' => ['nullable', 'string', 'min:6', 'max:255'], + 'primary_group' => ['nullable', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'groups' => ['sometimes', 'array'], + 'groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'shell' => ['nullable', 'string', 'max:128'], + 'home_dir' => ['nullable', 'string', 'max:255'], + 'user_id' => ['nullable', 'integer', 'exists:users,id'], + ]); + + $server = $this->server($id); + $passwordHash = (string) ($validated['password_hash'] ?? ''); + if ($passwordHash === '') { + $passwordHash = $this->linuxPasswordHash((string) ($validated['password'] ?? '')); + } + + $payload = [ + 'username' => $validated['username'], + 'password_hash' => $passwordHash, + 'primary_group' => $validated['primary_group'] ?? null, + 'groups' => array_values(array_unique($validated['groups'] ?? [])), + 'shell' => $validated['shell'] ?? '/bin/bash', + 'home_dir' => $validated['home_dir'] ?? null, + ]; + + $result = $this->client->createUser($server, $payload); + $this->upsertBinding((int) ($validated['user_id'] ?? 0), $server, (string) $validated['username'], true); + $this->auditLog($request, 'server_system_user_create', [ + 'server_resource_id' => $server->id, + 'metadata' => ['username' => $validated['username']], + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result], 201); + } + + #[Apidoc\Title('删除服务器用户'), Apidoc\Method('DELETE'), Apidoc\Url('/servers/{id}/system-users/{username}')] + public function destroyUser(Request $request, int $id, string $username): JsonResponse + { + $server = $this->server($id); + $result = $this->client->deleteUser($server, $username); + ServerUserBinding::query() + ->where('server_resource_id', $server->id) + ->where('username', $username) + ->update(['remote_exists' => false, 'last_synced_at' => now()]); + $this->auditLog($request, 'server_system_user_delete', [ + 'server_resource_id' => $server->id, + 'metadata' => ['username' => $username], + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]); + } + + #[Apidoc\Title('修改服务器用户密码'), Apidoc\Method('PATCH'), Apidoc\Url('/servers/{id}/system-users/{username}/password')] + public function updatePassword(Request $request, int $id, string $username): JsonResponse + { + $validated = $request->validate([ + 'password_hash' => ['nullable', 'string', 'min:10', 'max:512'], + 'password' => ['nullable', 'string', 'min:6', 'max:255'], + ]); + + $server = $this->server($id); + $passwordHash = (string) ($validated['password_hash'] ?? ''); + if ($passwordHash === '') { + $passwordHash = $this->linuxPasswordHash((string) ($validated['password'] ?? '')); + } + + $result = $this->client->updatePassword($server, $username, $passwordHash); + $this->auditLog($request, 'server_system_user_password_update', [ + 'server_resource_id' => $server->id, + 'metadata' => ['username' => $username], + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]); + } + + #[Apidoc\Title('创建服务器用户组'), Apidoc\Method('POST'), Apidoc\Url('/servers/{id}/system-groups')] + public function storeGroup(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'groupname' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + ]); + + $server = $this->server($id); + $result = $this->client->createGroup($server, (string) $validated['groupname']); + $this->auditLog($request, 'server_system_group_create', [ + 'server_resource_id' => $server->id, + 'metadata' => ['groupname' => $validated['groupname']], + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result], 201); + } + + #[Apidoc\Title('删除服务器用户组'), Apidoc\Method('DELETE'), Apidoc\Url('/servers/{id}/system-groups/{groupname}')] + public function destroyGroup(Request $request, int $id, string $groupname): JsonResponse + { + $server = $this->server($id); + $result = $this->client->deleteGroup($server, $groupname); + $this->auditLog($request, 'server_system_group_delete', [ + 'server_resource_id' => $server->id, + 'metadata' => ['groupname' => $groupname], + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]); + } + + #[Apidoc\Title('同步服务器用户组'), Apidoc\Method('PUT'), Apidoc\Url('/servers/{id}/system-users/{username}/groups')] + public function syncUserGroups(Request $request, int $id, string $username): JsonResponse + { + $validated = $request->validate([ + 'groups' => ['present', 'array'], + 'groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'mode' => ['nullable', 'string', 'in:append,replace'], + ]); + + $server = $this->server($id); + $result = $this->client->syncUserGroups( + $server, + $username, + array_values(array_unique($validated['groups'])), + (string) ($validated['mode'] ?? 'replace'), + ); + $this->auditLog($request, 'server_system_user_groups_update', [ + 'server_resource_id' => $server->id, + 'metadata' => ['username' => $username, 'groups' => $validated['groups']], + ]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $result]); + } + + private function server(int $id): ServerResource + { + $server = ServerResource::query()->with('parent')->findOrFail($id); + + return $server->parent_id ? $server->parent()->firstOrFail() : $server; + } + + private function upsertBinding(int $userId, ServerResource $server, string $username, bool $remoteExists): void + { + if ($userId <= 0) { + return; + } + + ServerUserBinding::query()->updateOrCreate( + ['user_id' => $userId, 'server_resource_id' => $server->id], + ['username' => $username, 'remote_exists' => $remoteExists, 'last_synced_at' => now()], + ); + } + + private function canUpdateBoundPassword(User $user, ServerResource $resource): bool + { + if ($user->can('platform.servers.view')) { + return true; + } + + $serverId = (int) ($resource->parent_id ?: $resource->id); + $hasBinding = ServerUserBinding::query() + ->where('user_id', $user->id) + ->where('server_resource_id', $serverId) + ->exists(); + + if (! $hasBinding) { + return false; + } + + if (! $resource->parent_id) { + return true; + } + + $pivot = $resource->users() + ->where('users.id', $user->id) + ->first()?->pivot; + + if ($pivot && (bool) ($pivot->can_ssh || $pivot->can_sftp || $pivot->can_rdp)) { + return true; + } + + return $user->can('resource.servers.use'); + } + + private function linuxPasswordHash(string $password): string + { + if ($password === '') { + throw ValidationException::withMessages([ + 'password' => ['请输入服务器账号密码。'], + ]); + } + + $salt = substr(strtr(base64_encode(random_bytes(12)), '+', '.'), 0, 16); + $hash = crypt($password, '$6$rounds=5000$'.$salt.'$'); + if (! is_string($hash) || strlen($hash) < 10) { + throw ValidationException::withMessages([ + 'password' => ['服务器账号密码 Hash 生成失败。'], + ]); + } + + return $hash; + } +} diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 10caf48..d9a1aa9 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -6,8 +6,10 @@ use App\Http\Controllers\Controller; use App\Http\Requests\StoreUserRequest; use App\Http\Requests\UpdateUserRequest; use App\Models\ServerResource; +use App\Models\ServerUserBinding; use App\Models\User; use App\Models\UserServerPermission; +use App\Services\ServerUserManagementClient; use hg\apidoc\annotation as Apidoc; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -20,11 +22,11 @@ use Spatie\Permission\Models\Role; #[Apidoc\Title('用户管理')] class UserController extends Controller { - public function __construct() + public function __construct(private ServerUserManagementClient $serverUserClient) { $this->middleware('auth:api'); $this->middleware('permission:platform.users.view,api')->only(['index', 'show']); - $this->middleware('permission:platform.users.manage,api')->only(['store', 'update', 'destroy', 'syncPermissions', 'syncBatchAssignments', 'import', 'importTemplate']); + $this->middleware('permission:platform.users.manage,api')->only(['store', 'update', 'destroy', 'syncPermissions', 'syncBatchAssignments', 'syncServerBindings', 'import', 'importTemplate']); } #[Apidoc\Title('用户列表'), Apidoc\Method('GET'), Apidoc\Url('/users')] @@ -41,7 +43,7 @@ class UserController extends Controller $perPage = (int) ($validated['per_page'] ?? 20); $users = User::query() - ->with('roles') + ->with(['roles', 'serverUserBindings.server']) ->orderBy($sortBy, $sortOrder) ->paginate($perPage); @@ -51,21 +53,26 @@ class UserController extends Controller #[Apidoc\Title('创建用户'), Apidoc\Method('POST'), Apidoc\Url('/users')] public function store(StoreUserRequest $request): JsonResponse { - $user = User::query()->create($request->validated()); + $data = $request->safe()->except(['role_ids', 'server_bindings']); + $user = User::query()->create($data); if ($request->filled('role_ids')) { $user->syncRoles($request->validated('role_ids')); } + if ($request->filled('server_bindings')) { + $this->syncServerBindingsPayload($user, $request->validated('server_bindings'), (string) $request->validated('password')); + } + $this->auditLog($request, 'user_create', ['metadata' => ['target_user_id' => $user->id]]); - return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load('roles')], 201); + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])], 201); } #[Apidoc\Title('用户详情'), Apidoc\Method('GET'), Apidoc\Url('/users/{id}')] public function show(int $id): JsonResponse { - $user = User::query()->with(['roles', 'permissions', 'serverResources'])->findOrFail($id); + $user = User::query()->with(['roles', 'permissions', 'serverResources', 'serverUserBindings.server'])->findOrFail($id); return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user]); } @@ -78,7 +85,7 @@ class UserController extends Controller } $user = User::query()->findOrFail($id); - $user->fill($request->safe()->except(['role_ids'])); + $user->fill($request->safe()->except(['role_ids', 'server_bindings'])); if ($request->filled('password')) { $user->password = $request->validated('password'); @@ -90,9 +97,33 @@ class UserController extends Controller $user->syncRoles($request->validated('role_ids')); } + if ($request->has('server_bindings')) { + $this->syncServerBindingsPayload($user, $request->validated('server_bindings'), (string) ($request->validated('password') ?? '')); + } + $this->auditLog($request, 'user_update', ['metadata' => ['target_user_id' => $user->id]]); - return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load('roles')]); + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])]); + } + + #[Apidoc\Title('同步用户服务器账号绑定'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}/server-bindings')] + public function syncServerBindings(Request $request, int $id): JsonResponse + { + $validated = $request->validate([ + 'server_bindings' => ['present', 'array'], + 'server_bindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'], + 'server_bindings.*.username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'server_bindings.*.create_remote' => ['sometimes', 'boolean'], + 'server_bindings.*.password' => ['nullable', 'string', 'min:6', 'max:255'], + 'server_bindings.*.groups' => ['sometimes', 'array'], + 'server_bindings.*.groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + ]); + + $user = User::query()->findOrFail($id); + $this->syncServerBindingsPayload($user, $validated['server_bindings'], ''); + $this->auditLog($request, 'user_server_bindings_update', ['metadata' => ['target_user_id' => $user->id]]); + + return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'serverUserBindings.server'])]); } #[Apidoc\Title('同步用户权限'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}/permissions')] @@ -502,6 +533,77 @@ class UserController extends Controller $zip->close(); } + private function syncServerBindingsPayload(User $user, array $bindings, string $defaultPassword): void + { + $seenServerIds = []; + + foreach ($bindings as $binding) { + $server = ServerResource::query()->with('parent')->findOrFail((int) $binding['server_resource_id']); + $targetServer = $server->parent_id ? $server->parent()->firstOrFail() : $server; + $serverId = (int) $targetServer->id; + $username = (string) $binding['username']; + $seenServerIds[] = $serverId; + $existingBinding = ServerUserBinding::query() + ->where('user_id', $user->id) + ->where('server_resource_id', $serverId) + ->first(); + $remoteExists = (bool) ($existingBinding?->remote_exists ?? false); + + if ((bool) ($binding['create_remote'] ?? false)) { + $password = (string) ($binding['password'] ?? $defaultPassword); + $this->serverUserClient->createUser($targetServer, [ + 'username' => $username, + 'password_hash' => $this->linuxPasswordHash($password), + 'primary_group' => null, + 'groups' => array_values(array_unique($binding['groups'] ?? [])), + 'shell' => '/bin/bash', + 'home_dir' => null, + ]); + $remoteExists = true; + } + + ServerUserBinding::query()->updateOrCreate( + [ + 'user_id' => $user->id, + 'server_resource_id' => $serverId, + ], + [ + 'username' => $username, + 'remote_exists' => $remoteExists, + 'last_synced_at' => $remoteExists ? now() : null, + 'metadata' => [ + 'groups' => array_values(array_unique($binding['groups'] ?? [])), + ], + ], + ); + } + + ServerUserBinding::query() + ->where('user_id', $user->id) + ->when(! empty($seenServerIds), fn ($query) => $query->whereNotIn('server_resource_id', array_unique($seenServerIds))) + ->when(empty($seenServerIds), fn ($query) => $query) + ->delete(); + } + + private function linuxPasswordHash(string $password): string + { + if ($password === '') { + throw ValidationException::withMessages([ + 'password' => ['请输入服务器账号密码。'], + ]); + } + + $salt = substr(strtr(base64_encode(random_bytes(12)), '+', '.'), 0, 16); + $hash = crypt($password, '$6$rounds=5000$'.$salt.'$'); + if (! is_string($hash) || strlen($hash) < 10) { + throw ValidationException::withMessages([ + 'password' => ['服务器账号密码 Hash 生成失败。'], + ]); + } + + return $hash; + } + private function syncServerResourcePermissionsByDirectPermissions(User $user, array $permissionIds): void { $resourcePermissions = Permission::query() diff --git a/app/Http/Requests/StoreServerResourceRequest.php b/app/Http/Requests/StoreServerResourceRequest.php index 36cfc42..8970ac6 100644 --- a/app/Http/Requests/StoreServerResourceRequest.php +++ b/app/Http/Requests/StoreServerResourceRequest.php @@ -18,6 +18,8 @@ class StoreServerResourceRequest extends FormRequest 'display_name' => ['nullable', 'string', 'max:255'], 'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'], 'internal_ip' => ['nullable', 'ip'], + 'user_api_base_url' => ['nullable', 'url', 'max:255'], + 'user_api_token' => ['nullable', 'string', 'max:2000'], 'asset_id' => ['nullable', 'integer', 'min:1'], 'account_id' => ['nullable', 'integer', 'min:1'], 'protocol' => ['nullable', 'string', 'max:64'], diff --git a/app/Http/Requests/StoreUserRequest.php b/app/Http/Requests/StoreUserRequest.php index 7879ce6..7afa294 100644 --- a/app/Http/Requests/StoreUserRequest.php +++ b/app/Http/Requests/StoreUserRequest.php @@ -21,6 +21,13 @@ class StoreUserRequest extends FormRequest 'force_password_change' => ['sometimes', 'boolean'], 'role_ids' => ['sometimes', 'array'], 'role_ids.*' => ['integer', 'exists:roles,id'], + 'server_bindings' => ['sometimes', 'array'], + 'server_bindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'], + 'server_bindings.*.username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'server_bindings.*.create_remote' => ['sometimes', 'boolean'], + 'server_bindings.*.password' => ['nullable', 'string', 'min:6', 'max:255'], + 'server_bindings.*.groups' => ['sometimes', 'array'], + 'server_bindings.*.groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], ]; } } diff --git a/app/Http/Requests/UpdateServerResourceRequest.php b/app/Http/Requests/UpdateServerResourceRequest.php index eedacef..7220958 100644 --- a/app/Http/Requests/UpdateServerResourceRequest.php +++ b/app/Http/Requests/UpdateServerResourceRequest.php @@ -18,6 +18,8 @@ class UpdateServerResourceRequest extends FormRequest 'display_name' => ['nullable', 'string', 'max:255'], 'parent_id' => ['nullable', 'integer', 'exists:server_resources,id'], 'internal_ip' => ['nullable', 'ip'], + 'user_api_base_url' => ['nullable', 'url', 'max:255'], + 'user_api_token' => ['nullable', 'string', 'max:2000'], 'asset_id' => ['nullable', 'integer', 'min:1'], 'account_id' => ['nullable', 'integer', 'min:1'], 'protocol' => ['nullable', 'string', 'max:64'], diff --git a/app/Http/Requests/UpdateUserRequest.php b/app/Http/Requests/UpdateUserRequest.php index 650354e..ad9df1d 100644 --- a/app/Http/Requests/UpdateUserRequest.php +++ b/app/Http/Requests/UpdateUserRequest.php @@ -24,6 +24,13 @@ class UpdateUserRequest extends FormRequest 'force_password_change' => ['sometimes', 'boolean'], 'role_ids' => ['sometimes', 'array'], 'role_ids.*' => ['integer', 'exists:roles,id'], + 'server_bindings' => ['sometimes', 'array'], + 'server_bindings.*.server_resource_id' => ['required', 'integer', 'exists:server_resources,id'], + 'server_bindings.*.username' => ['required', 'string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], + 'server_bindings.*.create_remote' => ['sometimes', 'boolean'], + 'server_bindings.*.password' => ['nullable', 'string', 'min:6', 'max:255'], + 'server_bindings.*.groups' => ['sometimes', 'array'], + 'server_bindings.*.groups.*' => ['string', 'max:32', 'regex:/^[a-z_][a-z0-9_-]{0,31}$/'], ]; } } diff --git a/app/Models/ServerResource.php b/app/Models/ServerResource.php index 16b1a2b..dc35592 100644 --- a/app/Models/ServerResource.php +++ b/app/Models/ServerResource.php @@ -17,6 +17,8 @@ class ServerResource extends Model 'display_name', 'parent_id', 'internal_ip', + 'user_api_base_url', + 'user_api_token', 'asset_id', 'account_id', 'protocols', @@ -25,6 +27,10 @@ class ServerResource extends Model 'is_active', ]; + protected $hidden = [ + 'user_api_token', + ]; + public function parent(): BelongsTo { return $this->belongsTo(self::class, 'parent_id'); @@ -42,6 +48,11 @@ class ServerResource extends Model ->withTimestamps(); } + public function serverUserBindings(): HasMany + { + return $this->hasMany(ServerUserBinding::class, 'server_resource_id'); + } + public function accessLogs(): HasMany { return $this->hasMany(AccessLog::class); @@ -51,6 +62,7 @@ class ServerResource extends Model { return [ 'protocols' => 'array', + 'user_api_token' => 'encrypted', 'allow_copy_temp_password' => 'boolean', 'is_active' => 'boolean', ]; diff --git a/app/Models/ServerUserBinding.php b/app/Models/ServerUserBinding.php new file mode 100644 index 0000000..5e4f8ed --- /dev/null +++ b/app/Models/ServerUserBinding.php @@ -0,0 +1,40 @@ +belongsTo(User::class); + } + + public function server(): BelongsTo + { + return $this->belongsTo(ServerResource::class, 'server_resource_id'); + } + + protected function casts(): array + { + return [ + 'remote_exists' => 'boolean', + 'last_synced_at' => 'datetime', + 'metadata' => 'array', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 37a0242..a5aaa59 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -48,6 +48,11 @@ class User extends Authenticatable implements JWTSubject ->withTimestamps(); } + public function serverUserBindings(): HasMany + { + return $this->hasMany(ServerUserBinding::class); + } + public function opsSoftwarePreferences(): HasMany { return $this->hasMany(UserOpsSoftwarePreference::class); diff --git a/app/Services/ServerUserManagementClient.php b/app/Services/ServerUserManagementClient.php new file mode 100644 index 0000000..ceff982 --- /dev/null +++ b/app/Services/ServerUserManagementClient.php @@ -0,0 +1,139 @@ +request($server, 'get', '/users')->json(); + } + + public function user(ServerResource $server, string $username): array + { + return $this->request($server, 'get', '/users/'.$this->encodePath($username))->json(); + } + + public function createUser(ServerResource $server, array $payload): array + { + return $this->request($server, 'post', '/users', $payload)->json(); + } + + public function deleteUser(ServerResource $server, string $username): array + { + return $this->request($server, 'delete', '/users/'.$this->encodePath($username))->json(); + } + + public function updatePassword(ServerResource $server, string $username, string $passwordHash): array + { + return $this->request($server, 'patch', '/users/'.$this->encodePath($username).'/password', [ + 'password_hash' => $passwordHash, + ])->json(); + } + + public function groups(ServerResource $server): array + { + return $this->request($server, 'get', '/groups')->json(); + } + + public function createGroup(ServerResource $server, string $groupname): array + { + return $this->request($server, 'post', '/groups', ['groupname' => $groupname])->json(); + } + + public function deleteGroup(ServerResource $server, string $groupname): array + { + return $this->request($server, 'delete', '/groups/'.$this->encodePath($groupname))->json(); + } + + public function userGroups(ServerResource $server, string $username): array + { + return $this->request($server, 'get', '/users/'.$this->encodePath($username).'/groups')->json(); + } + + public function syncUserGroups(ServerResource $server, string $username, array $groups, string $mode = 'replace'): array + { + return $this->request($server, 'post', '/users/'.$this->encodePath($username).'/groups', [ + 'groups' => array_values($groups), + 'mode' => $mode, + ])->json(); + } + + public function removeUserGroups(ServerResource $server, string $username, array $groups): array + { + return $this->request($server, 'delete', '/users/'.$this->encodePath($username).'/groups', [ + 'groups' => array_values($groups), + 'mode' => 'append', + ])->json(); + } + + private function request(ServerResource $server, string $method, string $path, array $payload = []) + { + $target = $this->resolveServer($server); + $baseUrl = rtrim((string) $target->user_api_base_url, '/'); + $token = trim((string) $target->user_api_token); + + if ($baseUrl === '' || $token === '') { + throw ValidationException::withMessages([ + 'server' => ['该服务器未配置用户管理 API 地址或密钥。'], + ]); + } + + try { + $pending = Http::baseUrl($baseUrl) + ->acceptJson() + ->asJson() + ->timeout((int) config('services.server_user_management.timeout', 15)) + ->connectTimeout((int) config('services.server_user_management.connect_timeout', 5)) + ->retry(2, 200, throw: false) + ->withToken($token) + ->withOptions([ + 'verify' => (bool) config('services.server_user_management.verify_ssl', false), + ]); + + $response = match ($method) { + 'post' => $pending->post($path, $payload), + 'patch' => $pending->patch($path, $payload), + 'delete' => empty($payload) ? $pending->delete($path) : $pending->send('DELETE', $path, ['json' => $payload]), + default => $pending->get($path), + }; + } catch (ConnectionException|RequestException $exception) { + throw ValidationException::withMessages([ + 'server' => ['服务器用户管理 API 调用失败:'.$exception->getMessage()], + ]); + } + + if (! $response->successful()) { + $message = (string) (data_get($response->json(), 'message') + ?: data_get($response->json(), 'detail.message') + ?: data_get($response->json(), 'detail') + ?: '服务器用户管理 API 返回异常'); + + throw ValidationException::withMessages([ + 'server' => [$message], + ]); + } + + return $response; + } + + private function resolveServer(ServerResource $server): ServerResource + { + if (! $server->parent_id) { + return $server; + } + + return $server->parent()->firstOrFail(); + } + + private function encodePath(string $value): string + { + return rawurlencode($value); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index c6f97eb..4e7b712 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -64,6 +64,8 @@ return Application::configure(basePath: dirname(__DIR__)) 'guard_name' => '守卫', 'parent_id' => '所属服务器', 'internal_ip' => '内网IP', + 'user_api_base_url' => '用户管理API地址', + 'user_api_token' => '用户管理API密钥', 'asset_id' => '资产ID', 'account_id' => '账号ID', 'protocol' => '协议', @@ -87,6 +89,11 @@ return Application::configure(basePath: dirname(__DIR__)) 'per_page' => '每页数量', 'username' => '用户名', 'token' => '令牌', + 'server_bindings' => '服务器账号绑定', + 'server_resource_id' => '服务器', + 'password_hash' => '服务器账号密码', + 'groups' => '用户组', + 'groupname' => '用户组', ]; $resolveAttribute = function (string $field) use ($attributeLabels): string { diff --git a/config/services.php b/config/services.php index 8877fd4..6a0dba4 100644 --- a/config/services.php +++ b/config/services.php @@ -55,6 +55,12 @@ return [ ], ], + 'server_user_management' => [ + 'timeout' => (int) env('SERVER_USER_MANAGEMENT_TIMEOUT', 15), + 'connect_timeout' => (int) env('SERVER_USER_MANAGEMENT_CONNECT_TIMEOUT', 5), + 'verify_ssl' => (bool) env('SERVER_USER_MANAGEMENT_VERIFY_SSL', false), + ], + 'ops_client' => [ 'ipv4' => env('OPS_CLIENT_IPV4', '172.16.1.2'), 'asset_ipv4' => env('OPS_CLIENT_ASSET_IPV4', '0.0.0.0'), diff --git a/database/migrations/2026_05_27_000001_add_user_management_api_to_server_resources_table.php b/database/migrations/2026_05_27_000001_add_user_management_api_to_server_resources_table.php new file mode 100644 index 0000000..7c00c1d --- /dev/null +++ b/database/migrations/2026_05_27_000001_add_user_management_api_to_server_resources_table.php @@ -0,0 +1,23 @@ +string('user_api_base_url')->nullable()->after('internal_ip'); + $table->text('user_api_token')->nullable()->after('user_api_base_url'); + }); + } + + public function down(): void + { + Schema::table('server_resources', function (Blueprint $table) { + $table->dropColumn(['user_api_base_url', 'user_api_token']); + }); + } +}; diff --git a/database/migrations/2026_05_27_000002_create_server_user_bindings_table.php b/database/migrations/2026_05_27_000002_create_server_user_bindings_table.php new file mode 100644 index 0000000..93b0e17 --- /dev/null +++ b/database/migrations/2026_05_27_000002_create_server_user_bindings_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->foreignId('server_resource_id')->constrained('server_resources')->cascadeOnDelete(); + $table->string('username'); + $table->boolean('remote_exists')->default(false); + $table->timestamp('last_synced_at')->nullable(); + $table->json('metadata')->nullable(); + $table->timestamps(); + + $table->unique(['user_id', 'server_resource_id']); + $table->index(['server_resource_id', 'username']); + }); + } + + public function down(): void + { + Schema::dropIfExists('server_user_bindings'); + } +}; diff --git a/tests/Feature/ServerSystemUserManagementTest.php b/tests/Feature/ServerSystemUserManagementTest.php new file mode 100644 index 0000000..ebf90a8 --- /dev/null +++ b/tests/Feature/ServerSystemUserManagementTest.php @@ -0,0 +1,167 @@ + Http::response([ + ['username' => 'alice', 'uid' => 1000, 'gid' => 1000, 'home_dir' => '/home/alice', 'shell' => '/bin/bash'], + ]), + 'http://node.test/groups' => Http::response([ + ['groupname' => 'dev', 'gid' => 1000, 'members' => ['alice']], + ]), + 'http://node.test/users/alice/groups' => Http::response(['username' => 'alice', 'groups' => ['dev']]), + ]); + + $admin = $this->admin(); + $server = $this->server(); + ServerUserBinding::query()->create([ + 'user_id' => $admin->id, + 'server_resource_id' => $server->id, + 'username' => 'alice', + 'remote_exists' => true, + ]); + + $response = $this->actingAs($admin, 'api')->getJson('/servers/'.$server->id.'/system-users/meta'); + + $response + ->assertOk() + ->assertJsonPath('code', 0) + ->assertJsonPath('data.users.0.username', 'alice') + ->assertJsonPath('data.groups.0.groupname', 'dev') + ->assertJsonPath('data.user_groups.alice.0', 'dev'); + } + + public function test_creating_user_can_create_remote_server_account_and_binding(): void + { + Http::preventStrayRequests(); + Http::fake([ + 'http://node.test/users' => Http::response(['status' => 'ok', 'message' => 'User created.'], 201), + ]); + + $admin = $this->admin(); + $server = $this->server(); + + $response = $this->actingAs($admin, 'api')->postJson('/users', [ + 'nickname' => 'Alice', + 'email' => 'alice@example.com', + 'phone' => '13800138000', + 'password' => 'secret123', + 'server_bindings' => [[ + 'server_resource_id' => $server->id, + 'username' => 'alice', + 'create_remote' => true, + 'groups' => [], + ]], + ]); + + $response->assertCreated()->assertJsonPath('code', 0); + $user = User::query()->where('email', 'alice@example.com')->firstOrFail(); + + $this->assertDatabaseHas('server_user_bindings', [ + 'user_id' => $user->id, + 'server_resource_id' => $server->id, + 'username' => 'alice', + 'remote_exists' => true, + ]); + + Http::assertSent(function (Request $request): bool { + return $request->method() === 'POST' + && $request->url() === 'http://node.test/users' + && $request['username'] === 'alice' + && is_string($request['password_hash']) + && str_starts_with($request['password_hash'], '$6$'); + }); + } + + public function test_deleting_sso_user_does_not_delete_remote_server_user(): void + { + Http::fake(); + $admin = $this->admin(); + $server = $this->server(); + $target = User::factory()->create(); + ServerUserBinding::query()->create([ + 'user_id' => $target->id, + 'server_resource_id' => $server->id, + 'username' => 'target', + 'remote_exists' => true, + ]); + + $response = $this->actingAs($admin, 'api')->deleteJson('/users/'.$target->id); + + $response->assertOk()->assertJsonPath('code', 0); + $this->assertDatabaseMissing('server_user_bindings', ['user_id' => $target->id]); + Http::assertNothingSent(); + } + + public function test_server_list_includes_bound_username_without_exposing_token(): void + { + $admin = $this->admin(); + $server = $this->server(); + $resource = ServerResource::query()->create([ + 'parent_id' => $server->id, + 'name' => 'ssh', + 'display_name' => 'SSH', + 'internal_ip' => '10.0.0.10', + 'asset_id' => 1, + 'account_id' => 2, + 'protocols' => ['SSH'], + 'is_active' => true, + ]); + ServerUserBinding::query()->create([ + 'user_id' => $admin->id, + 'server_resource_id' => $server->id, + 'username' => 'admin', + 'remote_exists' => true, + ]); + + $response = $this->actingAs($admin, 'api')->getJson('/servers'); + + $response + ->assertOk() + ->assertJsonPath('data.data.0.user_api_configured', true) + ->assertJsonMissingPath('data.data.0.user_api_token'); + + $resourcePayload = collect($response->json('data.data'))->firstWhere('id', $resource->id); + $this->assertSame('admin', $resourcePayload['server_username'] ?? null); + } + + private function admin(): User + { + $user = User::factory()->create(); + Role::query()->firstOrCreate(['name' => 'admin', 'guard_name' => 'api']); + $user->assignRole('admin'); + + return $user; + } + + private function server(): ServerResource + { + return ServerResource::query()->create([ + 'name' => 'server01', + 'display_name' => 'Server 01', + 'internal_ip' => '10.0.0.10', + 'user_api_base_url' => 'http://node.test', + 'user_api_token' => 'secret-token', + 'asset_id' => 1, + 'account_id' => null, + 'protocols' => [], + 'is_active' => true, + ]); + } +} diff --git a/user_manage_api/logs/user_manage_api.jsonl b/user_manage_api/logs/user_manage_api.jsonl new file mode 100644 index 0000000..4e10e08 --- /dev/null +++ b/user_manage_api/logs/user_manage_api.jsonl @@ -0,0 +1,13 @@ +{"operation": "create_user", "target": "admin", "result": "failed", "error_code": "system_command_error", "request_id": "", "source_ip": "127.0.0.1", "elapsed_ms": 13, "ts": 1779863595.5246663, "duration_ms": 0} +{"operation": "create_user", "target": "admin", "result": "failed", "error_code": "system_command_error", "request_id": "", "source_ip": "127.0.0.1", "elapsed_ms": 8, "ts": 1779863595.7691226, "duration_ms": 0} +{"operation": "create_user", "target": "testadmin", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779863611.8820772, "duration_ms": 0} +{"operation": "delete_user", "target": "testadmin", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864235.6534963, "duration_ms": 0} +{"operation": "create_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864389.9342248, "duration_ms": 0} +{"operation": "delete_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864398.5722318, "duration_ms": 0} +{"operation": "create_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864421.8503268, "duration_ms": 0} +{"operation": "delete_user", "target": "test", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864451.530417, "duration_ms": 0} +{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864549.259966, "duration_ms": 0} +{"operation": "change_user_password", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864630.5760016, "duration_ms": 0} +{"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779864656.8571804, "duration_ms": 0} +{"operation": "create_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779865162.184787, "duration_ms": 0} +{"operation": "delete_user", "target": "testtest", "result": "success", "request_id": "", "source_ip": "127.0.0.1", "ts": 1779865345.4267325, "duration_ms": 0} diff --git a/user_manage_api/logs/user_manage_api.log b/user_manage_api/logs/user_manage_api.log new file mode 100644 index 0000000..7be5229 --- /dev/null +++ b/user_manage_api/logs/user_manage_api.log @@ -0,0 +1,13 @@ +2026-05-27 14:33:15,524 INFO operation=create_user target=admin result=failed code=system_command_error request_id= +2026-05-27 14:33:15,769 INFO operation=create_user target=admin result=failed code=system_command_error request_id= +2026-05-27 14:33:31,882 INFO operation=create_user target=testadmin result=success code=None request_id= +2026-05-27 14:43:55,653 INFO operation=delete_user target=testadmin result=success code=None request_id= +2026-05-27 14:46:29,934 INFO operation=create_user target=test result=success code=None request_id= +2026-05-27 14:46:38,572 INFO operation=delete_user target=test result=success code=None request_id= +2026-05-27 14:47:01,850 INFO operation=create_user target=test result=success code=None request_id= +2026-05-27 14:47:31,530 INFO operation=delete_user target=test result=success code=None request_id= +2026-05-27 14:49:09,260 INFO operation=create_user target=testtest result=success code=None request_id= +2026-05-27 14:50:30,576 INFO operation=change_user_password target=testtest result=success code=None request_id= +2026-05-27 14:50:56,857 INFO operation=delete_user target=testtest result=success code=None request_id= +2026-05-27 14:59:22,184 INFO operation=create_user target=testtest result=success code=None request_id= +2026-05-27 15:02:25,426 INFO operation=delete_user target=testtest result=success code=None request_id=