diff --git a/backend/app/Middleware/PermissionMiddleware.php b/backend/app/Middleware/PermissionMiddleware.php index 06e6bc8..de0166c 100644 --- a/backend/app/Middleware/PermissionMiddleware.php +++ b/backend/app/Middleware/PermissionMiddleware.php @@ -12,6 +12,7 @@ use App\Service\ScopeBitmapService; use App\Service\ScopeTableManager; use Hyperf\DbConnection\Db; use Hyperf\HttpServer\Contract\ResponseInterface as HttpResponse; +use Hyperf\HttpServer\Router\Dispatched; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -33,7 +34,7 @@ class PermissionMiddleware implements MiddlewareInterface // 获取已认证用户(由 AuthMiddleware 预先认证) $user = $this->auth->guard('jwt')->user(); if (!$user) { - return $handler->handle($request); + return $this->forbiddenResponse('用户认证异常'); } // 获取用户 scope(含角色和 bitmap) @@ -44,11 +45,14 @@ class PermissionMiddleware implements MiddlewareInterface $role = $user_scope['role']; $method = $request->getMethod(); - $path = $request->getUri()->getPath(); + + // 通过 Dispatched 获取路由模板路径(如 /api/v1/users/{id}),解决参数化路由匹配问题 + $dispatched = $request->getAttribute(Dispatched::class); + $route_path = $dispatched?->handler?->route ?? $request->getUri()->getPath(); // ===== Step 1: 路由访问检查 ===== if ($role !== 'administrator') { - $access_result = $this->checkRouteAccess($user->role_id, $method, $path); + $access_result = $this->checkRouteAccess($user->role_id, $method, $route_path); if ($access_result === false) { return $this->forbiddenResponse('无权访问该接口'); } @@ -76,8 +80,8 @@ class PermissionMiddleware implements MiddlewareInterface // 查找路由记录 $route = Route::query()->where('method', $method)->where('path', $path)->first(); if (!$route) { - // 未注册到 routes 表的路由默认放行 - return true; + // 白名单模式:未注册到 routes 表的路由拒绝访问 + return false; } // 1. 先查 override(优先级最高) diff --git a/backend/test/Cases/Integration/Permission/PermissionFlowTest.php b/backend/test/Cases/Integration/Permission/PermissionFlowTest.php new file mode 100644 index 0000000..101957d --- /dev/null +++ b/backend/test/Cases/Integration/Permission/PermissionFlowTest.php @@ -0,0 +1,226 @@ +guard('jwt')->login($user); + } + + protected function authHeaders(User $user): array + { + return ['Authorization' => 'Bearer ' . $this->getAuthToken($user)]; + } + + protected function createTestUser(string $role_name, array $overrides = []): User + { + $role = Role::query()->where('name', $role_name)->firstOrFail(); + $suffix = bin2hex(random_bytes(4)); + + return User::query()->create(array_merge([ + 'username' => 'integ_perm_' . $suffix, + 'email' => 'integ_perm_' . $suffix . '@example.com', + 'password' => 'Pass_' . $suffix, + 'status' => 1, + 'role_id' => $role->id, + ], $overrides)); + } + + /** + * 为指定角色授权指定路由的路由组 + * + * @return array{group: RouteGroup, old_group_id: ?int} 用于清理 + */ + protected function authorizeRouteGroup(int $role_id, Route $route): array + { + $group = RouteGroup::query()->create([ + 'name' => 'integ_test_' . uniqid(), + 'label' => '集成测试路由组', + ]); + + $old_group_id = $route->group_id; + $route->group_id = $group->id; + $route->save(); + + Db::table('role_route_groups')->insert([ + 'role_id' => $role_id, + 'group_id' => $group->id, + ]); + + return ['group' => $group, 'old_group_id' => $old_group_id]; + } + + /** + * 清理路由组授权 + */ + protected function cleanupRouteGroup(int $role_id, Route $route, array $auth_data): void + { + Db::table('role_route_groups') + ->where('role_id', $role_id) + ->where('group_id', $auth_data['group']->id) + ->delete(); + $route->group_id = $auth_data['old_group_id']; + $route->save(); + $auth_data['group']->delete(); + } + + // ========== 测试用例 ========== + + public function test_admin_full_access(): void + { + $user = $this->createTestUser('administrator'); + + // administrator 可访问全部 UserController 端点 + $response = $this->get('/api/v1/users', [], $this->authHeaders($user)); + $response->assertStatus(200); + $response->assertJsonPath('code', 0); + + // 参数化路由 + $response = $this->get('/api/v1/users/1', [], $this->authHeaders($user)); + $this->assertContains($response->getStatusCode(), [200, 404]); + } + + public function test_developer_with_group_auth_access(): void + { + $user = $this->createTestUser('developer'); + + $route = Route::query()->where('method', 'GET')->where('path', '/api/v1/users')->first(); + if (!$route) { + $this->markTestSkipped('routes 表中无 GET /api/v1/users 路由'); + } + + $auth_data = $this->authorizeRouteGroup($user->role_id, $route); + + try { + $response = $this->get('/api/v1/users', [], $this->authHeaders($user)); + $response->assertStatus(200); + } finally { + $this->cleanupRouteGroup($user->role_id, $route, $auth_data); + } + } + + public function test_accessor_without_auth_denied(): void + { + $user = $this->createTestUser('accessor'); + + // accessor 无任何路由授权 → 403 + $response = $this->get('/api/v1/users', [], $this->authHeaders($user)); + $response->assertStatus(403); + } + + public function test_accessor_with_override_access(): void + { + $user = $this->createTestUser('accessor'); + + $route = Route::query()->where('method', 'GET')->where('path', '/api/v1/users')->first(); + if (!$route) { + $this->markTestSkipped('routes 表中无 GET /api/v1/users 路由'); + } + + // 添加 store scope 以通过数据范围检查 + $store = Store::query()->first(); + if ($store) { + UserDataScope::query()->create([ + 'user_id' => $user->id, + 'scope_type' => 'store', + 'scope_id' => $store->id, + ]); + } + + // 设置 override 允许访问 + RoleRouteOverride::query()->create([ + 'role_id' => $user->role_id, + 'route_id' => $route->id, + 'allowed' => true, + ]); + + try { + $response = $this->get('/api/v1/users', [], $this->authHeaders($user)); + $response->assertStatus(200); + } finally { + RoleRouteOverride::query() + ->where('role_id', $user->role_id) + ->where('route_id', $route->id) + ->delete(); + UserDataScope::query()->where('user_id', $user->id)->delete(); + } + } + + public function test_override_deny_overrides_group(): void + { + $user = $this->createTestUser('developer'); + + $route = Route::query()->where('method', 'GET')->where('path', '/api/v1/users')->first(); + if (!$route) { + $this->markTestSkipped('routes 表中无 GET /api/v1/users 路由'); + } + + // 授权路由组 + $auth_data = $this->authorizeRouteGroup($user->role_id, $route); + + // 设置 override 为拒绝(优先级高于 group) + RoleRouteOverride::query()->create([ + 'role_id' => $user->role_id, + 'route_id' => $route->id, + 'allowed' => false, + ]); + + try { + $response = $this->get('/api/v1/users', [], $this->authHeaders($user)); + $response->assertStatus(403); + } finally { + RoleRouteOverride::query() + ->where('role_id', $user->role_id) + ->where('route_id', $route->id) + ->delete(); + $this->cleanupRouteGroup($user->role_id, $route, $auth_data); + } + } + + public function test_developer_scope_param_validation(): void + { + $user = $this->createTestUser('developer'); + + $route = Route::query()->where('method', 'GET')->where('path', '/api/v1/users')->first(); + if (!$route) { + $this->markTestSkipped('routes 表中无 GET /api/v1/users 路由'); + } + + // 授权路由访问 + $auth_data = $this->authorizeRouteGroup($user->role_id, $route); + + try { + // 传不属于自己的 platform_id → 403 + $response = $this->get('/api/v1/users', ['platform_id' => 999999], $this->authHeaders($user)); + $response->assertStatus(403); + } finally { + $this->cleanupRouteGroup($user->role_id, $route, $auth_data); + } + } +} diff --git a/backend/test/Cases/Unit/Middleware/PermissionMiddlewareTest.php b/backend/test/Cases/Unit/Middleware/PermissionMiddlewareTest.php index 8552310..823d831 100644 --- a/backend/test/Cases/Unit/Middleware/PermissionMiddlewareTest.php +++ b/backend/test/Cases/Unit/Middleware/PermissionMiddlewareTest.php @@ -260,4 +260,95 @@ class PermissionMiddlewareTest extends TestCase $response->assertStatus(200); $response->assertJsonPath('code', 0); } + + // ========== 安全修复测试 ========== + + public function test_unregistered_route_denied_for_non_admin(): void + { + $user = $this->createTestUser('developer'); + + // 从 routes 表删除 GET /api/v1/users 记录,模拟未注册场景 + $route = Route::query()->where('method', 'GET')->where('path', '/api/v1/users')->first(); + if (!$route) { + $this->markTestSkipped('routes 表中无 GET /api/v1/users 路由'); + } + + $route_data = $route->toArray(); + $route->delete(); + + try { + // 白名单模式:routes 表中无记录 → 403 + $response = $this->get('/api/v1/users', [], $this->authHeaders($user)); + $response->assertStatus(403); + } finally { + // 恢复路由记录 + Route::query()->create($route_data); + } + } + + public function test_admin_allowed_on_unregistered_route(): void + { + $user = $this->createTestUser('administrator'); + + // 从 routes 表删除 GET /api/v1/users 记录 + $route = Route::query()->where('method', 'GET')->where('path', '/api/v1/users')->first(); + if (!$route) { + $this->markTestSkipped('routes 表中无 GET /api/v1/users 路由'); + } + + $route_data = $route->toArray(); + $route->delete(); + + try { + // administrator 跳过路由检查,即使路由未在 routes 表中也应正常访问 + $response = $this->get('/api/v1/users', [], $this->authHeaders($user)); + $response->assertStatus(200); + } finally { + // 恢复路由记录 + Route::query()->create($route_data); + } + } + + public function test_parametric_route_matches_template_path(): void + { + $user = $this->createTestUser('developer'); + + $route = Route::query() + ->where('method', 'GET') + ->where('path', '/api/v1/users/{id}') + ->first(); + + if (!$route) { + $this->markTestSkipped('routes 表中无 GET /api/v1/users/{id} 路由'); + } + + // 创建路由组并授权 + $group = RouteGroup::query()->create([ + 'name' => 'test_param_route_' . uniqid(), + 'label' => '参数化路由测试', + ]); + + $old_group_id = $route->group_id; + $route->group_id = $group->id; + $route->save(); + + Db::table('role_route_groups')->insert([ + 'role_id' => $user->role_id, + 'group_id' => $group->id, + ]); + + // 访问参数化路由 /api/v1/users/1(应匹配模板路径 /api/v1/users/{id}) + $response = $this->get('/api/v1/users/1', [], $this->authHeaders($user)); + // 路由检查应通过(200 或 404 取决于用户是否存在,但不应是 403) + $this->assertNotEquals(403, $response->getStatusCode()); + + // 清理 + Db::table('role_route_groups') + ->where('role_id', $user->role_id) + ->where('group_id', $group->id) + ->delete(); + $route->group_id = $old_group_id; + $route->save(); + $group->delete(); + } }