175 lines
6.7 KiB
Java
175 lines
6.7 KiB
Java
package com.yupi.springbootinit.component;
|
||
|
||
import cn.hutool.core.util.StrUtil;
|
||
import cn.hutool.crypto.SecureUtil;
|
||
import cn.hutool.crypto.symmetric.AES;
|
||
import com.yupi.springbootinit.common.ErrorCode;
|
||
import com.yupi.springbootinit.config.FeatureTicketProperties;
|
||
import com.yupi.springbootinit.exception.BusinessException;
|
||
import com.yupi.springbootinit.model.dto.ticket.FeatureTicketPayload;
|
||
import com.yupi.springbootinit.utils.JsonUtils;
|
||
import org.slf4j.Logger;
|
||
import org.slf4j.LoggerFactory;
|
||
import org.springframework.stereotype.Component;
|
||
|
||
import javax.annotation.PostConstruct;
|
||
import javax.annotation.Resource;
|
||
import java.nio.charset.StandardCharsets;
|
||
import java.util.Base64;
|
||
|
||
/**
|
||
* Feature Ticket 认证组件
|
||
* 负责 Ticket 的生成、加密、解密和校验
|
||
*
|
||
* @author ziin
|
||
*/
|
||
@Component
|
||
public class FeatureAuthComponent {
|
||
|
||
private static final Logger log = LoggerFactory.getLogger(FeatureAuthComponent.class);
|
||
|
||
@Resource
|
||
private FeatureTicketProperties ticketProperties;
|
||
|
||
private AES aes;
|
||
|
||
@PostConstruct
|
||
public void init() {
|
||
// 使用配置的 AES 密钥初始化加密器
|
||
byte[] keyBytes = ticketProperties.getAesKey().getBytes(StandardCharsets.UTF_8);
|
||
// 确保密钥长度为 16 字节(AES-128)
|
||
if (keyBytes.length < 16) {
|
||
byte[] paddedKey = new byte[16];
|
||
System.arraycopy(keyBytes, 0, paddedKey, 0, keyBytes.length);
|
||
keyBytes = paddedKey;
|
||
} else if (keyBytes.length > 16 && keyBytes.length < 24) {
|
||
byte[] truncatedKey = new byte[16];
|
||
System.arraycopy(keyBytes, 0, truncatedKey, 0, 16);
|
||
keyBytes = truncatedKey;
|
||
} else if (keyBytes.length > 24 && keyBytes.length < 32) {
|
||
byte[] truncatedKey = new byte[24];
|
||
System.arraycopy(keyBytes, 0, truncatedKey, 0, 24);
|
||
keyBytes = truncatedKey;
|
||
} else if (keyBytes.length > 32) {
|
||
byte[] truncatedKey = new byte[32];
|
||
System.arraycopy(keyBytes, 0, truncatedKey, 0, 32);
|
||
keyBytes = truncatedKey;
|
||
}
|
||
this.aes = SecureUtil.aes(keyBytes);
|
||
log.info("FeatureAuthComponent 初始化完成,AES 密钥长度: {} 字节", keyBytes.length);
|
||
}
|
||
|
||
/**
|
||
* 生成 Feature Ticket
|
||
*
|
||
* @param tenantId 租户 ID
|
||
* @param userId 用户 ID
|
||
* @param machineId 设备 ID
|
||
* @param featureCode 功能代码
|
||
* @param expireSeconds 有效期(秒)
|
||
* @return 加密后的 Base64 字符串
|
||
*/
|
||
public String generateTicket(Long tenantId, Long userId, String machineId,
|
||
String featureCode, Long expireSeconds) {
|
||
if (tenantId == null || userId == null || StrUtil.isBlank(machineId) || StrUtil.isBlank(featureCode)) {
|
||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "生成 Ticket 参数不完整");
|
||
}
|
||
|
||
// 计算过期时间
|
||
long expireTime = expireSeconds != null ? expireSeconds : ticketProperties.getDefaultExpireSeconds();
|
||
long expiryTimestamp = System.currentTimeMillis() + (expireTime * 1000);
|
||
|
||
// 构建载荷
|
||
FeatureTicketPayload payload = FeatureTicketPayload.builder()
|
||
.tenantId(tenantId)
|
||
.userId(userId)
|
||
.machineId(machineId)
|
||
.featureCode(featureCode)
|
||
.expiryTimestamp(expiryTimestamp)
|
||
.build();
|
||
|
||
// 序列化为 JSON
|
||
String jsonPayload = JsonUtils.toJsonString(payload);
|
||
|
||
// AES 加密
|
||
byte[] encryptedBytes = aes.encrypt(jsonPayload.getBytes(StandardCharsets.UTF_8));
|
||
|
||
// Base64 编码(URL 安全)
|
||
return Base64.getUrlEncoder().withoutPadding().encodeToString(encryptedBytes);
|
||
}
|
||
|
||
/**
|
||
* 解密 Ticket
|
||
*
|
||
* @param encryptedTicket 加密的 Ticket 字符串
|
||
* @return 解密后的载荷
|
||
*/
|
||
public FeatureTicketPayload decryptTicket(String encryptedTicket) {
|
||
if (StrUtil.isBlank(encryptedTicket)) {
|
||
throw new BusinessException(ErrorCode.TICKET_INVALID, "Ticket 不能为空");
|
||
}
|
||
|
||
try {
|
||
// Base64 解码
|
||
byte[] encryptedBytes = Base64.getUrlDecoder().decode(encryptedTicket);
|
||
|
||
// AES 解密
|
||
byte[] decryptedBytes = aes.decrypt(encryptedBytes);
|
||
String jsonPayload = new String(decryptedBytes, StandardCharsets.UTF_8);
|
||
|
||
// 反序列化
|
||
return JsonUtils.parseObject(jsonPayload, FeatureTicketPayload.class);
|
||
} catch (Exception e) {
|
||
log.error("Ticket 解密失败: {}", e.getMessage());
|
||
throw new BusinessException(ErrorCode.TICKET_INVALID, "Ticket 无效或已损坏");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 校验 Ticket
|
||
*
|
||
* @param encryptedTicket 加密的 Ticket
|
||
* @param requestMachineId 请求中的设备 ID
|
||
* @param requiredFeatureCode 需要的功能代码(可选,为 null 时不校验)
|
||
* @return 校验通过的载荷
|
||
*/
|
||
public FeatureTicketPayload validateTicket(String encryptedTicket, String requestMachineId,
|
||
String requiredFeatureCode) {
|
||
// 解密 Ticket
|
||
FeatureTicketPayload payload = decryptTicket(encryptedTicket);
|
||
|
||
// 校验过期时间
|
||
if (payload.getExpiryTimestamp() == null || System.currentTimeMillis() > payload.getExpiryTimestamp()) {
|
||
throw new BusinessException(ErrorCode.TICKET_EXPIRED, "Ticket 已过期");
|
||
}
|
||
|
||
// 校验设备 ID
|
||
if (StrUtil.isBlank(requestMachineId)) {
|
||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "请求中缺少设备 ID");
|
||
}
|
||
if (!requestMachineId.equals(payload.getMachineId())) {
|
||
log.warn("设备 ID 不匹配,请求: {}, Ticket: {}", requestMachineId, payload.getMachineId());
|
||
throw new BusinessException(ErrorCode.TICKET_MACHINE_MISMATCH, "设备 ID 不匹配");
|
||
}
|
||
|
||
// 校验功能代码(如果指定)
|
||
if (StrUtil.isNotBlank(requiredFeatureCode) && !requiredFeatureCode.equals(payload.getFeatureCode())) {
|
||
log.warn("功能代码不匹配,需要: {}, Ticket: {}", requiredFeatureCode, payload.getFeatureCode());
|
||
throw new BusinessException(ErrorCode.TICKET_FEATURE_MISMATCH, "功能代码不匹配");
|
||
}
|
||
|
||
return payload;
|
||
}
|
||
|
||
/**
|
||
* 简化校验(仅校验有效性和设备 ID)
|
||
*
|
||
* @param encryptedTicket 加密的 Ticket
|
||
* @param requestMachineId 请求中的设备 ID
|
||
* @return 校验通过的载荷
|
||
*/
|
||
public FeatureTicketPayload validateTicket(String encryptedTicket, String requestMachineId) {
|
||
return validateTicket(encryptedTicket, requestMachineId, null);
|
||
}
|
||
}
|