diff --git a/jeecg-boot/db/jeecgboot-mysql-5.7.sql b/jeecg-boot/db/jeecgboot-mysql-5.7.sql index 65110b5b7e6..2ba1fa54503 100644 --- a/jeecg-boot/db/jeecgboot-mysql-5.7.sql +++ b/jeecg-boot/db/jeecgboot-mysql-5.7.sql @@ -4937,6 +4937,12 @@ CREATE TABLE `open_api` ( `update_time` datetime NULL DEFAULT NULL COMMENT '修改时间', `headers_json` json NULL COMMENT '请求头json', `params_json` json NULL COMMENT '请求参数json', + `list_mode` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WHITELIST' COMMENT '访问清单模式:WHITELIST/BLACKLIST' AFTER `request_url`, + `allowed_list` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '访问清单,多IP/CIDR/域名,逗号或换行分隔' AFTER `black_list`, + `comment` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '访问清单备注' AFTER `allowed_list`, + `dns_cache_ttl_seconds` int(11) NULL DEFAULT NULL COMMENT 'DNS缓存TTL秒' AFTER `comment`, + `ip_version` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'Dual' COMMENT 'IPv4/IPv6/Dual' AFTER `dns_cache_ttl_seconds`, + `enable_strict` tinyint(1) NOT NULL DEFAULT 0 COMMENT '严格模式开关' AFTER `ip_version`, PRIMARY KEY (`id`) USING BTREE ) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '接口表' ROW_FORMAT = DYNAMIC; @@ -4944,7 +4950,14 @@ CREATE TABLE `open_api` ( -- Records of open_api -- ---------------------------- INSERT INTO `open_api` VALUES ('1922132683346649090', '根据部门查询用户', 'GET', 'TEwcXBlr', NULL, NULL, '/sys/user/queryUserByDepId', 1, 0, 'admin', '2025-05-13 11:31:58', 'admin', '2025-05-15 10:10:01', '[]', '[{\"id\": \"row_24\", \"note\": \"\", \"paramKey\": \"id\", \"required\": \"1\", \"defaultValue\": \"\"}]'); - +-- 数据迁移:老记录list_mode统一置为WHITELIST +UPDATE `open_api` +SET `list_mode` = 'WHITELIST' +WHERE `list_mode` IS NULL OR `list_mode` = ''; + +-- 索引:最近变更排序 +ALTER TABLE `open_api` + ADD INDEX `idx_open_api_update_time` (`update_time`); -- ---------------------------- -- Table structure for open_api_auth -- ---------------------------- diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/pom.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/pom.xml index 2a01534f8be..930fa52de04 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/pom.xml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/pom.xml @@ -75,4 +75,81 @@ + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + repackage + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + **/*Test.java + **/*Tests.java + + + test + + + + + + + org.jacoco + jacoco-maven-plugin + 0.8.11 + + + prepare-agent + initialize + + prepare-agent + + + + report + test + + report + + + + check + test + + check + + + + + BUNDLE + + + INSTRUCTION + COVEREDRATIO + 0.70 + + + + + + + + + + + diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java index 05b4fe7202b..35b64e4dd64 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/entity/OpenApi.java @@ -96,6 +96,36 @@ public class OpenApi implements Serializable { * 更新时间 */ private Date updateTime; + + /** + * 访问模式:WHITELIST/BLACKLIST + */ + private String listMode; + + /** + * 允许/拒绝清单,支持IP、CIDR、域名 + */ + private String allowedList; + + /** + * 清单备注 + */ + private String comment; + + /** + * 严格模式开关 + */ + private Boolean enableStrict; + + /** + * DNS缓存TTL(秒) + */ + private Integer dnsCacheTtlSeconds; + + /** + * IP版本:IPv4/IPv6/Dual + */ + private String ipVersion; /** * 历史已选接口 */ diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java index a301d13b6c7..5b2687b017e 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/openapi/filter/ApiAuthFilter.java @@ -17,9 +17,12 @@ import java.io.IOException; import java.security.MessageDigest; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.util.Arrays; import java.util.Date; import java.util.List; +import java.util.stream.Collectors; /** * @date 2024/12/19 16:55 @@ -46,8 +49,8 @@ public void doFilter(ServletRequest servletRequest, ServletResponse servletRespo OpenApi openApi = findOpenApi(request); - // IP 黑名单核验 - checkBlackList(openApi, ip); + // 访问清单核验 + checkAccessList(openApi, ip); // 签名核验 checkSignValid(appkey, signature, timestamp); @@ -81,21 +84,123 @@ public void init(FilterConfig filterConfig) throws ServletException { } /** - * IP 黑名单核验 + * 访问清单核验 * @param openApi * @param ip */ - protected void checkBlackList(OpenApi openApi, String ip) { - if (!StringUtils.hasText(openApi.getBlackList())) { - return; + protected void checkAccessList(OpenApi openApi, String ip) { + // 获取访问模式,默认白名单 + String listMode = StringUtils.hasText(openApi.getListMode()) ? openApi.getListMode() : "WHITELIST"; + // 检查列表模式是否有效,无效模式默认为白名单 + if (!"WHITELIST".equals(listMode) && !"BLACKLIST".equals(listMode)) { + listMode = "WHITELIST"; } + String allowedList = openApi.getAllowedList(); - List blackList = Arrays.asList(openApi.getBlackList().split(",")); - if (blackList.contains(ip)) { - throw new JeecgBootException("目标接口限制IP[" + ip + "]进行访问,IP已记录,请停止访问"); + // 如果清单为空,根据模式处理 + if (!StringUtils.hasText(allowedList)) { + if ("WHITELIST".equals(listMode)) { + // 白名单模式下清单为空,拒绝访问 + throw new JeecgBootException("目标接口白名单为空,拒绝访问"); + } else { + // 黑名单模式下清单为空,无屏蔽项,放行 + return; + } + } + + // 解析清单 + List listItems = Arrays.asList(allowedList.split("[,\\n]")).stream() + .map(String::trim) + .filter(StringUtils::hasText) + .collect(Collectors.toList()); + + // 匹配结果 + boolean isMatch = false; + for (String item : listItems) { + if (isIpMatch(ip, item) || isDomainMatch(ip, item)) { + isMatch = true; + break; + } + } + + // 根据模式判断是否放行 + if ("WHITELIST".equals(listMode)) { + if (!isMatch) { + throw new JeecgBootException("目标接口限制IP[" + ip + "]进行访问,IP已记录,请停止访问"); + } + } else { + if (isMatch) { + throw new JeecgBootException("目标接口限制IP[" + ip + "]进行访问,IP已记录,请停止访问"); + } + } + } + + /** + * IP/CIDR匹配 + * @param ip + * @param item + * @return + */ + private boolean isIpMatch(String ip, String item) { + // 简单实现IP和CIDR匹配,实际项目中建议使用成熟的IP库 + if (item.contains("/")) { + // CIDR匹配 + String[] parts = item.split("/"); + if (parts.length != 2) { + return false; + } + String cidrIp = parts[0]; + int prefixLength = Integer.parseInt(parts[1]); + + // 这里只实现IPv4的CIDR匹配,IPv6需要更复杂的处理 + if (ip.contains(".") && cidrIp.contains(".")) { + long ipLong = ipToLong(ip); + long cidrLong = ipToLong(cidrIp); + long mask = prefixLength == 0 ? 0 : (-1L << (32 - prefixLength)); + return (ipLong & mask) == (cidrLong & mask); + } + return false; + } else { + // 精确IP匹配 + return ip.equals(item); } } + /** + * 域名匹配 + * @param ip + * @param domain + * @return + */ + private boolean isDomainMatch(String ip, String domain) { + // 简单实现域名匹配,实际项目中需要考虑DNS缓存和IPv6 + try { + InetAddress[] addresses = InetAddress.getAllByName(domain); + for (InetAddress addr : addresses) { + if (addr.getHostAddress().equals(ip)) { + return true; + } + } + } catch (UnknownHostException e) { + log.warn("DNS解析失败: {}", domain); + } + return false; + } + + /** + * IPv4转long + * @param ip + * @return + */ + private long ipToLong(String ip) { + String[] parts = ip.split("\\."); + long result = 0; + for (int i = 0; i < 4; i++) { + result |= Long.parseLong(parts[i]) << (24 - (8 * i)); + } + return result; + } + /** * 签名验证 * @param appkey diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/test/java/org/jeecg/modules/openapi/filter/ApiAuthFilterTest.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/test/java/org/jeecg/modules/openapi/filter/ApiAuthFilterTest.java new file mode 100644 index 00000000000..fa55a88e502 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/test/java/org/jeecg/modules/openapi/filter/ApiAuthFilterTest.java @@ -0,0 +1,918 @@ +package org.jeecg.modules.openapi.filter; + +import org.jeecg.common.exception.JeecgBootException; +import org.jeecg.modules.openapi.entity.OpenApi; +import org.jeecg.modules.openapi.entity.OpenApiAuth; +import org.jeecg.modules.openapi.entity.OpenApiPermission; +import org.jeecg.modules.openapi.service.OpenApiAuthService; +import org.jeecg.modules.openapi.service.OpenApiPermissionService; +import org.jeecg.modules.openapi.service.OpenApiService; +import org.jeecg.modules.openapi.service.OpenApiLogService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.ActiveProfiles; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.Arrays; +import java.util.Date; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * ApiAuthFilter单元测试 + * 测试OpenAPI的黑名单和白名单校验功能 + */ +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("API认证过滤器测试") +public class ApiAuthFilterTest { + + /** + * 测试配置类 + */ + @Configuration + static class TestConfig { + + @Bean + public ApiAuthFilter apiAuthFilter() { + return new ApiAuthFilter(); + } + + @Bean + public TestableApiAuthFilter testableApiAuthFilter() { + return new TestableApiAuthFilter(); + } + + @Bean + public OpenApiService openApiService() { + return mock(OpenApiService.class); + } + + @Bean + public OpenApiAuthService openApiAuthService() { + return mock(OpenApiAuthService.class); + } + + @Bean + public OpenApiPermissionService openApiPermissionService() { + return mock(OpenApiPermissionService.class); + } + + @Bean + public OpenApiLogService openApiLogService() { + return mock(OpenApiLogService.class); + } + } + + /** + * 子类暴露protected方法以便测试 + */ + static class TestableApiAuthFilter extends ApiAuthFilter { + public void invokeCheckAccessList(OpenApi openApi, String ip) { + super.checkAccessList(openApi, ip); + } + + public void invokeCheckSignValid(String appkey, String signature, String timestamp) { + super.checkSignValid(appkey, signature, timestamp); + } + + public void invokeCheckSignature(String appKey, String signature, String timestamp, OpenApiAuth openApiAuth) { + super.checkSignature(appKey, signature, timestamp, openApiAuth); + } + + public void invokeCheckPermission(OpenApi openApi, OpenApiAuth openApiAuth) { + super.checkPermission(openApi, openApiAuth); + } + + public OpenApi invokeFindOpenApi(HttpServletRequest request) { + return super.findOpenApi(request); + } + + public String invokeMd5(String sourceStr) { + return super.md5(sourceStr); + } + } + + @InjectMocks + private TestableApiAuthFilter filter; + + @Mock + private OpenApiService openApiService; + + @Mock + private OpenApiAuthService openApiAuthService; + + @Mock + private OpenApiPermissionService openApiPermissionService; + + @Mock + private OpenApiLogService openApiLogService; + + @BeforeEach + void setUp() { + assertNotNull(filter, "ApiAuthFilter bean should be injected"); + assertNotNull(openApiService, "OpenApiService bean should be injected"); + assertNotNull(openApiAuthService, "OpenApiAuthService bean should be injected"); + assertNotNull(openApiPermissionService, "OpenApiPermissionService bean should be injected"); + assertNotNull(openApiLogService, "OpenApiLogService bean should be injected"); + } + + private OpenApi createOpenApi(String listMode, String allowedList) { + OpenApi openApi = new OpenApi(); + openApi.setListMode(listMode.toUpperCase()); + openApi.setAllowedList(allowedList); + return openApi; + } + + // ==================== 白名单测试用例 ==================== + + @Test + @DisplayName("白名单模式 - 允许IP在白名单中") + void testWhitelist_AllowedIpInList() { + OpenApi openApi = createOpenApi("whitelist", "192.168.1.100,10.0.0.1"); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + } + + @Test + @DisplayName("白名单模式 - 拒绝IP不在白名单中") + void testWhitelist_DeniedIpNotInList() { + OpenApi openApi = createOpenApi("whitelist", "192.168.1.100,10.0.0.1"); + + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.1.200"); + }); + + assertTrue(exception.getMessage().contains("目标接口限制IP")); + } + + @Test + @DisplayName("白名单模式 - 允许IP段匹配") + void testWhitelist_AllowedIpRangeMatch() { + OpenApi openApi = createOpenApi("whitelist", "192.168.1.0/24,10.0.0.0/16"); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.1.150"); + }); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "10.0.5.100"); + }); + } + + @Test + @DisplayName("白名单模式 - 拒绝IP段不匹配") + void testWhitelist_DeniedIpRangeNotMatch() { + OpenApi openApi = createOpenApi("whitelist", "192.168.1.0/24"); + + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.2.100"); + }); + + assertTrue(exception.getMessage().contains("目标接口限制IP")); + } + + // ==================== 黑名单测试用例 ==================== + + @Test + @DisplayName("黑名单模式 - 允许IP不在黑名单中") + void testBlacklist_AllowedIpNotInList() { + OpenApi openApi = createOpenApi("blacklist", "192.168.1.100,10.0.0.1"); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.1.200"); + }); + } + + @Test + @DisplayName("黑名单模式 - 拒绝IP在黑名单中") + void testBlacklist_DeniedIpInList() { + OpenApi openApi = createOpenApi("blacklist", "192.168.1.100,10.0.0.1"); + + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + + assertTrue(exception.getMessage().contains("目标接口限制IP")); + } + + @Test + @DisplayName("黑名单模式 - 拒绝IP段匹配") + void testBlacklist_DeniedIpRangeMatch() { + OpenApi openApi = createOpenApi("blacklist", "192.168.1.0/24,10.0.0.0/16"); + + JeecgBootException exception1 = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.1.150"); + }); + assertTrue(exception1.getMessage().contains("目标接口限制IP")); + + JeecgBootException exception2 = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "10.0.5.100"); + }); + assertTrue(exception2.getMessage().contains("目标接口限制IP")); + } + + @Test + @DisplayName("黑名单模式 - 允许IP段不匹配") + void testBlacklist_AllowedIpRangeNotMatch() { + OpenApi openApi = createOpenApi("blacklist", "192.168.1.0/24"); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.2.100"); + }); + } + + // ==================== 边界条件测试用例 ==================== + + @Test + @DisplayName("白名单模式 - 空访问列表拒绝访问") + void testWhitelist_EmptyAccessList() { + OpenApi openApi = createOpenApi("whitelist", ""); + + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + + assertTrue(exception.getMessage().contains("目标接口白名单为空")); + } + + @Test + @DisplayName("黑名单模式 - 空访问列表允许访问") + void testBlacklist_EmptyAccessList() { + OpenApi openApi = createOpenApi("blacklist", ""); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + } + + @Test + @DisplayName("白名单模式 - null访问列表拒绝访问") + void testWhitelist_NullAccessList() { + OpenApi openApi = createOpenApi("whitelist", null); + + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + + assertTrue(exception.getMessage().contains("目标接口白名单为空")); + } + + @Test + @DisplayName("黑名单模式 - null访问列表允许访问") + void testBlacklist_NullAccessList() { + OpenApi openApi = createOpenApi("blacklist", null); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + } + + @Test + @DisplayName("无效列表模式 - 默认白名单行为") + void testInvalidListMode_DefaultWhitelistBehavior() { + OpenApi openApi = createOpenApi("invalid_mode", "192.168.1.100"); + + // 无效模式应该默认为白名单行为 + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.1.200"); + }); + + assertTrue(exception.getMessage().contains("目标接口限制IP")); + } + + // ==================== 复杂场景测试用例 ==================== + + @Test + @DisplayName("白名单模式 - 混合IP和CIDR格式") + void testWhitelist_MixedIpAndCidrFormat() { + OpenApi openApi = createOpenApi("whitelist", "192.168.1.100,192.168.2.0/24,10.0.0.1"); + + // 测试精确IP匹配 + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + + // 测试CIDR网段匹配 + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.2.150"); + }); + + // 测试另一个精确IP匹配 + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "10.0.0.1"); + }); + + // 测试不在列表中的IP + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.1.200"); + }); + assertTrue(exception.getMessage().contains("目标接口限制IP")); + } + + @Test + @DisplayName("黑名单模式 - 混合IP和CIDR格式") + void testBlacklist_MixedIpAndCidrFormat() { + OpenApi openApi = createOpenApi("blacklist", "192.168.1.100,192.168.2.0/24,10.0.0.1"); + + // 测试被精确IP拒绝 + JeecgBootException exception1 = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + assertTrue(exception1.getMessage().contains("目标接口限制IP")); + + // 测试被CIDR网段拒绝 + JeecgBootException exception2 = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.2.150"); + }); + assertTrue(exception2.getMessage().contains("目标接口限制IP")); + + // 测试被另一个精确IP拒绝 + JeecgBootException exception3 = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "10.0.0.1"); + }); + assertTrue(exception3.getMessage().contains("目标接口限制IP")); + + // 测试不在黑名单中的IP允许访问 + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.1.200"); + }); + } + + @Test + @DisplayName("IP列表包含空格和换行符处理") + void testIpListWithSpacesAndNewlines() { + OpenApi openApi = createOpenApi("whitelist", " 192.168.1.100 , 10.0.0.1\n, 172.16.0.1 "); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "10.0.0.1"); + }); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "172.16.0.1"); + }); + + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.1.200"); + }); + assertTrue(exception.getMessage().contains("目标接口限制IP")); + } + + @Test + @DisplayName("极端CIDR网段测试") + void testExtremeCidrRanges() { + OpenApi openApi = createOpenApi("whitelist", "0.0.0.0/0"); + + // 0.0.0.0/0 应该匹配所有IP + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "10.0.0.1"); + }); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "255.255.255.255"); + }); + } + + @Test + @DisplayName("本地回环地址测试") + void testLoopbackAddressTest() { + OpenApi openApi = createOpenApi("whitelist", "127.0.0.1"); + + assertDoesNotThrow(() -> { + filter.invokeCheckAccessList(openApi, "127.0.0.1"); + }); + + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.invokeCheckAccessList(openApi, "192.168.1.100"); + }); + assertTrue(exception.getMessage().contains("目标接口限制IP")); + } + + // ==================== doFilter 集成测试用例 ==================== + + @Test + @DisplayName("doFilter - 正常流程测试") + void testDoFilter_NormalFlow() throws Exception { + // 准备测试数据 + String appKey = "test-app-key"; + String secretKey = "test-secret-key"; + long timestamp = System.currentTimeMillis(); + String signature = filter.invokeMd5(appKey + secretKey + timestamp); + + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 + when(request.getRemoteAddr()).thenReturn("192.168.1.100"); + when(request.getHeader("appkey")).thenReturn(appKey); + when(request.getHeader("signature")).thenReturn(signature); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp)); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("WHITELIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 创建OpenApiAuth对象 + OpenApiAuth openApiAuth = new OpenApiAuth(); + openApiAuth.setId("test-auth-id"); + openApiAuth.setAk(appKey); + openApiAuth.setSk(secretKey); + when(openApiAuthService.getByAppkey(appKey)).thenReturn(openApiAuth); + + // 创建权限对象 + OpenApiPermission permission = new OpenApiPermission(); + permission.setApiId("test-api-id"); + permission.setApiAuthId("test-auth-id"); + when(openApiPermissionService.findByAuthId("test-auth-id")).thenReturn(Arrays.asList(permission)); + + // 执行doFilter + assertDoesNotThrow(() -> { + filter.doFilter(request, response, filterChain); + }); + + // 验证filterChain被调用 + verify(filterChain, times(1)).doFilter(request, response); + + // 验证日志被保存 + verify(openApiLogService, times(1)).save(any()); + } + + @Test + @DisplayName("doFilter - IP白名单验证失败") + void testDoFilter_WhitelistValidationFailed() throws Exception { + // 准备测试数据 + String appKey = "test-app-key"; + String secretKey = "test-secret-key"; + long timestamp = System.currentTimeMillis(); + String signature = filter.invokeMd5(appKey + secretKey + timestamp); + + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 - IP不在白名单中 + when(request.getRemoteAddr()).thenReturn("192.168.1.200"); + when(request.getHeader("appkey")).thenReturn(appKey); + when(request.getHeader("signature")).thenReturn(signature); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp)); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 - 白名单只包含192.168.1.100 + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("WHITELIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 执行doFilter,应该抛出异常 + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.doFilter(request, response, filterChain); + }); + + assertTrue(exception.getMessage().contains("目标接口限制IP")); + + // 验证filterChain未被调用 + verify(filterChain, never()).doFilter(request, response); + } + + @Test + @DisplayName("doFilter - 签名验证失败 - appkey为空") + void testDoFilter_SignValidationFailed_AppKeyEmpty() throws Exception { + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 - appkey为空 + when(request.getRemoteAddr()).thenReturn("192.168.1.100"); + when(request.getHeader("appkey")).thenReturn(null); + when(request.getHeader("signature")).thenReturn("test-signature"); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(System.currentTimeMillis())); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("WHITELIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 执行doFilter,应该抛出异常 + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.doFilter(request, response, filterChain); + }); + + assertTrue(exception.getMessage().contains("appkey为空")); + + // 验证filterChain未被调用 + verify(filterChain, never()).doFilter(request, response); + } + + @Test + @DisplayName("doFilter - 签名验证失败 - signature为空") + void testDoFilter_SignValidationFailed_SignatureEmpty() throws Exception { + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 - signature为空 + when(request.getRemoteAddr()).thenReturn("192.168.1.100"); + when(request.getHeader("appkey")).thenReturn("test-app-key"); + when(request.getHeader("signature")).thenReturn(null); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(System.currentTimeMillis())); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("WHITELIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 执行doFilter,应该抛出异常 + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.doFilter(request, response, filterChain); + }); + + assertTrue(exception.getMessage().contains("signature为空")); + + // 验证filterChain未被调用 + verify(filterChain, never()).doFilter(request, response); + } + + @Test + @DisplayName("doFilter - 签名验证失败 - timestamp为空") + void testDoFilter_SignValidationFailed_TimestampEmpty() throws Exception { + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 - timestamp为空 + when(request.getRemoteAddr()).thenReturn("192.168.1.100"); + when(request.getHeader("appkey")).thenReturn("test-app-key"); + when(request.getHeader("signature")).thenReturn("test-signature"); + when(request.getHeader("timestamp")).thenReturn(null); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("WHITELIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 执行doFilter,应该抛出异常 + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.doFilter(request, response, filterChain); + }); + + assertTrue(exception.getMessage().contains("timastamp时间戳为空")); + + // 验证filterChain未被调用 + verify(filterChain, never()).doFilter(request, response); + } + + @Test + @DisplayName("doFilter - 签名验证失败 - timestamp过期") + void testDoFilter_SignValidationFailed_TimestampExpired() throws Exception { + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 - timestamp过期(超过5分钟) + long expiredTimestamp = System.currentTimeMillis() - 6 * 60 * 1000; // 6分钟前 + when(request.getRemoteAddr()).thenReturn("192.168.1.100"); + when(request.getHeader("appkey")).thenReturn("test-app-key"); + when(request.getHeader("signature")).thenReturn("test-signature"); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(expiredTimestamp)); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("WHITELIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 执行doFilter,应该抛出异常 + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.doFilter(request, response, filterChain); + }); + + assertTrue(exception.getMessage().contains("signature签名已过期")); + + // 验证filterChain未被调用 + verify(filterChain, never()).doFilter(request, response); + } + + @Test + @DisplayName("doFilter - 认证信息验证失败 - appkey不存在") + void testDoFilter_AuthValidationFailed_AppKeyNotFound() throws Exception { + // 准备测试数据 + String appKey = "non-existent-app-key"; + long timestamp = System.currentTimeMillis(); + + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 + when(request.getRemoteAddr()).thenReturn("192.168.1.100"); + when(request.getHeader("appkey")).thenReturn(appKey); + when(request.getHeader("signature")).thenReturn("test-signature"); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp)); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("WHITELIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 模拟appkey不存在 + when(openApiAuthService.getByAppkey(appKey)).thenReturn(null); + + // 执行doFilter,应该抛出异常 + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.doFilter(request, response, filterChain); + }); + + assertTrue(exception.getMessage().contains("不存在认证信息")); + + // 验证filterChain未被调用 + verify(filterChain, never()).doFilter(request, response); + } + + @Test + @DisplayName("doFilter - 认证信息验证失败 - appkey错误") + void testDoFilter_AuthValidationFailed_AppKeyMismatch() throws Exception { + // 准备测试数据 + String requestAppKey = "test-app-key"; + String storedAppKey = "different-app-key"; + String secretKey = "test-secret-key"; + long timestamp = System.currentTimeMillis(); + String signature = filter.invokeMd5(requestAppKey + secretKey + timestamp); + + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 + when(request.getRemoteAddr()).thenReturn("192.168.1.100"); + when(request.getHeader("appkey")).thenReturn(requestAppKey); + when(request.getHeader("signature")).thenReturn(signature); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp)); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("WHITELIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 创建OpenApiAuth对象 - 存储的appkey与请求的不匹配 + OpenApiAuth openApiAuth = new OpenApiAuth(); + openApiAuth.setId("test-auth-id"); + openApiAuth.setAk(storedAppKey); // 不同的appkey + openApiAuth.setSk(secretKey); + when(openApiAuthService.getByAppkey(requestAppKey)).thenReturn(openApiAuth); + + // 执行doFilter,应该抛出异常 + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.doFilter(request, response, filterChain); + }); + + assertTrue(exception.getMessage().contains("appkey错误")); + + // 验证filterChain未被调用 + verify(filterChain, never()).doFilter(request, response); + } + + @Test + @DisplayName("doFilter - 认证信息验证失败 - 签名错误") + void testDoFilter_AuthValidationFailed_SignatureMismatch() throws Exception { + // 准备测试数据 + String appKey = "test-app-key"; + String secretKey = "test-secret-key"; + long timestamp = System.currentTimeMillis(); + String wrongSignature = "wrong-signature"; + + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 - 错误的签名 + when(request.getRemoteAddr()).thenReturn("192.168.1.100"); + when(request.getHeader("appkey")).thenReturn(appKey); + when(request.getHeader("signature")).thenReturn(wrongSignature); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp)); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("WHITELIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 创建OpenApiAuth对象 + OpenApiAuth openApiAuth = new OpenApiAuth(); + openApiAuth.setId("test-auth-id"); + openApiAuth.setAk(appKey); + openApiAuth.setSk(secretKey); + when(openApiAuthService.getByAppkey(appKey)).thenReturn(openApiAuth); + + // 执行doFilter,应该抛出异常 + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.doFilter(request, response, filterChain); + }); + + assertTrue(exception.getMessage().contains("signature签名错误")); + + // 验证filterChain未被调用 + verify(filterChain, never()).doFilter(request, response); + } + + @Test + @DisplayName("doFilter - 权限验证失败 - 无权限访问接口") + void testDoFilter_PermissionValidationFailed_NoPermission() throws Exception { + // 准备测试数据 + String appKey = "test-app-key"; + String secretKey = "test-secret-key"; + long timestamp = System.currentTimeMillis(); + String signature = filter.invokeMd5(appKey + secretKey + timestamp); + + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 + when(request.getRemoteAddr()).thenReturn("192.168.1.100"); + when(request.getHeader("appkey")).thenReturn(appKey); + when(request.getHeader("signature")).thenReturn(signature); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp)); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("WHITELIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 创建OpenApiAuth对象 + OpenApiAuth openApiAuth = new OpenApiAuth(); + openApiAuth.setId("test-auth-id"); + openApiAuth.setAk(appKey); + openApiAuth.setSk(secretKey); + when(openApiAuthService.getByAppkey(appKey)).thenReturn(openApiAuth); + + // 模拟无权限 - 返回空列表或不包含当前API权限的列表 + OpenApiPermission differentPermission = new OpenApiPermission(); + differentPermission.setApiId("different-api-id"); + differentPermission.setApiAuthId("test-auth-id"); + when(openApiPermissionService.findByAuthId("test-auth-id")).thenReturn(Arrays.asList(differentPermission)); + + // 执行doFilter,应该抛出异常 + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.doFilter(request, response, filterChain); + }); + + assertTrue(exception.getMessage().contains("该appKey未授权当前接口")); + + // 验证filterChain未被调用 + verify(filterChain, never()).doFilter(request, response); + } + + @Test + @DisplayName("doFilter - 黑名单模式 - IP在黑名单中被拒绝") + void testDoFilter_Blacklist_IpInBlacklist() throws Exception { + // 准备测试数据 + String appKey = "test-app-key"; + String secretKey = "test-secret-key"; + long timestamp = System.currentTimeMillis(); + String signature = filter.invokeMd5(appKey + secretKey + timestamp); + + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 - IP在黑名单中 + when(request.getRemoteAddr()).thenReturn("192.168.1.100"); + when(request.getHeader("appkey")).thenReturn(appKey); + when(request.getHeader("signature")).thenReturn(signature); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp)); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 - 黑名单模式,包含该IP + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("BLACKLIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 执行doFilter,应该抛出异常 + JeecgBootException exception = assertThrows(JeecgBootException.class, () -> { + filter.doFilter(request, response, filterChain); + }); + + assertTrue(exception.getMessage().contains("目标接口限制IP")); + + // 验证filterChain未被调用 + verify(filterChain, never()).doFilter(request, response); + } + + @Test + @DisplayName("doFilter - 黑名单模式 - IP不在黑名单中允许访问") + void testDoFilter_Blacklist_IpNotInBlacklist() throws Exception { + // 准备测试数据 + String appKey = "test-app-key"; + String secretKey = "test-secret-key"; + long timestamp = System.currentTimeMillis(); + String signature = filter.invokeMd5(appKey + secretKey + timestamp); + + // 创建模拟对象 + HttpServletRequest request = mock(HttpServletRequest.class); + ServletResponse response = mock(ServletResponse.class); + FilterChain filterChain = mock(FilterChain.class); + + // 设置请求参数 - IP不在黑名单中 + when(request.getRemoteAddr()).thenReturn("192.168.1.200"); + when(request.getHeader("appkey")).thenReturn(appKey); + when(request.getHeader("signature")).thenReturn(signature); + when(request.getHeader("timestamp")).thenReturn(String.valueOf(timestamp)); + when(request.getRequestURI()).thenReturn("/api/test"); + + // 创建OpenApi对象 - 黑名单模式,不包含该IP + OpenApi openApi = new OpenApi(); + openApi.setId("test-api-id"); + openApi.setListMode("BLACKLIST"); + openApi.setAllowedList("192.168.1.100"); + when(openApiService.findByPath("test")).thenReturn(openApi); + + // 创建OpenApiAuth对象 + OpenApiAuth openApiAuth = new OpenApiAuth(); + openApiAuth.setId("test-auth-id"); + openApiAuth.setAk(appKey); + openApiAuth.setSk(secretKey); + when(openApiAuthService.getByAppkey(appKey)).thenReturn(openApiAuth); + + // 创建权限对象 + OpenApiPermission permission = new OpenApiPermission(); + permission.setApiId("test-api-id"); + permission.setApiAuthId("test-auth-id"); + when(openApiPermissionService.findByAuthId("test-auth-id")).thenReturn(Arrays.asList(permission)); + + // 执行doFilter + assertDoesNotThrow(() -> { + filter.doFilter(request, response, filterChain); + }); + + // 验证filterChain被调用 + verify(filterChain, times(1)).doFilter(request, response); + + // 验证日志被保存 + verify(openApiLogService, times(1)).save(any()); + } +} \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml index 09e1b3487c9..d25385c2cbd 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/application-dev.yml @@ -151,7 +151,7 @@ spring: master: url: jdbc:mysql://127.0.0.1:3306/jeecg-boot?characterEncoding=UTF-8&useUnicode=true&useSSL=false&tinyInt1isBit=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai username: root - password: root + password: bd198127 driver-class-name: com.mysql.cj.jdbc.Driver # # shardingjdbc数据源 # sharding-db: diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.8.3_1__openapi_accesslist.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.8.3_1__openapi_accesslist.sql new file mode 100644 index 00000000000..33226351e33 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.8.3_1__openapi_accesslist.sql @@ -0,0 +1,38 @@ +/* + OpenApi 访问清单字段增量升级 + - 新增列:list_mode, allowed_list, comment, dns_cache_ttl_seconds, ip_version, enable_strict + - 索引:idx_open_api_update_time + - 数据迁移:老记录 list_mode 置 WHITELIST +*/ +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- 添加新字段 +ALTER TABLE `open_api` + ADD COLUMN `list_mode` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WHITELIST' COMMENT '访问清单模式:WHITELIST/BLACKLIST' AFTER `request_url`, + ADD COLUMN `allowed_list` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '访问清单,多IP/CIDR/域名,逗号或换行分隔' AFTER `black_list`, + ADD COLUMN `comment` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '访问清单备注' AFTER `allowed_list`, + ADD COLUMN `dns_cache_ttl_seconds` int(11) NULL DEFAULT NULL COMMENT 'DNS缓存TTL秒' AFTER `comment`, + ADD COLUMN `ip_version` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT 'Dual' COMMENT 'IPv4/IPv6/Dual' AFTER `dns_cache_ttl_seconds`, + ADD COLUMN `enable_strict` tinyint(1) NOT NULL DEFAULT 0 COMMENT '严格模式开关' AFTER `ip_version`; + +-- 数据迁移:老记录list_mode统一置为WHITELIST +UPDATE `open_api` +SET `list_mode` = 'WHITELIST' +WHERE `list_mode` IS NULL OR `list_mode` = ''; + +-- 索引:最近变更排序 +ALTER TABLE `open_api` + ADD INDEX `idx_open_api_update_time` (`update_time`); + +SET FOREIGN_KEY_CHECKS = 1; + +-- 回滚要点(如需回滚,手工执行以下SQL) +-- ALTER TABLE `open_api` +-- DROP COLUMN `list_mode`, +-- DROP COLUMN `allowed_list`, +-- DROP COLUMN `comment`, +-- DROP COLUMN `dns_cache_ttl_seconds`, +-- DROP COLUMN `ip_version`, +-- DROP COLUMN `enable_strict`; +-- DROP INDEX `idx_open_api_update_time` ON `open_api`; \ No newline at end of file diff --git a/jeecgboot-vue3/src/views/openapi/OpenApi.data.ts b/jeecgboot-vue3/src/views/openapi/OpenApi.data.ts index a94b4da0c93..67c52b96f29 100644 --- a/jeecgboot-vue3/src/views/openapi/OpenApi.data.ts +++ b/jeecgboot-vue3/src/views/openapi/OpenApi.data.ts @@ -22,15 +22,32 @@ export const columns: BasicColumn[] = [ dataIndex: 'requestUrl' }, { - title: 'IP 黑名单', + title: '访问模式', align:"center", - dataIndex: 'blackList' + dataIndex: 'listMode', + customRender: ({ text }) => { + return text === 'WHITELIST' ? '白名单' : '黑名单'; + } + }, + { + title: '清单项数量', + align:"center", + dataIndex: 'allowedList', + customRender: ({ text }) => { + if (!text) return 0; + return text.split(/[,\n]/).filter(item => item.trim()).length; + } + }, + { + title: '最后修改人', + align:"center", + dataIndex: 'updateBy' + }, + { + title: '最后修改时间', + align:"center", + dataIndex: 'updateTime' }, - // { - // title: '状态', - // align:"center", - // dataIndex: 'status' - // }, { title: '创建人', align:"center", @@ -49,6 +66,17 @@ export const searchFormSchema: FormSchema[] = [ field: "name", component: 'JInput', }, + { + label: "访问模式", + field: "listMode", + component: 'Select', + componentProps: { + options: [ + { label: '白名单', value: 'WHITELIST' }, + { label: '黑名单', value: 'BLACKLIST' }, + ], + }, + }, { label: "创建人", field: "createBy", @@ -123,9 +151,110 @@ export const formSchema: FormSchema[] = [ dynamicDisabled:true }, { - label: 'IP 黑名单', - field: 'blackList', - component: 'Input', + label: '访问模式', + field: 'listMode', + component: 'RadioGroup', + defaultValue: 'WHITELIST', + componentProps: { + options: [ + { label: '白名单', value: 'WHITELIST' }, + { label: '黑名单', value: 'BLACKLIST' }, + ], + }, + }, + { + label: '访问清单', + field: 'allowedList', + component: 'InputTextArea', + slot: 'allowedListSlot', + componentProps: { + rows: 6, + placeholder: '支持IP、CIDR、域名;支持10.2.3.*与10.2.3.[1-234],每行一个或逗号分隔', + }, + dynamicRules: ({ model, schema }) => { + return [ + { + validator: (rule, value) => { + if (!value) return Promise.resolve(); + const items = value.split(/[,\n]/).filter((item) => item.trim()); + const ipv4Seg = '(?:25[0-5]|2[0-4][0-9]|[01]?\\d\\d?)'; + const ipRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}${ipv4Seg}$`); + const cidrRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}${ipv4Seg}\\/(?:[0-9]|[1-2][0-9]|3[0-2])$`); + const domainRegex = /^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}$/; + // 10.2.3.* 支持最后一段通配符 + const wildcardLastOctetRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}\\*$`); + // 10.2.3.[1-234] 支持最后一段范围 + const rangeLastOctetRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}\\[(\\d{1,3})-(\\d{1,3})\\]$`); + + for (const raw of items) { + const item = raw.trim(); + // 基础合法:IP / CIDR / 域名 + if (ipRegex.test(item) || cidrRegex.test(item) || domainRegex.test(item)) { + continue; + } + // 最后一段通配符:10.2.3.* + if (wildcardLastOctetRegex.test(item)) { + continue; + } + // 最后一段范围:10.2.3.[1-234] + const m = item.match(rangeLastOctetRegex); + if (m) { + const start = Number(m[1]); + const end = Number(m[2]); + if (Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end >= start && end <= 255) { + continue; + } + } + return Promise.reject(new Error(`"${item}" 不是有效的IP/CIDR/域名,或不支持的模式(仅支持10.2.3.*与10.2.3.[start-end])`)); + } + return Promise.resolve(); + }, + message: '请输入有效的IP、CIDR、域名或通配/范围模式', + }, + ]; + }, + }, + { + label: '备注', + field: 'comment', + component: 'InputTextArea', + componentProps: { + rows: 3, + }, + }, + { + label: '高级设置', + field: 'advancedSettings', + component: 'Divider', + }, + { + label: '严格模式', + field: 'enableStrict', + component: 'Switch', + defaultValue: false, + }, + { + label: 'DNS缓存TTL(秒)', + field: 'dnsCacheTtlSeconds', + component: 'InputNumber', + defaultValue: 300, + componentProps: { + min: 0, + max: 86400, + }, + }, + { + label: 'IP版本', + field: 'ipVersion', + component: 'Select', + defaultValue: 'Dual', + componentProps: { + options: [ + { label: 'IPv4', value: 'IPv4' }, + { label: 'IPv6', value: 'IPv6' }, + { label: '双栈', value: 'Dual' }, + ], + }, }, { label: '请求体内容', diff --git a/jeecgboot-vue3/src/views/openapi/components/OpenApiModal.vue b/jeecgboot-vue3/src/views/openapi/components/OpenApiModal.vue index e59fcab853b..25a5919d330 100644 --- a/jeecgboot-vue3/src/views/openapi/components/OpenApiModal.vue +++ b/jeecgboot-vue3/src/views/openapi/components/OpenApiModal.vue @@ -2,7 +2,27 @@ - + + + + @@ -118,6 +138,68 @@ refKeys ); + // 校验规则复用:IPv4段、IP、CIDR、域名、通配符、范围 + const ipv4Seg = '(?:25[0-5]|2[0-4][0-9]|[01]?\\d\\d?)'; + const ipRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}${ipv4Seg}$`); + const cidrRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}${ipv4Seg}\\/(?:[0-9]|[1-2][0-9]|3[0-2])$`); + const domainRegex = /^([a-zA-Z0-9]+(-[a-zA-Z0-9]+)*\.)+[a-zA-Z]{2,}$/; + const wildcardLastOctetRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}\\*$`); + const rangeLastOctetRegex = new RegExp(`^(?:${ipv4Seg}\\.){3}\\[(\\d{1,3})-(\\d{1,3})\\]$`); + + function isValidAllowedItem(raw: string): boolean { + const item = (raw || '').trim(); + if (!item) return false; + if (ipRegex.test(item) || cidrRegex.test(item) || domainRegex.test(item)) return true; + if (wildcardLastOctetRegex.test(item)) return true; + const m = item.match(rangeLastOctetRegex); + if (m) { + const start = Number(m[1]); + const end = Number(m[2]); + if (Number.isInteger(start) && Number.isInteger(end) && start >= 0 && end >= start && end <= 255) return true; + } + return false; + } + + function splitAllowedList(val?: string): string[] { + if (!val) return []; + return val.split(/[,\n]/).map(s => s.trim()).filter(Boolean); + } + + function organizeAllowedList(model: Record, field: string) { + const items = splitAllowedList(model[field]); + + // 去重(域名大小写不敏感;其他保持原样) + const seen = new Set(); + const normalizedPair: [string, string][] = items.map((x) => { + const isDomain = domainRegex.test(x); + return [isDomain ? x.toLowerCase() : x, x]; + }); + const deduped: string[] = []; + for (const [key, original] of normalizedPair) { + if (!seen.has(key)) { + seen.add(key); + deduped.push(original); + } + } + + // 类型排序:IP < CIDR < 通配符 < 范围 < 域名,然后字典序 + function typeRank(s: string): number { + if (ipRegex.test(s)) return 1; + if (cidrRegex.test(s)) return 2; + if (wildcardLastOctetRegex.test(s)) return 3; + if (rangeLastOctetRegex.test(s)) return 4; + if (domainRegex.test(s)) return 5; + return 9; + } + deduped.sort((a, b) => { + const ra = typeRank(a), rb = typeRank(b); + if (ra !== rb) return ra - rb; + return a.localeCompare(b); + }); + + model[field] = deduped.join('\n'); + } + //设置标题 const title = computed(() => (!unref(isUpdate) ? '新增' : !unref(formDisabled) ? '编辑' : '详情')); @@ -138,7 +220,7 @@ //表单提交事件 async function requestAddOrEdit(values) { let headersJson = !!values.headersJson?JSON.stringify(values.headersJson):null; - let paramsJson = !!values.headersJson?JSON.stringify(values.paramsJson):null; + let paramsJson = !!values.paramsJson?JSON.stringify(values.paramsJson):null; try { if (!!values.body){ try { @@ -151,6 +233,13 @@ return; } } + // 处理访问清单,将逗号分隔转换为换行分隔 + if (values.allowedList) { + values.allowedList = values.allowedList + .split(/[,\s]+/) + .filter(item => item.trim()) + .join('\n'); + } setModalProps({ confirmLoading: true }); values.headersJson = headersJson values.paramsJson = paramsJson @@ -175,4 +264,19 @@ :deep(.ant-calendar-picker) { width: 100%; } + + .allowed-tags { + margin-top: 8px; + min-height: 28px; + } + .allowed-tag { + background-color: #f6ffed; /* 绿色浅底 */ + color: #389e0d; + border: 1px solid #b7eb8f; + border-radius: 14px; + padding: 2px 10px; + margin: 2px 6px 6px 0; + font-size: 12px; + line-height: 20px; + }