feat(platform): add LicenseSigner, LicenseService, LicenseController, and persistence entities

This commit is contained in:
2026-05-18 22:27:03 +08:00
parent 91aabb500c
commit 6f79bb97d9
9 changed files with 497 additions and 0 deletions
@@ -0,0 +1,45 @@
package cn.craftlabs.platform.api.license;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/licenses")
public class LicenseController {
private final LicenseService licenseService;
public LicenseController(LicenseService licenseService) {
this.licenseService = licenseService;
}
@PostMapping
@PreAuthorize("hasRole('LICENSE_OPS') or hasRole('ADMIN')")
public ResponseEntity<Map<String, Object>> create(@RequestBody Map<String, Object> request) {
try {
return ResponseEntity.ok(licenseService.create(request));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
@GetMapping("/{licenseId}")
@PreAuthorize("hasRole('LICENSE_OPS') or hasRole('ADMIN')")
public ResponseEntity<Map<String, Object>> get(@PathVariable String licenseId) {
Map<String, Object> result = licenseService.get(licenseId);
if (result == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(result);
}
@PostMapping("/{licenseId}/revoke")
@PreAuthorize("hasRole('LICENSE_OPS') or hasRole('ADMIN')")
public ResponseEntity<Void> revoke(@PathVariable String licenseId, @RequestBody Map<String, String> request) {
licenseService.revoke(licenseId, request.getOrDefault("reason", "manual"));
return ResponseEntity.ok().build();
}
}
@@ -0,0 +1,138 @@
package cn.craftlabs.platform.api.license;
import cn.craftlabs.platform.api.persistence.license.*;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.*;
@Service
public class LicenseService {
private final PlatformLicenseMapper licenseMapper;
private final PlatformLicenseActivationMapper activationMapper;
private final PlatformLicenseKeyMapper keyMapper;
private final LicenseSigner signer;
public LicenseService(PlatformLicenseMapper licenseMapper,
PlatformLicenseActivationMapper activationMapper,
PlatformLicenseKeyMapper keyMapper,
LicenseSigner signer) {
this.licenseMapper = licenseMapper;
this.activationMapper = activationMapper;
this.keyMapper = keyMapper;
this.signer = signer;
}
@Transactional
public Map<String, Object> create(Map<String, Object> request) throws Exception {
PlatformLicenseKey activeKey = findActiveKey();
if (activeKey == null) {
throw new IllegalStateException("No active license key configured");
}
LicenseSigner.LicensePayload payload = new LicenseSigner.LicensePayload();
payload.setLicenseId(UUID.randomUUID().toString().replace("-", "").substring(0, 26));
payload.setTenantId((String) request.get("tenantId"));
payload.setProduct((String) request.getOrDefault("product", "default"));
LicenseSigner.Grant grant = new LicenseSigner.Grant();
grant.setType((String) request.getOrDefault("grantType", "subscription"));
grant.setNotBefore(toEpoch(request.get("notBefore")));
grant.setNotAfter(toEpoch(request.get("notAfter")));
grant.setOfflineGraceDays((Integer) request.getOrDefault("offlineGraceDays", 7));
grant.setHeartbeatIntervalHours((Integer) request.getOrDefault("heartbeatIntervalHours", 24));
payload.setGrant(grant);
LicenseSigner.Constraints constraints = new LicenseSigner.Constraints();
constraints.setMaxDevices((Integer) request.getOrDefault("maxDevices", 1));
constraints.setMaxConcurrentUsers((Integer) request.getOrDefault("maxConcurrentUsers", 0));
constraints.setMaxActivations((Integer) request.getOrDefault("maxActivations", 0));
payload.setConstraints(constraints);
@SuppressWarnings("unchecked")
Map<String, Boolean> features = (Map<String, Boolean>) request.getOrDefault("features", new HashMap<>());
payload.setFeatures(features);
@SuppressWarnings("unchecked")
Map<String, String> custom = (Map<String, String>) request.getOrDefault("custom", new HashMap<>());
payload.setCustom(custom);
String signedJson = signer.sign(payload, activeKey);
PlatformLicense entity = new PlatformLicense();
entity.setId(UUID.randomUUID());
entity.setLicenseId(payload.getLicenseId());
entity.setTenantId(payload.getTenantId());
entity.setGrantType(grant.getType());
entity.setNotBefore(grant.getNotBefore() != null ? toOffsetDateTime(grant.getNotBefore()) : OffsetDateTime.now());
entity.setNotAfter(grant.getNotAfter() != null ? toOffsetDateTime(grant.getNotAfter()) : null);
entity.setOfflineGraceDays(grant.getOfflineGraceDays());
entity.setHeartbeatIntervalHours(grant.getHeartbeatIntervalHours());
entity.setMaxDevices(constraints.getMaxDevices());
entity.setMaxConcurrentUsers(constraints.getMaxConcurrentUsers());
entity.setMaxActivations(constraints.getMaxActivations());
entity.setStatus("active");
entity.setIssuedAt(OffsetDateTime.now());
entity.setSignedPayload(signedJson);
entity.setKeyId(activeKey.getKeyId());
licenseMapper.insert(entity);
Map<String, Object> response = new HashMap<>();
response.put("licenseId", payload.getLicenseId());
response.put("status", "active");
response.put("signedPayload", signedJson);
response.put("issuedAt", OffsetDateTime.now().toString());
return response;
}
public Map<String, Object> get(String licenseId) {
PlatformLicense entity = licenseMapper.selectOne(
new LambdaQueryWrapper<PlatformLicense>().eq(PlatformLicense::getLicenseId, licenseId));
if (entity == null) {
return null;
}
Map<String, Object> resp = new HashMap<>();
resp.put("licenseId", entity.getLicenseId());
resp.put("tenantId", entity.getTenantId());
resp.put("status", entity.getStatus());
resp.put("grantType", entity.getGrantType());
resp.put("maxDevices", entity.getMaxDevices());
resp.put("offlineGraceDays", entity.getOfflineGraceDays());
resp.put("issuedAt", entity.getIssuedAt() != null ? entity.getIssuedAt().toString() : null);
return resp;
}
@Transactional
public void revoke(String licenseId, String reason) {
PlatformLicense entity = licenseMapper.selectOne(
new LambdaQueryWrapper<PlatformLicense>().eq(PlatformLicense::getLicenseId, licenseId));
if (entity != null) {
entity.setStatus("revoked");
entity.setRevokedAt(OffsetDateTime.now());
entity.setRevokedReason(reason);
licenseMapper.updateById(entity);
}
}
private PlatformLicenseKey findActiveKey() {
List<PlatformLicenseKey> keys = keyMapper.selectList(
new LambdaQueryWrapper<PlatformLicenseKey>()
.eq(PlatformLicenseKey::getStatus, "active")
.orderByDesc(PlatformLicenseKey::getCreatedAt)
.last("LIMIT 1"));
return keys.isEmpty() ? null : keys.get(0);
}
private static Long toEpoch(Object value) {
if (value instanceof Number) return ((Number) value).longValue();
if (value instanceof String) return Long.parseLong((String) value);
return null;
}
private static OffsetDateTime toOffsetDateTime(long epochSeconds) {
return OffsetDateTime.ofInstant(java.time.Instant.ofEpochSecond(epochSeconds), java.time.ZoneOffset.UTC);
}
}
@@ -0,0 +1,180 @@
package cn.craftlabs.platform.api.license;
import cn.craftlabs.platform.api.persistence.license.PlatformLicenseKey;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.security.*;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.Instant;
import java.util.Base64;
import java.util.Map;
@Service
public class LicenseSigner {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final String AES_ALGO = "AES/GCM/NoPadding";
private static final int GCM_TAG_LEN = 128;
private static final int GCM_NONCE_LEN = 12;
private static final String EMBEDDED_SALT = "craftlabs-license-salt-v1-2026-05";
public String sign(LicensePayload payload, PlatformLicenseKey key) throws Exception {
byte[] payloadJson = MAPPER.writeValueAsBytes(payload);
byte[] aesKey = deriveAesKey(EMBEDDED_SALT, payload.getLicenseId());
byte[] encrypted = aesGcmEncrypt(aesKey, payloadJson);
String payloadB64 = Base64.getUrlEncoder().withoutPadding().encodeToString(encrypted);
PrivateKey privateKey = loadPrivateKey(key.getPrivateKey());
Signature sig = Signature.getInstance("SHA256withRSA");
sig.initSign(privateKey);
sig.update(encrypted);
String sigB64 = Base64.getUrlEncoder().withoutPadding().encodeToString(sig.sign());
LicenseDocument doc = new LicenseDocument();
doc.setVersion(1);
doc.setLicenseId(payload.getLicenseId());
doc.setIssuedAt(Instant.now().toString());
doc.setPayload(payloadB64);
SignatureBlock block = new SignatureBlock();
block.setAlgorithm("RS256");
block.setKeyId(key.getKeyId());
block.setValue(sigB64);
doc.setSignature(block);
return MAPPER.writeValueAsString(doc);
}
private byte[] deriveAesKey(String salt, String licenseId) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(salt.getBytes("UTF-8"), "HmacSHA256"));
byte[] prk = mac.doFinal(new byte[0]);
mac.init(new SecretKeySpec(prk, "HmacSHA256"));
mac.update(licenseId.getBytes("UTF-8"));
mac.update((byte) 0x01);
return mac.doFinal();
}
private byte[] aesGcmEncrypt(byte[] key, byte[] plaintext) throws Exception {
SecureRandom sr = new SecureRandom();
byte[] nonce = new byte[GCM_NONCE_LEN];
sr.nextBytes(nonce);
Cipher cipher = Cipher.getInstance(AES_ALGO);
GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LEN, nonce);
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), spec);
byte[] ct = cipher.doFinal(plaintext);
byte[] result = new byte[GCM_NONCE_LEN + ct.length];
System.arraycopy(nonce, 0, result, 0, GCM_NONCE_LEN);
System.arraycopy(ct, 0, result, GCM_NONCE_LEN, ct.length);
return result;
}
private PrivateKey loadPrivateKey(String pem) throws Exception {
String key = pem
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] decoded = Base64.getDecoder().decode(key);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
return KeyFactory.getInstance("RSA").generatePrivate(spec);
}
// Inner DTOs
public static class LicenseDocument {
private int version;
private String licenseId;
private String issuedAt;
private String payload;
private SignatureBlock signature;
public int getVersion() { return version; }
public void setVersion(int version) { this.version = version; }
public String getLicenseId() { return licenseId; }
public void setLicenseId(String licenseId) { this.licenseId = licenseId; }
public String getIssuedAt() { return issuedAt; }
public void setIssuedAt(String issuedAt) { this.issuedAt = issuedAt; }
public String getPayload() { return payload; }
public void setPayload(String payload) { this.payload = payload; }
public SignatureBlock getSignature() { return signature; }
public void setSignature(SignatureBlock signature) { this.signature = signature; }
}
public static class SignatureBlock {
private String algorithm;
private String keyId;
private String value;
public String getAlgorithm() { return algorithm; }
public void setAlgorithm(String algorithm) { this.algorithm = algorithm; }
public String getKeyId() { return keyId; }
public void setKeyId(String keyId) { this.keyId = keyId; }
public String getValue() { return value; }
public void setValue(String value) { this.value = value; }
}
public static class LicensePayload {
private String licenseId;
private String tenantId;
private String product;
private Grant grant;
private Constraints constraints;
private Map<String, Boolean> features;
private Map<String, String> custom;
public String getLicenseId() { return licenseId; }
public void setLicenseId(String licenseId) { this.licenseId = licenseId; }
public String getTenantId() { return tenantId; }
public void setTenantId(String tenantId) { this.tenantId = tenantId; }
public String getProduct() { return product; }
public void setProduct(String product) { this.product = product; }
public Grant getGrant() { return grant; }
public void setGrant(Grant grant) { this.grant = grant; }
public Constraints getConstraints() { return constraints; }
public void setConstraints(Constraints constraints) { this.constraints = constraints; }
public Map<String, Boolean> getFeatures() { return features; }
public void setFeatures(Map<String, Boolean> features) { this.features = features; }
public Map<String, String> getCustom() { return custom; }
public void setCustom(Map<String, String> custom) { this.custom = custom; }
}
public static class Grant {
private String type;
private Long notBefore;
private Long notAfter;
private int offlineGraceDays;
private int heartbeatIntervalHours;
public String getType() { return type; }
public void setType(String type) { this.type = type; }
public Long getNotBefore() { return notBefore; }
public void setNotBefore(Long notBefore) { this.notBefore = notBefore; }
public Long getNotAfter() { return notAfter; }
public void setNotAfter(Long notAfter) { this.notAfter = notAfter; }
public int getOfflineGraceDays() { return offlineGraceDays; }
public void setOfflineGraceDays(int offlineGraceDays) { this.offlineGraceDays = offlineGraceDays; }
public int getHeartbeatIntervalHours() { return heartbeatIntervalHours; }
public void setHeartbeatIntervalHours(int heartbeatIntervalHours) { this.heartbeatIntervalHours = heartbeatIntervalHours; }
}
public static class Constraints {
private int maxDevices;
private int maxConcurrentUsers;
private int maxActivations;
public int getMaxDevices() { return maxDevices; }
public void setMaxDevices(int maxDevices) { this.maxDevices = maxDevices; }
public int getMaxConcurrentUsers() { return maxConcurrentUsers; }
public void setMaxConcurrentUsers(int maxConcurrentUsers) { this.maxConcurrentUsers = maxConcurrentUsers; }
public int getMaxActivations() { return maxActivations; }
public void setMaxActivations(int maxActivations) { this.maxActivations = maxActivations; }
}
}
@@ -0,0 +1,54 @@
package cn.craftlabs.platform.api.persistence.license;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.OffsetDateTime;
import java.util.UUID;
@TableName("platform_licenses")
public class PlatformLicense {
@TableId private UUID id;
@TableField("license_id") private String licenseId;
@TableField("tenant_id") private String tenantId;
@TableField("contract_id") private UUID contractId;
@TableField("policy_id") private UUID policyId;
@TableField("grant_type") private String grantType;
@TableField("not_before") private OffsetDateTime notBefore;
@TableField("not_after") private OffsetDateTime notAfter;
@TableField("offline_grace_days") private Integer offlineGraceDays;
@TableField("heartbeat_interval_hours") private Integer heartbeatIntervalHours;
@TableField("max_devices") private Integer maxDevices;
@TableField("max_concurrent_users") private Integer maxConcurrentUsers;
@TableField("max_activations") private Integer maxActivations;
private String status;
@TableField("issued_at") private OffsetDateTime issuedAt;
@TableField("revoked_at") private OffsetDateTime revokedAt;
@TableField("revoked_reason") private String revokedReason;
@TableField("signed_payload") private String signedPayload;
@TableField("key_id") private String keyId;
@TableField("created_at") private OffsetDateTime createdAt;
@TableField("updated_at") private OffsetDateTime updatedAt;
public UUID getId() { return id; } public void setId(UUID id) { this.id = id; }
public String getLicenseId() { return licenseId; } public void setLicenseId(String licenseId) { this.licenseId = licenseId; }
public String getTenantId() { return tenantId; } public void setTenantId(String tenantId) { this.tenantId = tenantId; }
public UUID getContractId() { return contractId; } public void setContractId(UUID contractId) { this.contractId = contractId; }
public UUID getPolicyId() { return policyId; } public void setPolicyId(UUID policyId) { this.policyId = policyId; }
public String getGrantType() { return grantType; } public void setGrantType(String grantType) { this.grantType = grantType; }
public OffsetDateTime getNotBefore() { return notBefore; } public void setNotBefore(OffsetDateTime notBefore) { this.notBefore = notBefore; }
public OffsetDateTime getNotAfter() { return notAfter; } public void setNotAfter(OffsetDateTime notAfter) { this.notAfter = notAfter; }
public Integer getOfflineGraceDays() { return offlineGraceDays; } public void setOfflineGraceDays(Integer d) { this.offlineGraceDays = d; }
public Integer getHeartbeatIntervalHours() { return heartbeatIntervalHours; } public void setHeartbeatIntervalHours(Integer h) { this.heartbeatIntervalHours = h; }
public Integer getMaxDevices() { return maxDevices; } public void setMaxDevices(Integer v) { this.maxDevices = v; }
public Integer getMaxConcurrentUsers() { return maxConcurrentUsers; } public void setMaxConcurrentUsers(Integer v) { this.maxConcurrentUsers = v; }
public Integer getMaxActivations() { return maxActivations; } public void setMaxActivations(Integer v) { this.maxActivations = v; }
public String getStatus() { return status; } public void setStatus(String status) { this.status = status; }
public OffsetDateTime getIssuedAt() { return issuedAt; } public void setIssuedAt(OffsetDateTime v) { this.issuedAt = v; }
public OffsetDateTime getRevokedAt() { return revokedAt; } public void setRevokedAt(OffsetDateTime v) { this.revokedAt = v; }
public String getRevokedReason() { return revokedReason; } public void setRevokedReason(String v) { this.revokedReason = v; }
public String getSignedPayload() { return signedPayload; } public void setSignedPayload(String v) { this.signedPayload = v; }
public String getKeyId() { return keyId; } public void setKeyId(String v) { this.keyId = v; }
public OffsetDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(OffsetDateTime v) { this.createdAt = v; }
public OffsetDateTime getUpdatedAt() { return updatedAt; } public void setUpdatedAt(OffsetDateTime v) { this.updatedAt = v; }
}
@@ -0,0 +1,29 @@
package cn.craftlabs.platform.api.persistence.license;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.OffsetDateTime;
import java.util.UUID;
@TableName("platform_license_activations")
public class PlatformLicenseActivation {
private UUID id;
@TableField("license_id") private String licenseId;
@TableField("device_hash") private String deviceHash;
@TableField("stability_score") private Short stabilityScore;
@TableField("server_uuid") private String serverUuid;
private String status;
@TableField("first_seen_at") private OffsetDateTime firstSeenAt;
@TableField("last_heartbeat") private OffsetDateTime lastHeartbeat;
@TableField("deactivated_at") private OffsetDateTime deactivatedAt;
public UUID getId() { return id; } public void setId(UUID id) { this.id = id; }
public String getLicenseId() { return licenseId; } public void setLicenseId(String v) { this.licenseId = v; }
public String getDeviceHash() { return deviceHash; } public void setDeviceHash(String v) { this.deviceHash = v; }
public Short getStabilityScore() { return stabilityScore; } public void setStabilityScore(Short v) { this.stabilityScore = v; }
public String getServerUuid() { return serverUuid; } public void setServerUuid(String v) { this.serverUuid = v; }
public String getStatus() { return status; } public void setStatus(String v) { this.status = v; }
public OffsetDateTime getFirstSeenAt() { return firstSeenAt; } public void setFirstSeenAt(OffsetDateTime v) { this.firstSeenAt = v; }
public OffsetDateTime getLastHeartbeat() { return lastHeartbeat; } public void setLastHeartbeat(OffsetDateTime v) { this.lastHeartbeat = v; }
public OffsetDateTime getDeactivatedAt() { return deactivatedAt; } public void setDeactivatedAt(OffsetDateTime v) { this.deactivatedAt = v; }
}
@@ -0,0 +1,8 @@
package cn.craftlabs.platform.api.persistence.license;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PlatformLicenseActivationMapper extends BaseMapper<PlatformLicenseActivation> {
}
@@ -0,0 +1,27 @@
package cn.craftlabs.platform.api.persistence.license;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import java.time.OffsetDateTime;
import java.util.UUID;
@TableName("platform_license_keys")
public class PlatformLicenseKey {
private UUID id;
@TableField("key_id") private String keyId;
@TableField("public_key") private String publicKey;
@TableField("private_key") private String privateKey;
private String algorithm;
private String status;
@TableField("created_at") private OffsetDateTime createdAt;
@TableField("rotated_at") private OffsetDateTime rotatedAt;
public UUID getId() { return id; } public void setId(UUID id) { this.id = id; }
public String getKeyId() { return keyId; } public void setKeyId(String v) { this.keyId = v; }
public String getPublicKey() { return publicKey; } public void setPublicKey(String v) { this.publicKey = v; }
public String getPrivateKey() { return privateKey; } public void setPrivateKey(String v) { this.privateKey = v; }
public String getAlgorithm() { return algorithm; } public void setAlgorithm(String v) { this.algorithm = v; }
public String getStatus() { return status; } public void setStatus(String v) { this.status = v; }
public OffsetDateTime getCreatedAt() { return createdAt; } public void setCreatedAt(OffsetDateTime v) { this.createdAt = v; }
public OffsetDateTime getRotatedAt() { return rotatedAt; } public void setRotatedAt(OffsetDateTime v) { this.rotatedAt = v; }
}
@@ -0,0 +1,8 @@
package cn.craftlabs.platform.api.persistence.license;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PlatformLicenseKeyMapper extends BaseMapper<PlatformLicenseKey> {
}
@@ -0,0 +1,8 @@
package cn.craftlabs.platform.api.persistence.license;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface PlatformLicenseMapper extends BaseMapper<PlatformLicense> {
}