From 3a2b175028c0025f972dec26544fc06ce022d9a5 Mon Sep 17 00:00:00 2001 From: Nick Zeng Date: Thu, 2 Apr 2026 10:40:47 +0800 Subject: [PATCH] update api key manage --- .../api/v1/AdminApiKeyController.php | 232 ++++++++++++++++++ .../Controller/api/v1/ApiKeyController.php | 108 ++++++++ .../app/Controller/api/v1/AuthController.php | 2 + .../app/Controller/api/v1/UserController.php | 77 ++++++ backend/app/Middleware/AuthMiddleware.php | 7 + .../Integration/Admin/AdminApiKeyTest.php | 223 +++++++++++++++++ .../Auth/ApiKeyGlobalSwitchTest.php | 109 ++++++++ .../Integration/Auth/ApiKeyToggleTest.php | 164 +++++++++++++ .../User/UserApiKeyEnabledTest.php | 112 +++++++++ 9 files changed, 1034 insertions(+) create mode 100644 backend/app/Controller/api/v1/AdminApiKeyController.php create mode 100644 backend/test/Cases/Integration/Admin/AdminApiKeyTest.php create mode 100644 backend/test/Cases/Integration/Auth/ApiKeyGlobalSwitchTest.php create mode 100644 backend/test/Cases/Integration/Auth/ApiKeyToggleTest.php create mode 100644 backend/test/Cases/Integration/User/UserApiKeyEnabledTest.php diff --git a/backend/app/Controller/api/v1/AdminApiKeyController.php b/backend/app/Controller/api/v1/AdminApiKeyController.php new file mode 100644 index 0000000..0969a79 --- /dev/null +++ b/backend/app/Controller/api/v1/AdminApiKeyController.php @@ -0,0 +1,232 @@ + []]], + tags: ['Admin API Keys'], + parameters: [ + new OA\Parameter(name: 'page', in: 'query', required: false, description: '页码,默认 1', schema: new OA\Schema(type: 'integer', default: 1)), + new OA\Parameter(name: 'per_page', in: 'query', required: false, description: '每页条数,默认 15,最大 100', schema: new OA\Schema(type: 'integer', default: 15)), + new OA\Parameter(name: 'user_id', in: 'query', required: false, description: '按用户 ID 筛选', schema: new OA\Schema(type: 'integer')), + new OA\Parameter(name: 'enabled', in: 'query', required: false, description: '按启用状态筛选(0/1)', schema: new OA\Schema(type: 'integer', enum: [0, 1])), + ], + responses: [ + new OA\Response( + response: 200, + description: '获取成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '获取成功'), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'items', type: 'array', items: new OA\Items(properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'user_id', type: 'integer'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'key_prefix', type: 'string'), + new OA\Property(property: 'last_used_at', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'expires_at', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'enabled', type: 'boolean'), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + new OA\Property(property: 'user', properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'username', type: 'string'), + new OA\Property(property: 'api_key_enabled', type: 'boolean'), + ], type: 'object'), + ])), + new OA\Property(property: 'total', type: 'integer'), + new OA\Property(property: 'page', type: 'integer'), + new OA\Property(property: 'per_page', type: 'integer'), + ], type: 'object'), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "", methods: "GET")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function index(): array + { + $page = (int) $this->request->input('page', 1); + $per_page = min((int) $this->request->input('per_page', 15), 100); + + $query = ApiKey::query()->with('user:id,username,api_key_enabled'); + + // 按用户 ID 筛选 + $user_id = $this->request->input('user_id'); + if ($user_id !== null && $user_id !== '') { + $query->where('user_id', (int) $user_id); + } + + // 按启用状态筛选 + $enabled = $this->request->input('enabled'); + if ($enabled !== null && $enabled !== '') { + $query->where('enabled', (bool) (int) $enabled); + } + + $total = $query->count(); + $items = $query->orderBy('created_at', 'desc') + ->offset(($page - 1) * $per_page) + ->limit($per_page) + ->get(); + + return [ + 'code' => 0, + 'message' => '获取成功', + 'data' => [ + 'items' => $items, + 'total' => $total, + 'page' => $page, + 'per_page' => $per_page, + ], + ]; + } + + /** + * 管理员启用/禁用指定 API Key + */ + #[OA\Patch( + path: '/admin/api-keys/{id}/toggle', + summary: '启用/禁用指定 API Key', + description: '管理员切换任意 API Key 的启用状态', + security: [['bearerAuth' => []]], + tags: ['Admin API Keys'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: 'API Key ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['enabled'], + properties: [ + new OA\Property(property: 'enabled', type: 'boolean', description: '是否启用'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: '状态更新成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '状态更新成功'), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'key_prefix', type: 'string'), + new OA\Property(property: 'enabled', type: 'boolean'), + new OA\Property(property: 'user_id', type: 'integer'), + ], type: 'object'), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 404, description: 'API Key 不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}/toggle", methods: "PATCH")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function toggle(int $id): ResponseInterface|array + { + $api_key = ApiKey::query()->find($id); + + if (!$api_key) { + return $this->response->json([ + 'code' => 404, + 'message' => 'API Key 不存在', + ])->withStatus(404); + } + + $enabled = filter_var($this->request->input('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($enabled === null) { + return $this->response->json([ + 'code' => 400, + 'message' => 'enabled 参数不能为空或格式不正确', + ])->withStatus(400); + } + + $api_key->enabled = $enabled; + $api_key->save(); + + return [ + 'code' => 0, + 'message' => '状态更新成功', + 'data' => $api_key, + ]; + } + + /** + * 管理员删除指定 API Key + */ + #[OA\Delete( + path: '/admin/api-keys/{id}', + summary: '删除指定 API Key', + description: '管理员删除任意 API Key(硬删除,不可恢复)', + security: [['bearerAuth' => []]], + tags: ['Admin API Keys'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: 'API Key ID', schema: new OA\Schema(type: 'integer')), + ], + responses: [ + new OA\Response( + response: 200, + description: '删除成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '删除成功'), + ]) + ), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 404, description: 'API Key 不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}", methods: "DELETE")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function destroy(int $id): ResponseInterface|array + { + $api_key = ApiKey::query()->find($id); + + if (!$api_key) { + return $this->response->json([ + 'code' => 404, + 'message' => 'API Key 不存在', + ])->withStatus(404); + } + + $api_key->delete(); + + return [ + 'code' => 0, + 'message' => '删除成功', + ]; + } +} diff --git a/backend/app/Controller/api/v1/ApiKeyController.php b/backend/app/Controller/api/v1/ApiKeyController.php index af375cf..2d5cf6b 100644 --- a/backend/app/Controller/api/v1/ApiKeyController.php +++ b/backend/app/Controller/api/v1/ApiKeyController.php @@ -102,6 +102,27 @@ class ApiKeyController extends AbstractController ])->withStatus(400); } + // 同用户内 Key 名称唯一 + $name_exists = ApiKey::query() + ->where('user_id', $user->id) + ->where('name', $name) + ->exists(); + if ($name_exists) { + return $this->response->json([ + 'code' => 400, + 'message' => '已存在同名的 API Key,请使用不同的名称', + ])->withStatus(400); + } + + // 每用户最多 10 个 Key + $key_count = ApiKey::query()->where('user_id', $user->id)->count(); + if ($key_count >= 10) { + return $this->response->json([ + 'code' => 400, + 'message' => '每个用户最多创建 10 个 API Key', + ])->withStatus(400); + } + // 校验过期时间格式 if ($expires_at !== null && $expires_at !== '') { try { @@ -241,4 +262,91 @@ class ApiKeyController extends AbstractController 'message' => '删除成功', ]; } + + /** + * 启用/禁用自己的 API Key + */ + #[OA\Patch( + path: '/me/api-keys/{id}/toggle', + summary: '启用/禁用自己的 API Key', + description: '用户切换自己 API Key 的启用状态', + security: [['bearerAuth' => []]], + tags: ['API Keys'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: 'API Key ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['enabled'], + properties: [ + new OA\Property(property: 'enabled', type: 'boolean', description: '是否启用'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: '状态更新成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: '状态更新成功'), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'name', type: 'string'), + new OA\Property(property: 'key_prefix', type: 'string'), + new OA\Property(property: 'enabled', type: 'boolean'), + new OA\Property(property: 'last_used_at', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'expires_at', type: 'string', format: 'date-time', nullable: true), + new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), + ], type: 'object'), + ]) + ), + new OA\Response(response: 400, description: '参数错误', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 404, description: 'API Key 不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}/toggle", methods: "PATCH")] + #[Middleware(AuthMiddleware::class)] + public function toggle(int $id, AuthManager $auth): \Psr\Http\Message\ResponseInterface|array + { + $user = $auth->guard('jwt')->user(); + + if (!$user instanceof User) { + return $this->response->json([ + 'code' => 401, + 'message' => '未授权', + ])->withStatus(401); + } + + $api_key = ApiKey::query() + ->where('id', $id) + ->where('user_id', $user->id) + ->first(); + + if (!$api_key) { + return $this->response->json([ + 'code' => 404, + 'message' => 'API Key 不存在', + ])->withStatus(404); + } + + $enabled = filter_var($this->request->input('enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($enabled === null) { + return $this->response->json([ + 'code' => 400, + 'message' => 'enabled 参数不能为空或格式不正确', + ])->withStatus(400); + } + + $api_key->enabled = $enabled; + $api_key->save(); + + return [ + 'code' => 0, + 'message' => '状态更新成功', + 'data' => $api_key, + ]; + } } diff --git a/backend/app/Controller/api/v1/AuthController.php b/backend/app/Controller/api/v1/AuthController.php index 6cdde16..4124653 100644 --- a/backend/app/Controller/api/v1/AuthController.php +++ b/backend/app/Controller/api/v1/AuthController.php @@ -396,6 +396,7 @@ class AuthController extends AbstractController new OA\Property(property: 'username', type: 'string', example: 'admin'), new OA\Property(property: 'email', type: 'string', example: 'admin@example.com'), new OA\Property(property: 'status', type: 'integer', example: 1), + new OA\Property(property: 'api_key_enabled', type: 'boolean', example: true, description: 'API Key 功能是否启用'), new OA\Property(property: 'ext', type: 'object', nullable: true), new OA\Property(property: 'created_at', type: 'string', format: 'date-time'), ], type: 'object'), @@ -428,6 +429,7 @@ class AuthController extends AbstractController 'email' => $user->email, 'role' => $user->role?->name ?? 'accessor', 'status' => $user->status, + 'api_key_enabled' => $user->api_key_enabled, 'ext' => $user->ext, 'created_at' => $user->created_at->toDateTimeString(), ], diff --git a/backend/app/Controller/api/v1/UserController.php b/backend/app/Controller/api/v1/UserController.php index 0a7ab8a..11eaaeb 100644 --- a/backend/app/Controller/api/v1/UserController.php +++ b/backend/app/Controller/api/v1/UserController.php @@ -554,4 +554,81 @@ class UserController extends AbstractController 'data' => $user, ]; } + + /** + * 管理员切换用户 API Key 权限 + */ + #[OA\Patch( + path: '/users/{id}/api-key-enabled', + summary: '切换用户 API Key 权限', + description: '管理员切换指定用户的 api_key_enabled 全局开关。关闭后该用户所有 Key 无法认证,重新开启后自动恢复', + security: [['bearerAuth' => []]], + tags: ['Users'], + parameters: [ + new OA\Parameter(name: 'id', in: 'path', required: true, description: '用户 ID', schema: new OA\Schema(type: 'integer')), + ], + requestBody: new OA\RequestBody( + required: true, + content: new OA\JsonContent( + required: ['api_key_enabled'], + properties: [ + new OA\Property(property: 'api_key_enabled', type: 'boolean', description: '是否启用 API Key 功能'), + ] + ) + ), + responses: [ + new OA\Response( + response: 200, + description: 'API Key 权限更新成功', + content: new OA\JsonContent(properties: [ + new OA\Property(property: 'code', type: 'integer', example: 0), + new OA\Property(property: 'message', type: 'string', example: 'API Key 权限更新成功'), + new OA\Property(property: 'data', properties: [ + new OA\Property(property: 'id', type: 'integer'), + new OA\Property(property: 'username', type: 'string'), + new OA\Property(property: 'api_key_enabled', type: 'boolean'), + ], type: 'object'), + ]) + ), + new OA\Response(response: 400, description: '参数错误', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 401, description: '未认证', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 403, description: '无权限', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + new OA\Response(response: 404, description: '用户不存在', content: new OA\JsonContent(ref: '#/components/schemas/ErrorResponse')), + ] + )] + #[RequestMapping(path: "{id}/api-key-enabled", methods: "PATCH")] + #[Middleware(AuthMiddleware::class)] + #[Middleware(PermissionMiddleware::class)] + public function updateApiKeyEnabled(int $id): \Psr\Http\Message\ResponseInterface|array + { + $user = User::query()->find($id); + + if (!$user) { + return $this->response->json([ + 'code' => 404, + 'message' => '用户不存在', + ])->withStatus(404); + } + + $api_key_enabled = filter_var($this->request->input('api_key_enabled'), FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($api_key_enabled === null) { + return $this->response->json([ + 'code' => 400, + 'message' => 'api_key_enabled 参数不能为空或格式不正确', + ])->withStatus(400); + } + + $user->api_key_enabled = $api_key_enabled; + $user->save(); + + return [ + 'code' => 0, + 'message' => 'API Key 权限更新成功', + 'data' => [ + 'id' => $user->id, + 'username' => $user->username, + 'api_key_enabled' => $user->api_key_enabled, + ], + ]; + } } diff --git a/backend/app/Middleware/AuthMiddleware.php b/backend/app/Middleware/AuthMiddleware.php index ba740ba..ff846d5 100644 --- a/backend/app/Middleware/AuthMiddleware.php +++ b/backend/app/Middleware/AuthMiddleware.php @@ -103,6 +103,13 @@ class AuthMiddleware implements MiddlewareInterface ])->withStatus(403); } + if (!$user->api_key_enabled) { + return $this->response->json([ + 'code' => 403, + 'message' => 'API Key 功能未启用,请联系管理员开启', + ])->withStatus(403); + } + // 更新最后使用时间 $api_key->last_used_at = \Carbon\Carbon::now(); $api_key->save(); diff --git a/backend/test/Cases/Integration/Admin/AdminApiKeyTest.php b/backend/test/Cases/Integration/Admin/AdminApiKeyTest.php new file mode 100644 index 0000000..954f04a --- /dev/null +++ b/backend/test/Cases/Integration/Admin/AdminApiKeyTest.php @@ -0,0 +1,223 @@ +fetchAdminRole(); + $user = User::query() + ->where('status', 1) + ->where('role_id', $admin_role->id) + ->first(); + if (!$user) { + $this->markTestSkipped('没有可用的 administrator 用户,无法测试'); + } + + $auth = make(AuthManager::class); + return $auth->guard('jwt')->login($user); + } + + protected function fetchAdminRole(): Role + { + return Role::query()->where('name', 'administrator')->firstOrFail(); + } + + protected function adminHeaders(): array + { + return ['Authorization' => 'Bearer ' . $this->getAdminAuthToken()]; + } + + protected function createTestUser(string $suffix, array $overrides = []): User + { + return User::query()->create(array_merge([ + 'username' => 'admin_apikey_test_' . $suffix, + 'password' => 'Pass_' . $suffix, + 'email' => 'admin_apikey_test_' . $suffix . '@example.com', + 'status' => 1, + 'api_key_enabled' => true, + ], $overrides)); + } + + protected function getNonAdminToken(): array + { + $user = $this->createTestUser('nonadmin_' . uniqid()); + $auth = make(AuthManager::class); + $token = $auth->guard('jwt')->login($user); + return ['Authorization' => 'Bearer ' . $token]; + } + + public function test_admin_can_list_all_api_keys(): void + { + $user = $this->createTestUser('list_' . uniqid()); + $result = ApiKey::generate($user->id, 'Test Key List'); + + $response = $this->get('/api/v1/admin/api-keys', [], $this->adminHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertArrayHasKey('items', $body['data']); + $this->assertArrayHasKey('total', $body['data']); + $this->assertArrayHasKey('page', $body['data']); + $this->assertArrayHasKey('per_page', $body['data']); + + // 验证 user 关联信息 + $items = $body['data']['items']; + $found = false; + foreach ($items as $item) { + if ($item['id'] === $result['api_key']->id) { + $found = true; + $this->assertArrayHasKey('user', $item); + $this->assertEquals($user->id, $item['user']['id']); + $this->assertEquals($user->username, $item['user']['username']); + $this->assertArrayHasKey('api_key_enabled', $item['user']); + } + } + $this->assertTrue($found, '应在列表中找到刚创建的 Key'); + + $user->forceDelete(); + } + + public function test_admin_list_does_not_expose_key_hash(): void + { + $user = $this->createTestUser('hash_' . uniqid()); + ApiKey::generate($user->id, 'Hash Check Key'); + + $response = $this->get('/api/v1/admin/api-keys', ['user_id' => $user->id], $this->adminHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getBody()->getContents(), true); + foreach ($body['data']['items'] as $item) { + $this->assertArrayNotHasKey('key_hash', $item, '响应不应包含 key_hash'); + } + + $user->forceDelete(); + } + + public function test_admin_list_filter_by_user_id(): void + { + $user = $this->createTestUser('filter_uid_' . uniqid()); + ApiKey::generate($user->id, 'Filter User Key'); + + $response = $this->get('/api/v1/admin/api-keys', ['user_id' => $user->id], $this->adminHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getBody()->getContents(), true); + foreach ($body['data']['items'] as $item) { + $this->assertEquals($user->id, $item['user_id']); + } + + $user->forceDelete(); + } + + public function test_admin_list_filter_by_enabled(): void + { + $user = $this->createTestUser('filter_en_' . uniqid()); + $result = ApiKey::generate($user->id, 'Enabled Filter Key'); + $result['api_key']->enabled = false; + $result['api_key']->save(); + + $response = $this->get('/api/v1/admin/api-keys', ['user_id' => $user->id, 'enabled' => 0], $this->adminHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertCount(1, $body['data']['items']); + $this->assertFalse($body['data']['items'][0]['enabled']); + + $user->forceDelete(); + } + + public function test_admin_list_pagination(): void + { + $response = $this->get('/api/v1/admin/api-keys', ['page' => 1, 'per_page' => 2], $this->adminHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertEquals(1, $body['data']['page']); + $this->assertEquals(2, $body['data']['per_page']); + $this->assertLessThanOrEqual(2, count($body['data']['items'])); + } + + public function test_admin_can_toggle_any_key(): void + { + $user = $this->createTestUser('toggle_' . uniqid()); + $result = ApiKey::generate($user->id, 'Toggle Key'); + $key_id = $result['api_key']->id; + + // 禁用 + $response = $this->patch('/api/v1/admin/api-keys/' . $key_id . '/toggle', ['enabled' => false], $this->adminHeaders()); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertFalse($body['data']['enabled']); + + // 重新启用 + $response = $this->patch('/api/v1/admin/api-keys/' . $key_id . '/toggle', ['enabled' => true], $this->adminHeaders()); + $response->assertStatus(200); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertTrue($body['data']['enabled']); + + $user->forceDelete(); + } + + public function test_admin_toggle_does_not_expose_key_hash(): void + { + $user = $this->createTestUser('toggle_hash_' . uniqid()); + $result = ApiKey::generate($user->id, 'Toggle Hash Key'); + + $response = $this->patch('/api/v1/admin/api-keys/' . $result['api_key']->id . '/toggle', ['enabled' => false], $this->adminHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertArrayNotHasKey('key_hash', $body['data'], '响应不应包含 key_hash'); + + $user->forceDelete(); + } + + public function test_admin_can_delete_any_key(): void + { + $user = $this->createTestUser('delete_' . uniqid()); + $result = ApiKey::generate($user->id, 'Delete Key'); + $key_id = $result['api_key']->id; + + $response = $this->delete('/api/v1/admin/api-keys/' . $key_id, [], $this->adminHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $this->assertNull(ApiKey::query()->find($key_id)); + + $user->forceDelete(); + } + + public function test_non_admin_cannot_access_admin_api_keys(): void + { + $headers = $this->getNonAdminToken(); + + $response = $this->get('/api/v1/admin/api-keys', [], $headers); + $response->assertStatus(403); + } + + public function test_toggle_nonexistent_key_returns_404(): void + { + $response = $this->patch('/api/v1/admin/api-keys/999999/toggle', ['enabled' => false], $this->adminHeaders()); + $response->assertStatus(404); + } +} diff --git a/backend/test/Cases/Integration/Auth/ApiKeyGlobalSwitchTest.php b/backend/test/Cases/Integration/Auth/ApiKeyGlobalSwitchTest.php new file mode 100644 index 0000000..ec52811 --- /dev/null +++ b/backend/test/Cases/Integration/Auth/ApiKeyGlobalSwitchTest.php @@ -0,0 +1,109 @@ +create(array_merge([ + 'username' => 'gs_test_' . $suffix, + 'password' => 'Pass_' . $suffix, + 'email' => 'gs_test_' . $suffix . '@example.com', + 'status' => 1, + 'api_key_enabled' => true, + ], $overrides)); + } + + public function test_api_key_auth_rejected_when_global_switch_off(): void + { + $user = $this->createTestUser('off_' . uniqid(), ['api_key_enabled' => false]); + $result = ApiKey::generate($user->id, 'Global Off Key'); + + // 手动启用 key(generate 默认 enabled=true),但全局开关关闭 + $response = $this->get('/api/v1/me', [], [ + 'X-API-Key' => $result['plain_key'], + ]); + + $response->assertStatus(403); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertStringContainsString('API Key 功能未启用', $body['message']); + + $user->forceDelete(); + } + + public function test_api_key_auth_works_when_global_switch_on(): void + { + $user = $this->createTestUser('on_' . uniqid(), ['api_key_enabled' => true]); + $result = ApiKey::generate($user->id, 'Global On Key'); + + $response = $this->get('/api/v1/me', [], [ + 'X-API-Key' => $result['plain_key'], + ]); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $response->assertJsonPath('data.id', $user->id); + + $user->forceDelete(); + } + + public function test_api_key_auth_restored_after_reenable(): void + { + $user = $this->createTestUser('restore_' . uniqid(), ['api_key_enabled' => true]); + $result = ApiKey::generate($user->id, 'Restore Key'); + + // 关闭全局开关 + $user->api_key_enabled = false; + $user->save(); + + $response = $this->get('/api/v1/me', [], [ + 'X-API-Key' => $result['plain_key'], + ]); + $response->assertStatus(403); + + // 重新开启 + $user->api_key_enabled = true; + $user->save(); + + $response = $this->get('/api/v1/me', [], [ + 'X-API-Key' => $result['plain_key'], + ]); + $response->assertStatus(200); + $response->assertJsonPath('data.id', $user->id); + + $user->forceDelete(); + } + + public function test_disabled_key_still_rejected_after_global_reenable(): void + { + $user = $this->createTestUser('disabled_key_' . uniqid(), ['api_key_enabled' => true]); + $result = ApiKey::generate($user->id, 'Disabled Key'); + + // 禁用单个 Key + $result['api_key']->enabled = false; + $result['api_key']->save(); + + // 全局开关开启,但单 Key 已禁用 + $response = $this->get('/api/v1/me', [], [ + 'X-API-Key' => $result['plain_key'], + ]); + + // ApiKey::findByPlainKey() 查询条件包含 enabled=true,所以禁用的 Key 返回 401(无效 Key) + $response->assertStatus(401); + + $user->forceDelete(); + } +} diff --git a/backend/test/Cases/Integration/Auth/ApiKeyToggleTest.php b/backend/test/Cases/Integration/Auth/ApiKeyToggleTest.php new file mode 100644 index 0000000..f9eb0f4 --- /dev/null +++ b/backend/test/Cases/Integration/Auth/ApiKeyToggleTest.php @@ -0,0 +1,164 @@ +create(array_merge([ + 'username' => 'toggle_test_' . $suffix, + 'password' => 'Pass_' . $suffix, + 'email' => 'toggle_test_' . $suffix . '@example.com', + 'status' => 1, + 'api_key_enabled' => true, + ], $overrides)); + } + + protected function getAuthToken(User $user): string + { + $auth = make(AuthManager::class); + return $auth->guard('jwt')->login($user); + } + + protected function authHeaders(User $user): array + { + return ['Authorization' => 'Bearer ' . $this->getAuthToken($user)]; + } + + public function test_user_can_toggle_own_key(): void + { + $user = $this->createTestUser('own_' . uniqid()); + $result = ApiKey::generate($user->id, 'Own Toggle Key'); + $key_id = $result['api_key']->id; + + // 禁用 + $response = $this->patch('/api/v1/me/api-keys/' . $key_id . '/toggle', ['enabled' => false], $this->authHeaders($user)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertFalse($body['data']['enabled']); + + // 重新启用 + $response = $this->patch('/api/v1/me/api-keys/' . $key_id . '/toggle', ['enabled' => true], $this->authHeaders($user)); + $response->assertStatus(200); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertTrue($body['data']['enabled']); + + $user->forceDelete(); + } + + public function test_user_cannot_toggle_others_key(): void + { + $user_a = $this->createTestUser('a_' . uniqid()); + $user_b = $this->createTestUser('b_' . uniqid()); + $result = ApiKey::generate($user_b->id, 'Other User Key'); + + $response = $this->patch('/api/v1/me/api-keys/' . $result['api_key']->id . '/toggle', ['enabled' => false], $this->authHeaders($user_a)); + $response->assertStatus(404); + + $user_a->forceDelete(); + $user_b->forceDelete(); + } + + public function test_toggle_nonexistent_key_returns_404(): void + { + $user = $this->createTestUser('nokey_' . uniqid()); + + $response = $this->patch('/api/v1/me/api-keys/999999/toggle', ['enabled' => false], $this->authHeaders($user)); + $response->assertStatus(404); + + $user->forceDelete(); + } + + public function test_user_cannot_create_duplicate_name(): void + { + $user = $this->createTestUser('dup_' . uniqid()); + + // 创建第一个 Key + $response = $this->post('/api/v1/me/api-keys', ['name' => 'Duplicate Name'], $this->authHeaders($user)); + $response->assertStatus(200); + + // 创建同名 Key + $response = $this->post('/api/v1/me/api-keys', ['name' => 'Duplicate Name'], $this->authHeaders($user)); + $response->assertStatus(400); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertStringContainsString('已存在同名', $body['message']); + + $user->forceDelete(); + } + + public function test_user_can_reuse_name_after_delete(): void + { + $user = $this->createTestUser('reuse_' . uniqid()); + + // 创建 Key + $response = $this->post('/api/v1/me/api-keys', ['name' => 'Reuse Name'], $this->authHeaders($user)); + $response->assertStatus(200); + $body = json_decode($response->getBody()->getContents(), true); + $key_id = $body['data']['api_key']['id']; + + // 删除 + $response = $this->delete('/api/v1/me/api-keys/' . $key_id, [], $this->authHeaders($user)); + $response->assertStatus(200); + + // 复用名称创建新 Key + $response = $this->post('/api/v1/me/api-keys', ['name' => 'Reuse Name'], $this->authHeaders($user)); + $response->assertStatus(200); + + $user->forceDelete(); + } + + public function test_user_cannot_create_more_than_10_keys(): void + { + $user = $this->createTestUser('limit_' . uniqid()); + + // 通过模型直接创建 10 个 Key + for ($i = 1; $i <= 10; $i++) { + ApiKey::generate($user->id, 'Key ' . $i); + } + + // 尝试创建第 11 个 + $response = $this->post('/api/v1/me/api-keys', ['name' => 'Key 11'], $this->authHeaders($user)); + $response->assertStatus(400); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertStringContainsString('最多创建 10 个', $body['message']); + + $user->forceDelete(); + } + + public function test_user_can_create_after_deleting_to_below_limit(): void + { + $user = $this->createTestUser('dellimit_' . uniqid()); + + // 创建 10 个 Key + $results = []; + for ($i = 1; $i <= 10; $i++) { + $results[] = ApiKey::generate($user->id, 'Key ' . $i); + } + + // 删除一个 + $this->delete('/api/v1/me/api-keys/' . $results[0]['api_key']->id, [], $this->authHeaders($user)); + + // 现在可以创建新的 + $response = $this->post('/api/v1/me/api-keys', ['name' => 'New Key'], $this->authHeaders($user)); + $response->assertStatus(200); + + $user->forceDelete(); + } +} diff --git a/backend/test/Cases/Integration/User/UserApiKeyEnabledTest.php b/backend/test/Cases/Integration/User/UserApiKeyEnabledTest.php new file mode 100644 index 0000000..01c0378 --- /dev/null +++ b/backend/test/Cases/Integration/User/UserApiKeyEnabledTest.php @@ -0,0 +1,112 @@ +fetchAdminRole(); + $user = User::query() + ->where('status', 1) + ->where('role_id', $admin_role->id) + ->first(); + if (!$user) { + $this->markTestSkipped('没有可用的 administrator 用户,无法测试'); + } + + $auth = make(AuthManager::class); + return $auth->guard('jwt')->login($user); + } + + protected function fetchAdminRole(): Role + { + return Role::query()->where('name', 'administrator')->firstOrFail(); + } + + protected function adminHeaders(): array + { + return ['Authorization' => 'Bearer ' . $this->getAdminAuthToken()]; + } + + protected function createTestUser(string $suffix, array $overrides = []): User + { + return User::query()->create(array_merge([ + 'username' => 'ake_test_' . $suffix, + 'password' => 'Pass_' . $suffix, + 'email' => 'ake_test_' . $suffix . '@example.com', + 'status' => 1, + 'api_key_enabled' => true, + ], $overrides)); + } + + protected function getNonAdminHeaders(): array + { + $user = $this->createTestUser('nonadmin_' . uniqid()); + $auth = make(AuthManager::class); + $token = $auth->guard('jwt')->login($user); + return ['Authorization' => 'Bearer ' . $token]; + } + + public function test_admin_can_enable_user_api_key(): void + { + $user = $this->createTestUser('enable_' . uniqid(), ['api_key_enabled' => false]); + + $response = $this->patch('/api/v1/users/' . $user->id . '/api-key-enabled', ['api_key_enabled' => true], $this->adminHeaders()); + + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertTrue($body['data']['api_key_enabled']); + $this->assertEquals($user->username, $body['data']['username']); + + $user->forceDelete(); + } + + public function test_admin_can_disable_user_api_key(): void + { + $user = $this->createTestUser('disable_' . uniqid(), ['api_key_enabled' => true]); + + $response = $this->patch('/api/v1/users/' . $user->id . '/api-key-enabled', ['api_key_enabled' => false], $this->adminHeaders()); + + $response->assertStatus(200); + $body = json_decode($response->getBody()->getContents(), true); + $this->assertFalse($body['data']['api_key_enabled']); + + $user->forceDelete(); + } + + public function test_non_admin_cannot_update_api_key_enabled(): void + { + $target = $this->createTestUser('target_' . uniqid()); + $headers = $this->getNonAdminHeaders(); + + $response = $this->patch('/api/v1/users/' . $target->id . '/api-key-enabled', ['api_key_enabled' => false], $headers); + + $response->assertStatus(403); + + $target->forceDelete(); + } + + public function test_update_nonexistent_user_returns_404(): void + { + $response = $this->patch('/api/v1/users/999999/api-key-enabled', ['api_key_enabled' => false], $this->adminHeaders()); + + $response->assertStatus(404); + } +}