refactor(security): remove client-embedded app secret

这个提交包含在:
XuqmGroup 2026-07-28 20:12:27 +08:00
父节点 f96a27facc
当前提交 228c87f5da
共有 9 个文件被更改,包括 102 次插入238 次删除

查看文件

@ -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;
}
}

查看文件

@ -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 签名
* <p>
* 请求头
* <ul>
* <li>{@code X-App-Key} 应用标识</li>
* <li>{@code X-Timestamp} Unix 时间戳</li>
* <li>{@code X-Nonce} 随机字符串防重放</li>
* <li>{@code X-Signature} HMAC-SHA256 签名</li>
* </ul>
* <p>
* 签名载荷格式{@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);
}
}

查看文件

@ -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) {

查看文件

@ -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");
}
}

查看文件

@ -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;

查看文件

@ -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));

查看文件

@ -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<String> 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());
}
}

查看文件

@ -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)

查看文件

@ -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<String, String> 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;
}
}