From 228c87f5da3bcecd121e30904f385abbd468af4d Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Jul 2026 20:12:27 +0800 Subject: [PATCH] refactor(security): remove client-embedded app secret --- .../security/AppRequestSignatureUtil.java | 48 -------- .../security/AppSignatureAuthFilter.java | 111 ------------------ .../common/security/ConfigFileCrypto.java | 2 - .../common/security/ConfigFileCryptoTest.java | 4 + .../java/com/xuqm/im/sdk/XuqmImServerSdk.java | 1 - .../com/xuqm/tenant/service/AppService.java | 2 - .../service/AppServiceConfigIssuanceTest.java | 97 +++++++++++++++ .../xuqm/update/config/SecurityConfig.java | 8 +- .../config/TenantAppSecretResolver.java | 67 ----------- 9 files changed, 102 insertions(+), 238 deletions(-) delete mode 100644 common/src/main/java/com/xuqm/common/security/AppRequestSignatureUtil.java delete mode 100644 common/src/main/java/com/xuqm/common/security/AppSignatureAuthFilter.java create mode 100644 tenant-service/src/test/java/com/xuqm/tenant/service/AppServiceConfigIssuanceTest.java delete mode 100644 update-service/src/main/java/com/xuqm/update/config/TenantAppSecretResolver.java diff --git a/common/src/main/java/com/xuqm/common/security/AppRequestSignatureUtil.java b/common/src/main/java/com/xuqm/common/security/AppRequestSignatureUtil.java deleted file mode 100644 index 4363142..0000000 --- a/common/src/main/java/com/xuqm/common/security/AppRequestSignatureUtil.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.xuqm.common.security; - -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.Base64; - -public final class AppRequestSignatureUtil { - - private static final String HMAC_ALG = "HmacSHA256"; - - private AppRequestSignatureUtil() { - } - - public static String payload(String appKey, - String userId, - long timestamp, - String nonce) { - return normalize(appKey) + '\n' - + normalize(userId) + '\n' - + timestamp + '\n' - + normalize(nonce); - } - - public static String sign(String secret, String payload) { - try { - Mac mac = Mac.getInstance(HMAC_ALG); - mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALG)); - byte[] digest = mac.doFinal(payload.getBytes(StandardCharsets.UTF_8)); - return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); - } catch (Exception e) { - throw new IllegalStateException("Failed to sign app request", e); - } - } - - public static boolean matches(String secret, String payload, String expectedSignature) { - String actual = sign(secret, payload); - return MessageDigest.isEqual( - actual.getBytes(StandardCharsets.UTF_8), - normalize(expectedSignature).getBytes(StandardCharsets.UTF_8) - ); - } - - private static String normalize(String value) { - return value == null ? "" : value; - } -} diff --git a/common/src/main/java/com/xuqm/common/security/AppSignatureAuthFilter.java b/common/src/main/java/com/xuqm/common/security/AppSignatureAuthFilter.java deleted file mode 100644 index cec74c3..0000000 --- a/common/src/main/java/com/xuqm/common/security/AppSignatureAuthFilter.java +++ /dev/null @@ -1,111 +0,0 @@ -package com.xuqm.common.security; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.web.filter.OncePerRequestFilter; - -import java.io.IOException; -import java.time.Instant; - -/** - * 验证 SDK 请求的 HMAC-SHA256 签名。 - *

- * 请求头: - *

- *

- * 签名载荷格式:{@code appKey\nuserId\ntimestamp\nnonce} - */ -public class AppSignatureAuthFilter extends OncePerRequestFilter { - - private static final Logger log = LoggerFactory.getLogger(AppSignatureAuthFilter.class); - private static final long MAX_TIMESTAMP_DRIFT_SECONDS = 300; // ±5 分钟 - - private final AppSecretResolver secretResolver; - - public AppSignatureAuthFilter(AppSecretResolver secretResolver) { - this.secretResolver = secretResolver; - } - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - String appKey = request.getHeader("X-App-Key"); - String timestampStr = request.getHeader("X-Timestamp"); - String nonce = request.getHeader("X-Nonce"); - String signature = request.getHeader("X-Signature"); - - // 如果没有签名头,跳过验证(交给后续 Filter 处理) - if (appKey == null || signature == null) { - filterChain.doFilter(request, response); - return; - } - - // 验证时间戳 - if (timestampStr == null || timestampStr.isBlank()) { - log.warn("Missing X-Timestamp header for appKey={}", appKey); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing timestamp"); - return; - } - - long timestamp; - try { - timestamp = Long.parseLong(timestampStr); - } catch (NumberFormatException e) { - log.warn("Invalid X-Timestamp header: {} for appKey={}", timestampStr, appKey); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid timestamp"); - return; - } - - long now = Instant.now().getEpochSecond(); - if (Math.abs(now - timestamp) > MAX_TIMESTAMP_DRIFT_SECONDS) { - log.warn("Timestamp drift too large: now={}, request={}, appKey={}", now, timestamp, appKey); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Timestamp expired"); - return; - } - - // 解析 appSecret - String appSecret; - try { - appSecret = secretResolver.resolveAppSecret(appKey); - } catch (Exception e) { - log.warn("Failed to resolve appSecret for appKey={}: {}", appKey, e.getMessage()); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid appKey"); - return; - } - - if (appSecret == null || appSecret.isBlank()) { - log.warn("No appSecret found for appKey={}", appKey); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid appKey"); - return; - } - - // 构造签名载荷并验证 - String userId = request.getHeader("X-User-Id"); // 可选 - String payload = AppRequestSignatureUtil.payload(appKey, userId, timestamp, nonce); - if (!AppRequestSignatureUtil.matches(appSecret, payload, signature)) { - log.warn("Signature mismatch for appKey={}", appKey); - response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Invalid signature"); - return; - } - - // 签名验证通过,继续处理请求 - filterChain.doFilter(request, response); - } - - /** - * 根据 appKey 解析 appSecret。 - * 各服务需实现此接口以查询 appSecret。 - */ - public interface AppSecretResolver { - String resolveAppSecret(String appKey); - } -} diff --git a/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java b/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java index ab23812..ec3d67f 100644 --- a/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java +++ b/common/src/main/java/com/xuqm/common/security/ConfigFileCrypto.java @@ -109,7 +109,6 @@ public final class ConfigFileCrypto { text(node, "harmonyBundleName"), text(node, "baseUrl"), text(node, "serverUrl"), - text(node, "signingKey"), parts[1]); } catch (IllegalArgumentException e) { throw e; @@ -161,7 +160,6 @@ public final class ConfigFileCrypto { String harmonyBundleName, String baseUrl, String serverUrl, - String signingKey, String keyId) { public boolean matchesPackageName(String candidate) { diff --git a/common/src/test/java/com/xuqm/common/security/ConfigFileCryptoTest.java b/common/src/test/java/com/xuqm/common/security/ConfigFileCryptoTest.java index 70e600d..ac7d827 100644 --- a/common/src/test/java/com/xuqm/common/security/ConfigFileCryptoTest.java +++ b/common/src/test/java/com/xuqm/common/security/ConfigFileCryptoTest.java @@ -6,6 +6,7 @@ import org.junit.jupiter.api.Test; import java.security.KeyFactory; import java.security.spec.X509EncodedKeySpec; +import java.util.Arrays; import java.util.Base64; import static org.assertj.core.api.Assertions.assertThat; @@ -28,5 +29,8 @@ class ConfigFileCryptoTest { assertThat(payload.configId()).isEqualTo("00000000-0000-4000-8000-000000000001"); assertThat(payload.revision()).isEqualTo(1); assertThat(payload.packageName()).isEqualTo("com.xuqm.vector"); + assertThat(Arrays.stream(ConfigFileCrypto.ConfigPayload.class.getRecordComponents()) + .map(component -> component.getName())) + .doesNotContain("signingKey", "appSecret"); } } diff --git a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java index 08084bb..676bb50 100644 --- a/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java +++ b/im-sdk/src/main/java/com/xuqm/im/sdk/XuqmImServerSdk.java @@ -7,7 +7,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import com.xuqm.common.model.ApiResponse; -import com.xuqm.common.security.AppRequestSignatureUtil; import java.io.IOException; import java.net.URI; diff --git a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java index c621411..243fe57 100644 --- a/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java +++ b/tenant-service/src/main/java/com/xuqm/tenant/service/AppService.java @@ -184,7 +184,6 @@ public class AppService { AppEntity app = getByAppKey(appKey, tenantId); String newSecret = generateSecret(); app.setAppSecret(newSecret); - issueConfigFile(app, app.getConfigFileExpiresAt()); appRepository.save(app); operationLogService.record(tenantId, "APP", "APP_SECRET", app.getAppKey(), "RESET_APP_SECRET", "重置应用「" + app.getName() + "」的 AppSecret", @@ -286,7 +285,6 @@ public class AppService { payload.put("harmonyBundleName", app.getHarmonyBundleName()); } payload.put("serverUrl", normalizeBaseUrl(sdkPlatformPublicBaseUrl)); - payload.put("signingKey", app.getAppSecret()); try { String canonicalJson = MAPPER.writeValueAsString(payload); app.setConfigFileContent(configFileSigningService.sign(canonicalJson)); diff --git a/tenant-service/src/test/java/com/xuqm/tenant/service/AppServiceConfigIssuanceTest.java b/tenant-service/src/test/java/com/xuqm/tenant/service/AppServiceConfigIssuanceTest.java new file mode 100644 index 0000000..51282ca --- /dev/null +++ b/tenant-service/src/test/java/com/xuqm/tenant/service/AppServiceConfigIssuanceTest.java @@ -0,0 +1,97 @@ +package com.xuqm.tenant.service; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.xuqm.tenant.config.PrivateDeploymentProperties; +import com.xuqm.tenant.dto.CreateAppRequest; +import com.xuqm.tenant.entity.AppEntity; +import com.xuqm.tenant.repository.AppRepository; +import com.xuqm.tenant.repository.FeatureServiceRepository; +import com.xuqm.tenant.repository.TenantRepository; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class AppServiceConfigIssuanceTest { + + @Test + void newlyIssuedClientConfigDoesNotContainServerCredential() throws Exception { + AppRepository appRepository = mock(AppRepository.class); + OperationLogService operationLogService = mock(OperationLogService.class); + FeatureServiceRepository featureServiceRepository = mock(FeatureServiceRepository.class); + PrivateDeploymentProperties deploymentProperties = new PrivateDeploymentProperties(); + TenantRepository tenantRepository = mock(TenantRepository.class); + ConfigFileSigningService signingService = mock(ConfigFileSigningService.class); + ArgumentCaptor canonicalPayload = ArgumentCaptor.forClass(String.class); + + when(appRepository.save(any(AppEntity.class))).thenAnswer(invocation -> invocation.getArgument(0)); + when(signingService.sign(canonicalPayload.capture())).thenReturn("signed-config"); + + AppService service = new AppService( + appRepository, + operationLogService, + featureServiceRepository, + deploymentProperties, + tenantRepository, + signingService); + ReflectionTestUtils.setField(service, "sdkPlatformPublicBaseUrl", "https://sdk.example.com/"); + + AppEntity created = service.create("tenant-1", new CreateAppRequest( + "com.example.android", + "com.example.ios", + "com.example.harmony", + "Example", + null, + null)); + + JsonNode payload = new ObjectMapper().readTree(canonicalPayload.getValue()); + assertThat(created.getConfigFileContent()).isEqualTo("signed-config"); + assertThat(payload.path("appKey").asText()).isEqualTo(created.getAppKey()); + assertThat(payload.path("serverUrl").asText()).isEqualTo("https://sdk.example.com/"); + assertThat(payload.has("signingKey")).isFalse(); + assertThat(payload.has("appSecret")).isFalse(); + } + + @Test + void resettingServerAppSecretDoesNotInvalidateIndependentClientConfig() { + AppRepository appRepository = mock(AppRepository.class); + OperationLogService operationLogService = mock(OperationLogService.class); + FeatureServiceRepository featureServiceRepository = mock(FeatureServiceRepository.class); + ConfigFileSigningService signingService = mock(ConfigFileSigningService.class); + AppEntity app = new AppEntity(); + app.setId("app-1"); + app.setTenantId("tenant-1"); + app.setAppKey("ak_test"); + app.setAppSecret("old-secret"); + app.setName("Example"); + app.setPackageName("com.example"); + app.setConfigFileContent("unchanged-signed-config"); + app.setConfigFileId("config-1"); + app.setConfigFileRevision(7); + + when(appRepository.findByAppKey("ak_test")).thenReturn(java.util.Optional.of(app)); + when(appRepository.save(any(AppEntity.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + AppService service = new AppService( + appRepository, + operationLogService, + featureServiceRepository, + new PrivateDeploymentProperties(), + mock(TenantRepository.class), + signingService); + + String newSecret = service.resetSecret("ak_test", "tenant-1"); + + assertThat(newSecret).isNotBlank().isNotEqualTo("old-secret"); + assertThat(app.getConfigFileContent()).isEqualTo("unchanged-signed-config"); + assertThat(app.getConfigFileRevision()).isEqualTo(7); + verify(signingService, never()).sign(any()); + } +} diff --git a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java index 8a1ecaa..5525f33 100644 --- a/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java +++ b/update-service/src/main/java/com/xuqm/update/config/SecurityConfig.java @@ -1,7 +1,6 @@ package com.xuqm.update.config; import com.xuqm.common.security.ApiKeyAuthFilter; -import com.xuqm.common.security.AppSignatureAuthFilter; import com.xuqm.common.security.JwtAuthFilter; import com.xuqm.common.security.JwtUtil; import org.springframework.context.annotation.Bean; @@ -26,13 +25,10 @@ public class SecurityConfig { private final JwtUtil jwtUtil; private final TenantApiKeyValidator apiKeyValidator; - private final TenantAppSecretResolver appSecretResolver; - public SecurityConfig(JwtUtil jwtUtil, TenantApiKeyValidator apiKeyValidator, - TenantAppSecretResolver appSecretResolver) { + public SecurityConfig(JwtUtil jwtUtil, TenantApiKeyValidator apiKeyValidator) { this.jwtUtil = jwtUtil; this.apiKeyValidator = apiKeyValidator; - this.appSecretResolver = appSecretResolver; } @Bean @@ -67,8 +63,6 @@ public class SecurityConfig { UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new RnBearerAuthFilter(apiKeyValidator), UsernamePasswordAuthenticationFilter.class) - // HMAC 签名验证(SDK 请求) - .addFilterBefore(new AppSignatureAuthFilter(appSecretResolver), UsernamePasswordAuthenticationFilter.class) // JWT 认证 .addFilterBefore(new JwtAuthFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class) .httpBasic(AbstractHttpConfigurer::disable) diff --git a/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretResolver.java b/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretResolver.java deleted file mode 100644 index 3b9ec88..0000000 --- a/update-service/src/main/java/com/xuqm/update/config/TenantAppSecretResolver.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.xuqm.update.config; - -import com.xuqm.common.security.AppSignatureAuthFilter; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; - -import java.util.concurrent.ConcurrentHashMap; - -/** - * 通过 tenant-service 内部 API 解析 appSecret。 - */ -@Component -public class TenantAppSecretResolver implements AppSignatureAuthFilter.AppSecretResolver { - - private static final Logger log = LoggerFactory.getLogger(TenantAppSecretResolver.class); - - @Value("${sdk.tenant-service-url:http://xuqm-tenant-service:9001}") - private String tenantServiceUrl; - - @Value("${sdk.internal-token:xuqm-internal-token}") - private String internalToken; - - private final RestTemplate restTemplate = new RestTemplate(); - private final ObjectMapper objectMapper = new ObjectMapper(); - - /** 缓存:appKey → appSecret */ - private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); - - @Override - public String resolveAppSecret(String appKey) { - // 先查缓存 - String cached = cache.get(appKey); - if (cached != null) return cached; - - try { - String url = tenantServiceUrl + "/api/internal/sdk/apps/" + appKey + "/secret"; - var headers = new org.springframework.http.HttpHeaders(); - headers.set("X-Internal-Token", internalToken); - var entity = new org.springframework.http.HttpEntity<>(headers); - - var response = restTemplate.exchange( - url, - org.springframework.http.HttpMethod.GET, - entity, - String.class - ); - - if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { - JsonNode root = objectMapper.readTree(response.getBody()); - JsonNode data = root.path("data"); - String appSecret = data.path("appSecret").asText(null); - if (appSecret != null) { - cache.put(appKey, appSecret); - } - return appSecret; - } - } catch (Exception e) { - log.warn("Failed to resolve appSecret for appKey={}: {}", appKey, e.getMessage()); - } - return null; - } -}