feat: license 文件作为通用凭证支持所有服务 SDK 初始化
- LicenseFileCrypto 移至 common 模块并新增 decrypt() 方法 - LicenseFileCrypto.LicensePayload 携带 appKey / packageName / iosBundleId / harmonyBundleName,matchesPackageName() 支持三端包名任一匹配 - tenant-service downloadLicenseFile:去掉"License 服务已开通"限制,app 创建即可下载;payload 新增 iosBundleId / harmonyBundleName - im / push / update / license 四个服务 SDK 初始化端点均支持双模式: · licenseFile 模式:解密文件取 appKey,比对 packageName(无需调 tenant-service) · appKey 模式:调 tenant-service 取 platformInfo 比对 packageName(原有逻辑) - appKey 参数由必填改为可选(与 licenseFile 二选一) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
这个提交包含在:
父节点
0a267c5f70
当前提交
8c9bfb6acd
@ -0,0 +1,112 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
public final class LicenseFileCrypto {
|
||||||
|
|
||||||
|
private static final String MAGIC = "XUQM-LICENSE-V1";
|
||||||
|
private static final String PASSPHRASE = "xuqm-license-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 LicenseFileCrypto() {
|
||||||
|
}
|
||||||
|
|
||||||
|
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 license file", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static LicensePayload decrypt(String token) {
|
||||||
|
try {
|
||||||
|
String[] parts = token.split("\\.");
|
||||||
|
if (parts.length != 4 || !MAGIC.equals(parts[0])) {
|
||||||
|
throw new IllegalArgumentException("Invalid license 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 LicensePayload(
|
||||||
|
text(node, "appKey"),
|
||||||
|
text(node, "appName"),
|
||||||
|
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 license 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 LicensePayload(
|
||||||
|
String appKey,
|
||||||
|
String appName,
|
||||||
|
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,6 +2,7 @@ package com.xuqm.im.controller;
|
|||||||
|
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.common.security.LicenseFileCrypto;
|
||||||
import com.xuqm.im.service.ImAccountService;
|
import com.xuqm.im.service.ImAccountService;
|
||||||
import com.xuqm.im.service.ImAppSecretClient;
|
import com.xuqm.im.service.ImAppSecretClient;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
@ -27,28 +28,36 @@ public class AuthController {
|
|||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
|
||||||
@RequestParam @NotBlank String appKey,
|
@RequestParam(required = false) String appKey,
|
||||||
@RequestParam @NotBlank String userId,
|
@RequestParam @NotBlank String userId,
|
||||||
@RequestParam @NotBlank String userSig,
|
@RequestParam @NotBlank String userSig,
|
||||||
@RequestParam @NotBlank String packageName) {
|
@RequestParam @NotBlank String packageName,
|
||||||
if (userSig.isBlank()) {
|
@RequestParam(required = false) String licenseFile) {
|
||||||
return ResponseEntity.status(401).body(ApiResponse.error(401, "Missing userSig"));
|
String resolvedAppKey = resolveAndValidate(appKey, packageName, licenseFile);
|
||||||
}
|
ImAccountService.LoginResult result = accountService.loginWithUserSig(resolvedAppKey, userId, userSig);
|
||||||
validatePackageName(appKey, packageName);
|
|
||||||
ImAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig);
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token(), "admin", result.admin())));
|
return ResponseEntity.ok(ApiResponse.success(Map.of("token", result.token(), "admin", result.admin())));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validatePackageName(String appKey, String packageName) {
|
private String resolveAndValidate(String appKey, String packageName, String licenseFile) {
|
||||||
|
if (hasText(licenseFile)) {
|
||||||
|
LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile);
|
||||||
|
if (!payload.matchesPackageName(packageName)) {
|
||||||
|
throw new BusinessException(403, "包名与应用配置不匹配");
|
||||||
|
}
|
||||||
|
return payload.appKey();
|
||||||
|
}
|
||||||
|
if (!hasText(appKey)) {
|
||||||
|
throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个");
|
||||||
|
}
|
||||||
ImAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey);
|
ImAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey);
|
||||||
String android = info.androidPackageName();
|
boolean anyConfigured = hasText(info.androidPackageName()) || hasText(info.iosBundleId()) || hasText(info.harmonyBundleName());
|
||||||
String ios = info.iosBundleId();
|
|
||||||
String harmony = info.harmonyBundleName();
|
|
||||||
boolean anyConfigured = hasText(android) || hasText(ios) || hasText(harmony);
|
|
||||||
if (anyConfigured) {
|
if (anyConfigured) {
|
||||||
boolean matches = packageName.equals(android) || packageName.equals(ios) || packageName.equals(harmony);
|
boolean matches = packageName.equals(info.androidPackageName())
|
||||||
|
|| packageName.equals(info.iosBundleId())
|
||||||
|
|| packageName.equals(info.harmonyBundleName());
|
||||||
if (!matches) throw new BusinessException(403, "包名与应用配置不匹配");
|
if (!matches) throw new BusinessException(403, "包名与应用配置不匹配");
|
||||||
}
|
}
|
||||||
|
return appKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasText(String s) { return s != null && !s.isBlank(); }
|
private static boolean hasText(String s) { return s != null && !s.isBlank(); }
|
||||||
|
|||||||
@ -3,7 +3,9 @@ package com.xuqm.license.controller;
|
|||||||
import com.fasterxml.jackson.annotation.JsonAlias;
|
import com.fasterxml.jackson.annotation.JsonAlias;
|
||||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.xuqm.common.exception.BusinessException;
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.common.security.LicenseFileCrypto;
|
||||||
import com.xuqm.license.service.DeviceService;
|
import com.xuqm.license.service.DeviceService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
import jakarta.validation.constraints.NotBlank;
|
import jakarta.validation.constraints.NotBlank;
|
||||||
@ -27,8 +29,9 @@ public class LicensePublicController {
|
|||||||
|
|
||||||
@PostMapping("/register")
|
@PostMapping("/register")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> register(@Valid @RequestBody RegisterRequest req) {
|
public ResponseEntity<ApiResponse<Map<String, Object>>> register(@Valid @RequestBody RegisterRequest req) {
|
||||||
|
String resolvedAppKey = resolveAppKey(req.appKey(), req.packageName(), req.licenseFile());
|
||||||
DeviceService.RegisterResult result = deviceService.register(
|
DeviceService.RegisterResult result = deviceService.register(
|
||||||
req.appKey(),
|
resolvedAppKey,
|
||||||
req.packageName(),
|
req.packageName(),
|
||||||
req.deviceId(),
|
req.deviceId(),
|
||||||
req.deviceName(),
|
req.deviceName(),
|
||||||
@ -47,7 +50,8 @@ public class LicensePublicController {
|
|||||||
|
|
||||||
@PostMapping("/verify")
|
@PostMapping("/verify")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> verify(@Valid @RequestBody VerifyRequest req) {
|
public ResponseEntity<ApiResponse<Map<String, Object>>> verify(@Valid @RequestBody VerifyRequest req) {
|
||||||
DeviceService.VerifyResult result = deviceService.verify(req.appKey(), req.packageName(), req.deviceId(), req.token(), req.userInfo());
|
String resolvedAppKey = resolveAppKey(req.appKey(), req.packageName(), req.licenseFile());
|
||||||
|
DeviceService.VerifyResult result = deviceService.verify(resolvedAppKey, req.packageName(), req.deviceId(), req.token(), req.userInfo());
|
||||||
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
Map<String, Object> data = new java.util.LinkedHashMap<>();
|
||||||
data.put("valid", result.valid());
|
data.put("valid", result.valid());
|
||||||
if (result.error() != null) {
|
if (result.error() != null) {
|
||||||
@ -56,9 +60,24 @@ public class LicensePublicController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(data));
|
return ResponseEntity.ok(ApiResponse.success(data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String resolveAppKey(String appKey, String packageName, String licenseFile) {
|
||||||
|
if (licenseFile != null && !licenseFile.isBlank()) {
|
||||||
|
LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile);
|
||||||
|
if (!payload.matchesPackageName(packageName)) {
|
||||||
|
throw new BusinessException(403, "包名与应用配置不匹配");
|
||||||
|
}
|
||||||
|
return payload.appKey();
|
||||||
|
}
|
||||||
|
if (appKey == null || appKey.isBlank()) {
|
||||||
|
throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个");
|
||||||
|
}
|
||||||
|
return appKey;
|
||||||
|
}
|
||||||
|
|
||||||
public record RegisterRequest(
|
public record RegisterRequest(
|
||||||
@NotBlank String appKey,
|
String appKey,
|
||||||
@NotBlank @JsonProperty("packageName") @JsonAlias("package_name") String packageName,
|
@NotBlank @JsonProperty("packageName") @JsonAlias("package_name") String packageName,
|
||||||
|
@JsonProperty("licenseFile") @JsonAlias("license_file") String licenseFile,
|
||||||
@NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId,
|
@NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId,
|
||||||
@JsonProperty("deviceName") @JsonAlias("device_name") String deviceName,
|
@JsonProperty("deviceName") @JsonAlias("device_name") String deviceName,
|
||||||
@JsonProperty("deviceModel") @JsonAlias("device_model") String deviceModel,
|
@JsonProperty("deviceModel") @JsonAlias("device_model") String deviceModel,
|
||||||
@ -68,8 +87,9 @@ public class LicensePublicController {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
public record VerifyRequest(
|
public record VerifyRequest(
|
||||||
@NotBlank String appKey,
|
String appKey,
|
||||||
@NotBlank @JsonProperty("packageName") @JsonAlias("package_name") String packageName,
|
@NotBlank @JsonProperty("packageName") @JsonAlias("package_name") String packageName,
|
||||||
|
@JsonProperty("licenseFile") @JsonAlias("license_file") String licenseFile,
|
||||||
@NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId,
|
@NotBlank @JsonProperty("deviceId") @JsonAlias("device_id") String deviceId,
|
||||||
@NotBlank String token,
|
@NotBlank String token,
|
||||||
@JsonProperty("userInfo") @JsonAlias("user_info") JsonNode userInfo
|
@JsonProperty("userInfo") @JsonAlias("user_info") JsonNode userInfo
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package com.xuqm.push.controller;
|
|||||||
|
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
import com.xuqm.common.model.ApiResponse;
|
import com.xuqm.common.model.ApiResponse;
|
||||||
|
import com.xuqm.common.security.LicenseFileCrypto;
|
||||||
import com.xuqm.push.entity.DeviceTokenEntity;
|
import com.xuqm.push.entity.DeviceTokenEntity;
|
||||||
import com.xuqm.push.service.PushAccountService;
|
import com.xuqm.push.service.PushAccountService;
|
||||||
import com.xuqm.push.service.PushAppSecretClient;
|
import com.xuqm.push.service.PushAppSecretClient;
|
||||||
@ -31,10 +32,11 @@ public class PushAuthController {
|
|||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> login(
|
||||||
@RequestParam String appKey,
|
@RequestParam(required = false) String appKey,
|
||||||
@RequestParam String userId,
|
@RequestParam String userId,
|
||||||
@RequestParam String userSig,
|
@RequestParam String userSig,
|
||||||
@RequestParam String packageName,
|
@RequestParam String packageName,
|
||||||
|
@RequestParam(required = false) String licenseFile,
|
||||||
@RequestParam DeviceTokenEntity.Vendor vendor,
|
@RequestParam DeviceTokenEntity.Vendor vendor,
|
||||||
@RequestParam String token,
|
@RequestParam String token,
|
||||||
@RequestParam(required = false) String platform,
|
@RequestParam(required = false) String platform,
|
||||||
@ -44,9 +46,9 @@ public class PushAuthController {
|
|||||||
@RequestParam(required = false) String osVersion,
|
@RequestParam(required = false) String osVersion,
|
||||||
@RequestParam(required = false) String appVersion) {
|
@RequestParam(required = false) String appVersion) {
|
||||||
|
|
||||||
validatePackageName(appKey, packageName);
|
String resolvedAppKey = resolveAndValidate(appKey, packageName, licenseFile);
|
||||||
PushAccountService.LoginResult result = accountService.loginWithUserSig(appKey, userId, userSig);
|
PushAccountService.LoginResult result = accountService.loginWithUserSig(resolvedAppKey, userId, userSig);
|
||||||
pushDispatcher.registerToken(appKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion);
|
pushDispatcher.registerToken(resolvedAppKey, userId, vendor, token, platform, deviceId, brand, model, osVersion, appVersion);
|
||||||
|
|
||||||
Map<String, Object> response = Map.of(
|
Map<String, Object> response = Map.of(
|
||||||
"pushToken", result.pushToken(),
|
"pushToken", result.pushToken(),
|
||||||
@ -57,19 +59,29 @@ public class PushAuthController {
|
|||||||
return ResponseEntity.ok(ApiResponse.success(response));
|
return ResponseEntity.ok(ApiResponse.success(response));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validatePackageName(String appKey, String packageName) {
|
private String resolveAndValidate(String appKey, String packageName, String licenseFile) {
|
||||||
|
if (hasText(licenseFile)) {
|
||||||
|
LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile);
|
||||||
|
if (!payload.matchesPackageName(packageName)) {
|
||||||
|
throw new BusinessException(403, "包名与应用配置不匹配");
|
||||||
|
}
|
||||||
|
return payload.appKey();
|
||||||
|
}
|
||||||
|
if (!hasText(appKey)) {
|
||||||
|
throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个");
|
||||||
|
}
|
||||||
if (packageName == null || packageName.isBlank()) {
|
if (packageName == null || packageName.isBlank()) {
|
||||||
throw new BusinessException(403, "packageName is required");
|
throw new BusinessException(403, "packageName is required");
|
||||||
}
|
}
|
||||||
PushAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey);
|
PushAppSecretClient.PlatformInfo info = appSecretClient.getPlatformInfo(appKey);
|
||||||
String android = info.androidPackageName();
|
boolean anyConfigured = hasText(info.androidPackageName()) || hasText(info.iosBundleId()) || hasText(info.harmonyBundleName());
|
||||||
String ios = info.iosBundleId();
|
|
||||||
String harmony = info.harmonyBundleName();
|
|
||||||
boolean anyConfigured = hasText(android) || hasText(ios) || hasText(harmony);
|
|
||||||
if (anyConfigured) {
|
if (anyConfigured) {
|
||||||
boolean matches = packageName.equals(android) || packageName.equals(ios) || packageName.equals(harmony);
|
boolean matches = packageName.equals(info.androidPackageName())
|
||||||
|
|| packageName.equals(info.iosBundleId())
|
||||||
|
|| packageName.equals(info.harmonyBundleName());
|
||||||
if (!matches) throw new BusinessException(403, "包名与应用配置不匹配");
|
if (!matches) throw new BusinessException(403, "包名与应用配置不匹配");
|
||||||
}
|
}
|
||||||
|
return appKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static boolean hasText(String s) { return s != null && !s.isBlank(); }
|
private static boolean hasText(String s) { return s != null && !s.isBlank(); }
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import com.xuqm.tenant.service.AppService;
|
|||||||
import com.xuqm.tenant.service.AppUserClient;
|
import com.xuqm.tenant.service.AppUserClient;
|
||||||
import com.xuqm.tenant.service.EmailService;
|
import com.xuqm.tenant.service.EmailService;
|
||||||
import com.xuqm.tenant.service.FeatureServiceManager;
|
import com.xuqm.tenant.service.FeatureServiceManager;
|
||||||
import com.xuqm.tenant.service.LicenseFileCrypto;
|
import com.xuqm.common.security.LicenseFileCrypto;
|
||||||
import com.xuqm.tenant.config.PrivateDeploymentProperties;
|
import com.xuqm.tenant.config.PrivateDeploymentProperties;
|
||||||
import com.xuqm.tenant.service.OperationLogService;
|
import com.xuqm.tenant.service.OperationLogService;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
@ -172,18 +172,17 @@ public class AppController {
|
|||||||
public ResponseEntity<byte[]> downloadLicenseFile(@PathVariable String appKey,
|
public ResponseEntity<byte[]> downloadLicenseFile(@PathVariable String appKey,
|
||||||
@AuthenticationPrincipal String tenantId) {
|
@AuthenticationPrincipal String tenantId) {
|
||||||
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
AppEntity app = appService.getByAppKey(appKey, tenantId);
|
||||||
boolean licenseEnabled = featureServiceManager.listByApp(appKey).stream()
|
|
||||||
.anyMatch(service -> service.getServiceType() == FeatureServiceEntity.ServiceType.LICENSE && service.isEnabled());
|
|
||||||
if (!licenseEnabled) {
|
|
||||||
throw new BusinessException(400, "License 服务未开通");
|
|
||||||
}
|
|
||||||
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
Map<String, Object> payload = new java.util.LinkedHashMap<>();
|
||||||
payload.put("appKey", app.getAppKey());
|
payload.put("appKey", app.getAppKey());
|
||||||
payload.put("appName", app.getName());
|
payload.put("appName", app.getName());
|
||||||
payload.put("packageName", app.getPackageName());
|
payload.put("packageName", app.getPackageName());
|
||||||
|
if (app.getIosBundleId() != null && !app.getIosBundleId().isBlank()) {
|
||||||
|
payload.put("iosBundleId", app.getIosBundleId());
|
||||||
|
}
|
||||||
|
if (app.getHarmonyBundleName() != null && !app.getHarmonyBundleName().isBlank()) {
|
||||||
|
payload.put("harmonyBundleName", app.getHarmonyBundleName());
|
||||||
|
}
|
||||||
payload.put("baseUrl", normalizeBaseUrl(licensePublicBaseUrl));
|
payload.put("baseUrl", normalizeBaseUrl(licensePublicBaseUrl));
|
||||||
// serverUrl is set only for private deployments; the SDK uses it to configure all service
|
|
||||||
// endpoints automatically via XuqmSDK.autoInitialize(). Public deployments use default endpoints.
|
|
||||||
if (deployProps.isPrivate()) {
|
if (deployProps.isPrivate()) {
|
||||||
payload.put("serverUrl", normalizeBaseUrl(licensePublicBaseUrl));
|
payload.put("serverUrl", normalizeBaseUrl(licensePublicBaseUrl));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,55 +0,0 @@
|
|||||||
package com.xuqm.tenant.service;
|
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
public final class LicenseFileCrypto {
|
|
||||||
|
|
||||||
private static final String MAGIC = "XUQM-LICENSE-V1";
|
|
||||||
private static final String PASSPHRASE = "xuqm-license-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 LicenseFileCrypto() {
|
|
||||||
}
|
|
||||||
|
|
||||||
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 license file", 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -23,6 +23,7 @@ import com.xuqm.update.service.AppStoreService;
|
|||||||
import com.xuqm.update.service.ImPushUserClient;
|
import com.xuqm.update.service.ImPushUserClient;
|
||||||
import com.xuqm.update.service.UpdateTenantClient;
|
import com.xuqm.update.service.UpdateTenantClient;
|
||||||
import com.xuqm.common.exception.BusinessException;
|
import com.xuqm.common.exception.BusinessException;
|
||||||
|
import com.xuqm.common.security.LicenseFileCrypto;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/updates")
|
@RequestMapping("/api/v1/updates")
|
||||||
@ -56,21 +57,22 @@ public class AppVersionController {
|
|||||||
|
|
||||||
@GetMapping("/app/check")
|
@GetMapping("/app/check")
|
||||||
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
|
public ResponseEntity<ApiResponse<Map<String, Object>>> checkUpdate(
|
||||||
@RequestParam String appKey,
|
@RequestParam(required = false) String appKey,
|
||||||
@RequestParam AppVersionEntity.Platform platform,
|
@RequestParam AppVersionEntity.Platform platform,
|
||||||
@RequestParam int currentVersionCode,
|
@RequestParam int currentVersionCode,
|
||||||
@RequestParam @jakarta.validation.constraints.NotBlank String packageName,
|
@RequestParam @jakarta.validation.constraints.NotBlank String packageName,
|
||||||
|
@RequestParam(required = false) String licenseFile,
|
||||||
@RequestParam(required = false) String userId) {
|
@RequestParam(required = false) String userId) {
|
||||||
|
|
||||||
validatePackageName(appKey, platform, packageName);
|
String resolvedAppKey = resolveAndValidate(appKey, platform, packageName, licenseFile);
|
||||||
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(appKey);
|
boolean allowAnonymousCheck = publishConfigService.allowAnonymousUpdateCheck(resolvedAppKey);
|
||||||
|
|
||||||
Optional<AppVersionEntity> latest = versionRepository
|
Optional<AppVersionEntity> latest = versionRepository
|
||||||
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
|
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanOrderByVersionCodeDesc(
|
||||||
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
|
resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
|
||||||
Optional<AppVersionEntity> forcedHigher = versionRepository
|
Optional<AppVersionEntity> forcedHigher = versionRepository
|
||||||
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc(
|
.findTopByAppKeyAndPlatformAndPublishStatusAndVersionCodeGreaterThanAndForceUpdateTrueOrderByVersionCodeDesc(
|
||||||
appKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
|
resolvedAppKey, platform, AppVersionEntity.PublishStatus.PUBLISHED, currentVersionCode);
|
||||||
|
|
||||||
if (latest.isEmpty()) {
|
if (latest.isEmpty()) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
||||||
@ -78,8 +80,6 @@ public class AppVersionController {
|
|||||||
|
|
||||||
AppVersionEntity v = latest.get();
|
AppVersionEntity v = latest.get();
|
||||||
|
|
||||||
// Gray release: userId is required when anonymous checks are disabled and version is gray-targeted.
|
|
||||||
// Non-gray published versions are visible to all callers regardless of userId.
|
|
||||||
if (v.isGrayEnabled()) {
|
if (v.isGrayEnabled()) {
|
||||||
if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
|
if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
||||||
@ -91,16 +91,15 @@ public class AppVersionController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
|
} else if (!allowAnonymousCheck && (userId == null || userId.isBlank())) {
|
||||||
// App explicitly requires login to check for updates even without gray targeting.
|
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
return ResponseEntity.ok(ApiResponse.success(Map.of("needsUpdate", false)));
|
||||||
}
|
}
|
||||||
|
|
||||||
String appStoreJumpUrl = hasText(v.getAppStoreUrl())
|
String appStoreJumpUrl = hasText(v.getAppStoreUrl())
|
||||||
? v.getAppStoreUrl()
|
? v.getAppStoreUrl()
|
||||||
: appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE);
|
: appStoreService.getStoreJumpUrl(resolvedAppKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.APP_STORE);
|
||||||
String harmonyJumpUrl = hasText(v.getMarketUrl())
|
String harmonyJumpUrl = hasText(v.getMarketUrl())
|
||||||
? v.getMarketUrl()
|
? v.getMarketUrl()
|
||||||
: appStoreService.getStoreJumpUrl(appKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP);
|
: appStoreService.getStoreJumpUrl(resolvedAppKey, com.xuqm.update.entity.AppStoreConfigEntity.StoreType.HARMONY_APP);
|
||||||
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
return ResponseEntity.ok(ApiResponse.success(Map.of(
|
||||||
"needsUpdate", true,
|
"needsUpdate", true,
|
||||||
"versionName", v.getVersionName(),
|
"versionName", v.getVersionName(),
|
||||||
@ -495,7 +494,17 @@ public class AppVersionController {
|
|||||||
return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT";
|
return currentStatus == AppVersionEntity.PublishStatus.PUBLISHED ? "PUBLISH" : "SAVE_DRAFT";
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validatePackageName(String appKey, AppVersionEntity.Platform platform, String packageName) {
|
private String resolveAndValidate(String appKey, AppVersionEntity.Platform platform, String packageName, String licenseFile) {
|
||||||
|
if (hasText(licenseFile)) {
|
||||||
|
LicenseFileCrypto.LicensePayload payload = LicenseFileCrypto.decrypt(licenseFile);
|
||||||
|
if (!payload.matchesPackageName(packageName)) {
|
||||||
|
throw new BusinessException(403, "包名与应用配置不匹配");
|
||||||
|
}
|
||||||
|
return payload.appKey();
|
||||||
|
}
|
||||||
|
if (!hasText(appKey)) {
|
||||||
|
throw new BusinessException(400, "appKey 或 licenseFile 必须提供其中一个");
|
||||||
|
}
|
||||||
UpdateTenantClient.PlatformInfo info = tenantClient.getPlatformInfo(appKey);
|
UpdateTenantClient.PlatformInfo info = tenantClient.getPlatformInfo(appKey);
|
||||||
String registered = switch (platform) {
|
String registered = switch (platform) {
|
||||||
case IOS -> info.iosBundleId();
|
case IOS -> info.iosBundleId();
|
||||||
@ -505,6 +514,7 @@ public class AppVersionController {
|
|||||||
if (hasText(registered) && !registered.equals(packageName)) {
|
if (hasText(registered) && !registered.equals(packageName)) {
|
||||||
throw new BusinessException(403, "包名与应用配置不匹配");
|
throw new BusinessException(403, "包名与应用配置不匹配");
|
||||||
}
|
}
|
||||||
|
return appKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean hasText(String value) {
|
private boolean hasText(String value) {
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户