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.model.ApiResponse;
|
||||||
import com.xuqm.common.exception.BusinessException;
|
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.dto.CreateAppRequest;
|
||||||
import com.xuqm.tenant.entity.AppEntity;
|
import com.xuqm.tenant.entity.AppEntity;
|
||||||
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
||||||
@ -170,12 +170,12 @@ public class AppController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(result));
|
return ResponseEntity.ok(ApiResponse.success(result));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/{appKey}/license-file")
|
@GetMapping("/{appKey}/config-file")
|
||||||
public ResponseEntity<byte[]> downloadLicenseFile(@PathVariable String appKey,
|
public ResponseEntity<byte[]> downloadConfigFile(@PathVariable String appKey,
|
||||||
@AuthenticationPrincipal String tenantId) {
|
@AuthenticationPrincipal String tenantId) {
|
||||||
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
||||||
String encrypted = appService.ensureLicenseFile(appKey, tenantId);
|
String encrypted = appService.ensureConfigFile(appKey, tenantId);
|
||||||
String filename = sanitizeFileName(app.getName()) + ".xuqmlicense";
|
String filename = sanitizeFileName(app.getName()) + ".xuqmconfig";
|
||||||
return ResponseEntity.ok()
|
return ResponseEntity.ok()
|
||||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||||
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(filename, java.nio.charset.StandardCharsets.UTF_8).build().toString())
|
.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.
|
* Parse an uploaded config file and return its decrypted contents.
|
||||||
* Used by the security center to verify license file information.
|
* Used by the security center to verify config file information.
|
||||||
*/
|
*/
|
||||||
@PostMapping("/license/parse")
|
@PostMapping("/config/parse")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> parseLicenseFile(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> parseConfigFile(
|
||||||
@RequestBody Map<String, String> body,
|
@RequestBody Map<String, String> body,
|
||||||
@AuthenticationPrincipal String tenantId) {
|
@AuthenticationPrincipal String tenantId) {
|
||||||
String content = body.get("content");
|
String content = body.get("content");
|
||||||
if (content == null || content.isBlank()) {
|
if (content == null || content.isBlank()) {
|
||||||
throw new BusinessException("License file content is required");
|
throw new BusinessException("Config file content is required");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(content.trim());
|
ConfigFileCrypto.ConfigPayload payload = ConfigFileCrypto.decrypt(content.trim());
|
||||||
// Verify the license file belongs to the current tenant
|
// Verify the config file belongs to the current tenant
|
||||||
try {
|
try {
|
||||||
appService.getByAppKey(payload.appKey(), tenantId);
|
appService.getByAppKey(payload.appKey(), tenantId);
|
||||||
} catch (BusinessException e) {
|
} catch (BusinessException e) {
|
||||||
@ -217,9 +217,9 @@ public class AppController {
|
|||||||
} catch (BusinessException e) {
|
} catch (BusinessException e) {
|
||||||
throw e;
|
throw e;
|
||||||
} catch (IllegalArgumentException e) {
|
} catch (IllegalArgumentException e) {
|
||||||
throw new BusinessException("Invalid license file: " + e.getMessage());
|
throw new BusinessException("Invalid config file: " + e.getMessage());
|
||||||
} catch (Exception e) {
|
} 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;
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
@Column(name = "license_file_content", length = 4096)
|
@Column(name = "license_file_content", length = 4096)
|
||||||
private String licenseFileContent;
|
private String configFileContent;
|
||||||
|
|
||||||
@Column(name = "is_default", columnDefinition = "BIT(1) DEFAULT 0")
|
@Column(name = "is_default", columnDefinition = "BIT(1) DEFAULT 0")
|
||||||
private boolean isDefault;
|
private boolean isDefault;
|
||||||
@ -91,6 +91,6 @@ public class AppEntity {
|
|||||||
public boolean isDeletable() { return deletable; }
|
public boolean isDeletable() { return deletable; }
|
||||||
public void setDeletable(boolean deletable) { this.deletable = deletable; }
|
public void setDeletable(boolean deletable) { this.deletable = deletable; }
|
||||||
|
|
||||||
public String getLicenseFileContent() { return licenseFileContent; }
|
public String getConfigFileContent() { return configFileContent; }
|
||||||
public void setLicenseFileContent(String licenseFileContent) { this.licenseFileContent = licenseFileContent; }
|
public void setConfigFileContent(String configFileContent) { this.configFileContent = configFileContent; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
package com.xuqm.tenant.service;
|
package com.xuqm.tenant.service;
|
||||||
|
|
||||||
import com.xuqm.common.exception.BusinessException;
|
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.dto.CreateAppRequest;
|
||||||
import com.xuqm.tenant.entity.AppEntity;
|
import com.xuqm.tenant.entity.AppEntity;
|
||||||
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
import com.xuqm.tenant.entity.FeatureServiceEntity;
|
||||||
@ -79,7 +79,7 @@ public class AppService {
|
|||||||
app.setAppKey(generateAppKey());
|
app.setAppKey(generateAppKey());
|
||||||
app.setAppSecret(generateSecret());
|
app.setAppSecret(generateSecret());
|
||||||
app.setCreatedAt(LocalDateTime.now());
|
app.setCreatedAt(LocalDateTime.now());
|
||||||
app.setLicenseFileContent(generateLicenseFileContent(app));
|
app.setConfigFileContent(generateConfigFileContent(app));
|
||||||
AppEntity saved = appRepository.save(app);
|
AppEntity saved = appRepository.save(app);
|
||||||
autoEnableFileService(saved.getAppKey());
|
autoEnableFileService(saved.getAppKey());
|
||||||
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP",
|
operationLogService.record(tenantId, "APP", "APP", saved.getAppKey(), "CREATE_APP",
|
||||||
@ -89,12 +89,12 @@ public class AppService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
public String ensureLicenseFile(String appKey, String tenantId) {
|
public String ensureConfigFile(String appKey, String tenantId) {
|
||||||
AppEntity app = getByAppKey(appKey, tenantId);
|
AppEntity app = getByAppKey(appKey, tenantId);
|
||||||
String content = app.getLicenseFileContent();
|
String content = app.getConfigFileContent();
|
||||||
if (content == null || content.isBlank()) {
|
if (content == null || content.isBlank()) {
|
||||||
content = generateLicenseFileContent(app);
|
content = generateConfigFileContent(app);
|
||||||
app.setLicenseFileContent(content);
|
app.setConfigFileContent(content);
|
||||||
appRepository.save(app);
|
appRepository.save(app);
|
||||||
}
|
}
|
||||||
return content;
|
return content;
|
||||||
@ -112,7 +112,7 @@ public class AppService {
|
|||||||
app.setName(req.name());
|
app.setName(req.name());
|
||||||
app.setDescription(req.description());
|
app.setDescription(req.description());
|
||||||
app.setIconUrl(req.iconUrl());
|
app.setIconUrl(req.iconUrl());
|
||||||
app.setLicenseFileContent(generateLicenseFileContent(app));
|
app.setConfigFileContent(generateConfigFileContent(app));
|
||||||
AppEntity saved = appRepository.save(app);
|
AppEntity saved = appRepository.save(app);
|
||||||
Map<String, Object> after = new LinkedHashMap<>();
|
Map<String, Object> after = new LinkedHashMap<>();
|
||||||
after.put("name", saved.getName());
|
after.put("name", saved.getName());
|
||||||
@ -167,7 +167,7 @@ public class AppService {
|
|||||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String generateLicenseFileContent(AppEntity app) {
|
private String generateConfigFileContent(AppEntity app) {
|
||||||
Map<String, Object> payload = new LinkedHashMap<>();
|
Map<String, Object> payload = new LinkedHashMap<>();
|
||||||
payload.put("appKey", app.getAppKey());
|
payload.put("appKey", app.getAppKey());
|
||||||
payload.put("appName", app.getName());
|
payload.put("appName", app.getName());
|
||||||
@ -184,9 +184,9 @@ public class AppService {
|
|||||||
}
|
}
|
||||||
payload.put("issuedAt", java.time.Instant.now().toString());
|
payload.put("issuedAt", java.time.Instant.now().toString());
|
||||||
try {
|
try {
|
||||||
return LicenseFileCrypto.encrypt(MAPPER.valueToTree(payload).toString());
|
return ConfigFileCrypto.encrypt(MAPPER.valueToTree(payload).toString());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
throw new IllegalStateException("Failed to generate license file", e);
|
throw new IllegalStateException("Failed to generate config file", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户