refactor(app): 将许可证文件功能替换为配置文件功能
- 替换 LicenseFileCrypto 为 ConfigFileCrypto 加密类 - 将 /license-file 相关接口重命名为 /config-file - 修改数据库实体中的 licenseFileContent 字段为 configFileContent - 更新前端 API 调用从 downloadLicenseFile 改为 downloadConfigFile - 将安全中心的 License 文件解析功能改为 Config 文件解析 - 更新文件扩展名从 .xuqmlicense 改为 .xuqmconfig - 修改后端服务方法 ensureLicenseFile 为 ensureConfigFile - 调整加密解密逻辑以支持新的配置文件格式
这个提交包含在:
父节点
21fa87b3ac
当前提交
596927c1c6
@ -0,0 +1,119 @@
|
||||
package com.xuqm.common.security;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.SecretKeyFactory;
|
||||
import javax.crypto.spec.GCMParameterSpec;
|
||||
import javax.crypto.spec.PBEKeySpec;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* Encrypts/decrypts the SDK init config file (format: XUQM-CONFIG-V1.<salt>.<iv>.<ciphertext>).
|
||||
* Algorithm: AES-256-GCM with PBKDF2-HMAC-SHA256 key derivation (120,000 iterations).
|
||||
* This is separate from LicenseFileCrypto (device activation).
|
||||
*/
|
||||
public final class ConfigFileCrypto {
|
||||
|
||||
private static final String MAGIC = "XUQM-CONFIG-V1";
|
||||
private static final String PASSPHRASE = "xuqm-config-file-v1.2026.internal";
|
||||
private static final int SALT_BYTES = 16;
|
||||
private static final int IV_BYTES = 12;
|
||||
private static final int KEY_BITS = 256;
|
||||
private static final int ITERATIONS = 120_000;
|
||||
private static final int GCM_TAG_BITS = 128;
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||
|
||||
private ConfigFileCrypto() {
|
||||
}
|
||||
|
||||
public static String encrypt(String plainText) {
|
||||
try {
|
||||
byte[] salt = randomBytes(SALT_BYTES);
|
||||
byte[] iv = randomBytes(IV_BYTES);
|
||||
SecretKeySpec key = deriveKey(salt);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||
byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8));
|
||||
return String.join(".",
|
||||
MAGIC,
|
||||
Base64.getUrlEncoder().withoutPadding().encodeToString(salt),
|
||||
Base64.getUrlEncoder().withoutPadding().encodeToString(iv),
|
||||
Base64.getUrlEncoder().withoutPadding().encodeToString(cipherText));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to encrypt config file", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static ConfigPayload decrypt(String token) {
|
||||
try {
|
||||
String[] parts = token.split("\\.");
|
||||
if (parts.length != 4 || !MAGIC.equals(parts[0])) {
|
||||
throw new IllegalArgumentException("Invalid config file format");
|
||||
}
|
||||
byte[] salt = Base64.getUrlDecoder().decode(parts[1]);
|
||||
byte[] iv = Base64.getUrlDecoder().decode(parts[2]);
|
||||
byte[] cipherText = Base64.getUrlDecoder().decode(parts[3]);
|
||||
SecretKeySpec key = deriveKey(salt);
|
||||
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
|
||||
cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_BITS, iv));
|
||||
String plainText = new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8);
|
||||
JsonNode node = MAPPER.readTree(plainText);
|
||||
return new ConfigPayload(
|
||||
text(node, "appKey"),
|
||||
text(node, "appName"),
|
||||
text(node, "companyName"),
|
||||
text(node, "packageName"),
|
||||
text(node, "iosBundleId"),
|
||||
text(node, "harmonyBundleName"),
|
||||
text(node, "baseUrl"),
|
||||
text(node, "serverUrl"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Failed to decrypt config file: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static SecretKeySpec deriveKey(byte[] salt) throws Exception {
|
||||
PBEKeySpec spec = new PBEKeySpec(PASSPHRASE.toCharArray(), salt, ITERATIONS, KEY_BITS);
|
||||
byte[] encoded = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
|
||||
return new SecretKeySpec(encoded, "AES");
|
||||
}
|
||||
|
||||
private static byte[] randomBytes(int size) {
|
||||
byte[] bytes = new byte[size];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
private static String text(JsonNode node, String field) {
|
||||
JsonNode v = node.path(field);
|
||||
return v.isMissingNode() || v.isNull() ? null : v.asText(null);
|
||||
}
|
||||
|
||||
public record ConfigPayload(
|
||||
String appKey,
|
||||
String appName,
|
||||
String companyName,
|
||||
String packageName,
|
||||
String iosBundleId,
|
||||
String harmonyBundleName,
|
||||
String baseUrl,
|
||||
String serverUrl) {
|
||||
|
||||
public boolean matchesPackageName(String candidate) {
|
||||
if (candidate == null || candidate.isBlank()) return false;
|
||||
boolean anyConfigured = hasText(packageName) || hasText(iosBundleId) || hasText(harmonyBundleName);
|
||||
if (!anyConfigured) return true;
|
||||
return candidate.equals(packageName) || candidate.equals(iosBundleId) || candidate.equals(harmonyBundleName);
|
||||
}
|
||||
|
||||
private static boolean hasText(String s) { return s != null && !s.isBlank(); }
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@ package com.xuqm.tenant.controller;
|
||||
|
||||
import com.xuqm.common.model.ApiResponse;
|
||||
import com.xuqm.common.exception.BusinessException;
|
||||
import com.xuqm.common.security.LicenseFileCrypto;
|
||||
import com.xuqm.common.security.ConfigFileCrypto;
|
||||
import com.xuqm.tenant.dto.CreateAppRequest;
|
||||
import com.xuqm.tenant.entity.AppEntity;
|
||||
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
||||
@ -170,12 +170,12 @@ public class AppController {
|
||||
return ResponseEntity.ok(ApiResponse.success(result));
|
||||
}
|
||||
|
||||
@GetMapping("/{appKey}/license-file")
|
||||
public ResponseEntity<byte[]> downloadLicenseFile(@PathVariable String appKey,
|
||||
@AuthenticationPrincipal String tenantId) {
|
||||
@GetMapping("/{appKey}/config-file")
|
||||
public ResponseEntity<byte[]> downloadConfigFile(@PathVariable String appKey,
|
||||
@AuthenticationPrincipal String tenantId) {
|
||||
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
||||
String encrypted = appService.ensureLicenseFile(appKey, tenantId);
|
||||
String filename = sanitizeFileName(app.getName()) + ".xuqmlicense";
|
||||
String encrypted = appService.ensureConfigFile(appKey, tenantId);
|
||||
String filename = sanitizeFileName(app.getName()) + ".xuqmconfig";
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(filename, java.nio.charset.StandardCharsets.UTF_8).build().toString())
|
||||
@ -183,20 +183,20 @@ public class AppController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an uploaded license file and return its decrypted contents.
|
||||
* Used by the security center to verify license file information.
|
||||
* Parse an uploaded config file and return its decrypted contents.
|
||||
* Used by the security center to verify config file information.
|
||||
*/
|
||||
@PostMapping("/license/parse")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> parseLicenseFile(
|
||||
@PostMapping("/config/parse")
|
||||
public ResponseEntity<ApiResponse<Map<String, Object>>> parseConfigFile(
|
||||
@RequestBody Map<String, String> body,
|
||||
@AuthenticationPrincipal String tenantId) {
|
||||
String content = body.get("content");
|
||||
if (content == null || content.isBlank()) {
|
||||
throw new BusinessException("License file content is required");
|
||||
throw new BusinessException("Config file content is required");
|
||||
}
|
||||
try {
|
||||
LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(content.trim());
|
||||
// Verify the license file belongs to the current tenant
|
||||
ConfigFileCrypto.ConfigPayload payload = ConfigFileCrypto.decrypt(content.trim());
|
||||
// Verify the config file belongs to the current tenant
|
||||
try {
|
||||
appService.getByAppKey(payload.appKey(), tenantId);
|
||||
} catch (BusinessException e) {
|
||||
@ -217,9 +217,9 @@ public class AppController {
|
||||
} catch (BusinessException e) {
|
||||
throw e;
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw new BusinessException("Invalid license file: " + e.getMessage());
|
||||
throw new BusinessException("Invalid config file: " + e.getMessage());
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException("Failed to parse license file: " + e.getMessage());
|
||||
throw new BusinessException("Failed to parse config file: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ public class AppEntity {
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@Column(name = "license_file_content", length = 4096)
|
||||
private String licenseFileContent;
|
||||
private String configFileContent;
|
||||
|
||||
@Column(name = "is_default", columnDefinition = "BIT(1) DEFAULT 0")
|
||||
private boolean isDefault;
|
||||
@ -91,6 +91,6 @@ public class AppEntity {
|
||||
public boolean isDeletable() { return deletable; }
|
||||
public void setDeletable(boolean deletable) { this.deletable = deletable; }
|
||||
|
||||
public String getLicenseFileContent() { return licenseFileContent; }
|
||||
public void setLicenseFileContent(String licenseFileContent) { this.licenseFileContent = licenseFileContent; }
|
||||
public String getConfigFileContent() { return configFileContent; }
|
||||
public void setConfigFileContent(String configFileContent) { this.configFileContent = configFileContent; }
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
package com.xuqm.tenant.service;
|
||||
|
||||
import com.xuqm.common.exception.BusinessException;
|
||||
import com.xuqm.common.security.LicenseFileCrypto;
|
||||
import com.xuqm.common.security.ConfigFileCrypto;
|
||||
import com.xuqm.tenant.dto.CreateAppRequest;
|
||||
import com.xuqm.tenant.entity.AppEntity;
|
||||
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
||||
@ -79,7 +79,7 @@ public class AppService {
|
||||
app.setAppKey(generateAppKey());
|
||||
app.setAppSecret(generateSecret());
|
||||
app.setCreatedAt(LocalDateTime.now());
|
||||
app.setLicenseFileContent(generateLicenseFileContent(app));
|
||||
app.setConfigFileContent(generateConfigFileContent(app));
|
||||
AppEntity saved = appRepository.save(app);
|
||||
autoEnableFileService(saved.getAppKey());
|
||||
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP",
|
||||
@ -89,12 +89,12 @@ public class AppService {
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String ensureLicenseFile(String appKey, String tenantId) {
|
||||
public String ensureConfigFile(String appKey, String tenantId) {
|
||||
AppEntity app = getByAppKey(appKey, tenantId);
|
||||
String content = app.getLicenseFileContent();
|
||||
String content = app.getConfigFileContent();
|
||||
if (content == null || content.isBlank()) {
|
||||
content = generateLicenseFileContent(app);
|
||||
app.setLicenseFileContent(content);
|
||||
content = generateConfigFileContent(app);
|
||||
app.setConfigFileContent(content);
|
||||
appRepository.save(app);
|
||||
}
|
||||
return content;
|
||||
@ -112,7 +112,7 @@ public class AppService {
|
||||
app.setName(req.name());
|
||||
app.setDescription(req.description());
|
||||
app.setIconUrl(req.iconUrl());
|
||||
app.setLicenseFileContent(generateLicenseFileContent(app));
|
||||
app.setConfigFileContent(generateConfigFileContent(app));
|
||||
AppEntity saved = appRepository.save(app);
|
||||
Map<String, Object> after = new LinkedHashMap<>();
|
||||
after.put("name", saved.getName());
|
||||
@ -167,7 +167,7 @@ public class AppService {
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
private String generateLicenseFileContent(AppEntity app) {
|
||||
private String generateConfigFileContent(AppEntity app) {
|
||||
Map<String, Object> payload = new LinkedHashMap<>();
|
||||
payload.put("appKey", app.getAppKey());
|
||||
payload.put("appName", app.getName());
|
||||
@ -184,9 +184,9 @@ public class AppService {
|
||||
}
|
||||
payload.put("issuedAt", java.time.Instant.now().toString());
|
||||
try {
|
||||
return LicenseFileCrypto.encrypt(MAPPER.valueToTree(payload).toString());
|
||||
return ConfigFileCrypto.encrypt(MAPPER.valueToTree(payload).toString());
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to generate license file", e);
|
||||
throw new IllegalStateException("Failed to generate config file", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户