BastionSSO/app/Http/Controllers/Api/UserController.php

577 lines
22 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreUserRequest;
use App\Http\Requests\UpdateUserRequest;
use App\Models\ServerResource;
use App\Models\User;
use App\Models\UserServerPermission;
use hg\apidoc\annotation as Apidoc;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\ValidationException;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
#[Apidoc\Title('用户管理')]
class UserController extends Controller
{
public function __construct()
{
$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']);
}
#[Apidoc\Title('用户列表'), Apidoc\Method('GET'), Apidoc\Url('/users')]
public function index(Request $request): JsonResponse
{
$validated = $request->validate([
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
'sort_by' => ['nullable', 'string', 'in:id,nickname,email,phone,created_at'],
'sort_order' => ['nullable', 'string', 'in:asc,desc'],
]);
$sortBy = $validated['sort_by'] ?? 'created_at';
$sortOrder = $validated['sort_order'] ?? 'desc';
$perPage = (int) ($validated['per_page'] ?? 20);
$users = User::query()
->with('roles')
->orderBy($sortBy, $sortOrder)
->paginate($perPage);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $users]);
}
#[Apidoc\Title('创建用户'), Apidoc\Method('POST'), Apidoc\Url('/users')]
public function store(StoreUserRequest $request): JsonResponse
{
$user = User::query()->create($request->validated());
if ($request->filled('role_ids')) {
$user->syncRoles($request->validated('role_ids'));
}
$this->auditLog($request, 'user_create', ['metadata' => ['target_user_id' => $user->id]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load('roles')], 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);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user]);
}
#[Apidoc\Title('更新用户'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}')]
public function update(UpdateUserRequest $request, string $id): JsonResponse
{
if ($id === 'batch-assignments') {
return $this->syncBatchAssignments($request);
}
$user = User::query()->findOrFail($id);
$user->fill($request->safe()->except(['role_ids']));
if ($request->filled('password')) {
$user->password = $request->validated('password');
}
$user->save();
if ($request->has('role_ids')) {
$user->syncRoles($request->validated('role_ids'));
}
$this->auditLog($request, 'user_update', ['metadata' => ['target_user_id' => $user->id]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load('roles')]);
}
#[Apidoc\Title('同步用户权限'), Apidoc\Method('PUT'), Apidoc\Url('/users/{id}/permissions')]
public function syncPermissions(Request $request, int $id): JsonResponse
{
$validated = $request->validate([
'permission_ids' => ['present', 'array'],
'permission_ids.*' => ['integer', 'exists:permissions,id'],
]);
$user = User::query()->findOrFail($id);
$user->syncPermissions($validated['permission_ids']);
$this->syncServerResourcePermissionsByDirectPermissions($user, $validated['permission_ids']);
$this->auditLog($request, 'user_permissions_update', ['metadata' => ['target_user_id' => $user->id, 'permission_ids' => $validated['permission_ids']]]);
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $user->load(['roles', 'permissions'])]);
}
#[Apidoc\Title('批量设置用户组和用户权限'), Apidoc\Method('PUT'), Apidoc\Url('/users/batch-assignments')]
public function syncBatchAssignments(Request $request): JsonResponse
{
$validated = $request->validate([
'user_ids' => ['required', 'array', 'min:1'],
'user_ids.*' => ['integer', 'exists:users,id'],
'role_ids' => ['present', 'array'],
'role_ids.*' => ['integer', 'exists:roles,id'],
'permission_ids' => ['present', 'array'],
'permission_ids.*' => ['integer', 'exists:permissions,id'],
]);
$userIds = collect($validated['user_ids'])->map(fn (int $id): int => (int) $id)->unique()->values()->all();
$roleIds = collect($validated['role_ids'])->map(fn (int $id): int => (int) $id)->unique()->values()->all();
$permissionIds = collect($validated['permission_ids'])->map(fn (int $id): int => (int) $id)->unique()->values()->all();
$users = User::query()->whereIn('id', $userIds)->get();
foreach ($users as $user) {
$user->syncRoles($roleIds);
$user->syncPermissions($permissionIds);
$this->syncServerResourcePermissionsByDirectPermissions($user, $permissionIds);
}
$this->auditLog($request, 'users_batch_assignments_update', [
'metadata' => [
'target_user_ids' => $userIds,
'role_ids' => $roleIds,
'permission_ids' => $permissionIds,
],
]);
return response()->json([
'code' => 0,
'message' => 'ok',
'data' => [
'updated_count' => $users->count(),
],
]);
}
#[Apidoc\Title('删除用户'), Apidoc\Method('DELETE'), Apidoc\Url('/users/{id}')]
public function destroy(Request $request, int $id): JsonResponse
{
$user = User::query()->findOrFail($id);
$this->auditLog($request, 'user_delete', ['metadata' => ['target_user_id' => $user->id]]);
$user->delete();
return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]);
}
#[Apidoc\Title('批量导入用户'), Apidoc\Method('POST'), Apidoc\Url('/users/import')]
public function import(Request $request): JsonResponse
{
$validated = $request->validate([
'file' => ['required', 'file', 'mimes:xlsx'],
]);
$file = $validated['file'];
$rows = $this->parseImportRows($file->getPathname(), (string) $file->getClientOriginalExtension());
if ($rows->isEmpty()) {
throw ValidationException::withMessages([
'file' => ['文件中没有可导入的数据行。'],
]);
}
$createdCount = 0;
$skippedCount = 0;
$errors = [];
foreach ($rows as $index => $row) {
$line = $index + 2;
$normalizedRow = [];
foreach ($row as $key => $value) {
$normalizedRow[$this->normalizeImportHeader((string) $key)] = (string) $value;
}
$payload = [
'nickname' => trim($this->firstMatchedValue($normalizedRow, ['nickname', 'nick_name', 'name', '用户名', '昵称'])),
'email' => trim($this->firstMatchedValue($normalizedRow, ['email', 'mail', '邮箱'])),
'phone' => trim($this->firstMatchedValue($normalizedRow, ['phone', 'mobile', '手机号', '手机', '电话'])),
'password' => trim($this->firstMatchedValue($normalizedRow, ['password', 'passwd', 'pwd', '密码'])),
'force_password_change' => $this->parseBooleanValue($this->firstMatchedValue($normalizedRow, ['force_password_change', 'require_password_change', '要求更改密码', 'force_change_password'])),
];
$validator = Validator::make($payload, [
'nickname' => ['required', 'string', 'max:255'],
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
'phone' => ['nullable', 'string', 'max:32', 'unique:users,phone'],
'password' => ['required', 'string', 'min:6', 'max:255'],
]);
if ($validator->fails()) {
$errors[] = [
'line' => $line,
'message' => implode('', $validator->errors()->all()),
];
$skippedCount++;
continue;
}
$user = User::query()->create($payload);
$roleIds = $this->resolveRoleIds($normalizedRow);
if (! empty($roleIds)) {
$user->syncRoles($roleIds);
}
$createdCount++;
}
$this->auditLog($request, 'user_import', [
'metadata' => [
'created_count' => $createdCount,
'skipped_count' => $skippedCount,
'error_count' => count($errors),
],
]);
return response()->json([
'code' => 0,
'message' => 'ok',
'data' => [
'created_count' => $createdCount,
'skipped_count' => $skippedCount,
'errors' => $errors,
],
]);
}
#[Apidoc\Title('下载用户导入模板'), Apidoc\Method('GET'), Apidoc\Url('/users/import/template')]
public function importTemplate()
{
if (! class_exists(\ZipArchive::class)) {
throw ValidationException::withMessages([
'file' => ['当前 PHP 环境未启用 ZipArchive 扩展,无法生成 xlsx 模板。'],
]);
}
$tempPath = tempnam(sys_get_temp_dir(), 'users_import_template_');
if ($tempPath === false) {
throw ValidationException::withMessages(['file' => ['无法创建临时模板文件。']]);
}
$xlsxPath = $tempPath.'.xlsx';
@rename($tempPath, $xlsxPath);
$this->buildUsersImportTemplateXlsx($xlsxPath);
return response()->download(
$xlsxPath,
'users_import_template.xlsx',
['Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
)->deleteFileAfterSend(true);
}
private function parseImportRows(string $filePath, string $extension): Collection
{
$ext = strtolower($extension);
if ($ext !== 'xlsx') {
throw ValidationException::withMessages(['file' => ['仅支持 xlsx 文件导入。']]);
}
return $this->parseXlsxRows($filePath);
}
private function parseXlsxRows(string $filePath): Collection
{
if (! class_exists(\ZipArchive::class)) {
throw ValidationException::withMessages([
'file' => ['当前 PHP 环境未启用 ZipArchive 扩展,无法导入 xlsx。请启用 php_zip 扩展。'],
]);
}
$zip = new \ZipArchive;
if ($zip->open($filePath) !== true) {
throw ValidationException::withMessages(['file' => ['无法解析 xlsx 文件。']]);
}
$sharedStrings = [];
$sharedXml = $zip->getFromName('xl/sharedStrings.xml');
if (is_string($sharedXml)) {
$sharedDoc = new \SimpleXMLElement($sharedXml);
foreach ($sharedDoc->si as $si) {
$sharedStrings[] = (string) $si->t;
}
}
$sheetXml = $zip->getFromName('xl/worksheets/sheet1.xml');
$zip->close();
if (! is_string($sheetXml)) {
throw ValidationException::withMessages(['file' => ['xlsx 文件缺少 sheet1。']]);
}
$sheet = new \SimpleXMLElement($sheetXml);
$rowsData = [];
foreach ($sheet->sheetData->row as $rowNode) {
$rowValues = [];
foreach ($rowNode->c as $cell) {
$ref = (string) $cell['r'];
preg_match('/^[A-Z]+/', $ref, $matches);
$column = $matches[0] ?? '';
$value = '';
if ((string) $cell['t'] === 's') {
$index = (int) ($cell->v ?? 0);
$value = (string) ($sharedStrings[$index] ?? '');
} else {
$value = (string) ($cell->v ?? '');
}
$rowValues[$column] = $value;
}
$rowsData[] = $rowValues;
}
if (count($rowsData) <= 1) {
return collect();
}
$headerRow = $rowsData[0];
$orderedColumns = array_keys($headerRow);
$headers = [];
foreach ($orderedColumns as $col) {
$headers[] = $this->normalizeImportHeader((string) ($headerRow[$col] ?? ''));
}
$resultRows = collect();
for ($i = 1; $i < count($rowsData); $i++) {
$row = $rowsData[$i];
$assoc = [];
$hasValue = false;
foreach ($orderedColumns as $idx => $col) {
$header = $headers[$idx] ?? '';
if ($header === '') {
continue;
}
$value = (string) ($row[$col] ?? '');
if (trim($value) !== '') {
$hasValue = true;
}
$assoc[$header] = $value;
}
if ($hasValue) {
$resultRows->push($assoc);
}
}
return $resultRows;
}
private function resolveRoleIds(array $row): array
{
$roleIdsRaw = trim((string) ($row['role_ids'] ?? ''));
if ($roleIdsRaw !== '') {
return collect(explode(',', $roleIdsRaw))
->map(fn ($id) => (int) trim($id))
->filter(fn (int $id): bool => $id > 0 && Role::query()->where('id', $id)->exists())
->values()
->all();
}
$roleNamesRaw = trim((string) ($row['roles'] ?? $row['role_names'] ?? ''));
if ($roleNamesRaw === '') {
return [];
}
$names = collect(explode(',', $roleNamesRaw))
->map(fn ($name) => trim($name))
->filter()
->values()
->all();
if (empty($names)) {
return [];
}
return Role::query()
->whereIn('name', $names)
->pluck('id')
->map(fn (int $id): int => (int) $id)
->all();
}
private function normalizeImportHeader(string $header): string
{
$normalized = trim($this->toUtf8($header));
$normalized = preg_replace('/^\xEF\xBB\xBF/u', '', $normalized) ?? $normalized;
$normalized = strtolower(str_replace([' ', '-', '.'], '_', trim($normalized)));
return match ($normalized) {
'昵称', '用户名', '姓名', 'name', 'nick_name' => 'nickname',
'邮箱', '邮件', 'mail' => 'email',
'手机号', '手机', '电话', 'mobile' => 'phone',
'密码', 'passwd', 'pwd' => 'password',
'角色id', '角色ids', 'role_id', '角色_id' => 'role_ids',
'角色', '角色名', 'role_name', 'role_names' => 'roles',
default => $normalized,
};
}
private function toUtf8(string $value): string
{
if ($value === '' || mb_check_encoding($value, 'UTF-8')) {
return $value;
}
return mb_convert_encoding($value, 'UTF-8', 'UTF-8,GB18030,GBK,Big5,ISO-8859-1');
}
/**
* @param array<string, string> $row
* @param array<int, string> $aliases
*/
private function firstMatchedValue(array $row, array $aliases): string
{
foreach ($aliases as $alias) {
$key = $this->normalizeImportHeader($alias);
if (array_key_exists($key, $row)) {
return (string) $row[$key];
}
}
return '';
}
private function parseBooleanValue(string $value): bool
{
$normalized = strtolower(trim($value));
if ($normalized === '') {
return false;
}
return in_array($normalized, ['1', 'true', 'yes', 'y', 'on', '是', '真'], true);
}
private function buildUsersImportTemplateXlsx(string $xlsxPath): void
{
$zip = new \ZipArchive;
if ($zip->open($xlsxPath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE) !== true) {
throw ValidationException::withMessages(['file' => ['无法生成 xlsx 模板文件。']]);
}
$contentTypes = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
.'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
.'<Default Extension="xml" ContentType="application/xml"/>'
.'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
.'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
.'</Types>';
$rels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
.'</Relationships>';
$workbook = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
.'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
.'<sheets><sheet name="Users" sheetId="1" r:id="rId1"/></sheets></workbook>';
$workbookRels = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
.'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
.'</Relationships>';
$rows = [
['nickname', 'email', 'phone', 'password', 'role_ids', 'force_password_change'],
['张三', 'zhangsan@example.com', '13800138000', 'Pass@123456', '1', '0'],
['李四', 'lisi@example.com', '13900139000', 'Pass@123456', '1,2', '1'],
];
$sheetData = '';
foreach ($rows as $rowIndex => $cells) {
$r = $rowIndex + 1;
$sheetData .= '<row r="'.$r.'">';
foreach ($cells as $colIndex => $cellValue) {
$column = chr(ord('A') + $colIndex).$r;
$escapedValue = htmlspecialchars((string) $cellValue, ENT_XML1 | ENT_QUOTES, 'UTF-8');
$sheetData .= '<c r="'.$column.'" t="inlineStr"><is><t>'.$escapedValue.'</t></is></c>';
}
$sheetData .= '</row>';
}
$sheet = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
.'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>'
.$sheetData
.'</sheetData></worksheet>';
$zip->addFromString('[Content_Types].xml', $contentTypes);
$zip->addFromString('_rels/.rels', $rels);
$zip->addFromString('xl/workbook.xml', $workbook);
$zip->addFromString('xl/_rels/workbook.xml.rels', $workbookRels);
$zip->addFromString('xl/worksheets/sheet1.xml', $sheet);
$zip->close();
}
private function syncServerResourcePermissionsByDirectPermissions(User $user, array $permissionIds): void
{
$resourcePermissions = Permission::query()
->select(['id', 'name', 'description'])
->whereIn('id', $permissionIds)
->where('guard_name', 'api')
->where('name', 'like', 'resource.servers.use.%')
->get();
$grantedResourceIds = $resourcePermissions
->map(fn (Permission $permission): ?int => $this->resourceIdFromPermissionDescription((string) $permission->description))
->filter(fn (?int $resourceId): bool => $resourceId !== null)
->map(fn (int $resourceId): int => (int) $resourceId)
->values()
->all();
if (count($grantedResourceIds) > 0) {
$existingResourceIds = ServerResource::query()
->whereIn('id', $grantedResourceIds)
->whereNotNull('parent_id')
->pluck('id')
->map(fn (int $resourceId): int => (int) $resourceId)
->all();
foreach ($existingResourceIds as $resourceId) {
UserServerPermission::query()->updateOrCreate(
[
'user_id' => $user->id,
'server_resource_id' => $resourceId,
],
[
'can_ssh' => true,
'can_sftp' => true,
'can_rdp' => true,
]
);
}
}
$managedResourceIds = Permission::query()
->where('guard_name', 'api')
->where('name', 'like', 'resource.servers.use.%')
->where('description', 'like', '服务器资源访问权限(%资源ID:%')
->pluck('description')
->map(fn (string $description): ?int => $this->resourceIdFromPermissionDescription($description))
->filter(fn (?int $resourceId): bool => $resourceId !== null)
->map(fn (int $resourceId): int => $resourceId)
->values()
->all();
if (count($managedResourceIds) > 0) {
UserServerPermission::query()
->where('user_id', $user->id)
->whereIn('server_resource_id', $managedResourceIds)
->whereNotIn('server_resource_id', $grantedResourceIds)
->update([
'can_ssh' => false,
'can_sftp' => false,
'can_rdp' => false,
]);
}
}
private function resourceIdFromPermissionDescription(string $description): ?int
{
if (! preg_match('/资源ID:\s*(\d+)/u', $description, $matches)) {
return null;
}
return isset($matches[1]) ? (int) $matches[1] : null;
}
}