diff --git a/web-api/ruoyi-admin/src/main/resources/application.yml b/web-api/ruoyi-admin/src/main/resources/application.yml index 4722c5b..388abff 100644 --- a/web-api/ruoyi-admin/src/main/resources/application.yml +++ b/web-api/ruoyi-admin/src/main/resources/application.yml @@ -240,6 +240,7 @@ volcengine: callbackUrl: http://47.86.170.114:8011/api/ai/volcCallback ak: AKLTNmYyN2VhZTcyMDcxNDNlNzg3OGVlMDVmZjRhNWQwY2M sk: Tm1ZeU1UTmlORFk1WmpKa05HUmpaRGcxTWpjMFpqUmpOVE01TUdJME5URQ== + projectAesKeyBase64: "gJajABVfQJ9xA94Q9IvQi68fqqhSIkfcKlG7pjGFt2U=" url: https://ark.ap-southeast.bytepluses.com/api/v3 apiKey: 3e33e034-7e25-4228-8864-b51b2a7a8f97 callBackUrl: http://47.86.170.114:5173/ diff --git a/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/crypto/AesCbcPkcs5Decrypt.java b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/crypto/AesCbcPkcs5Decrypt.java new file mode 100644 index 0000000..42b1bab --- /dev/null +++ b/web-api/ruoyi-common/src/main/java/com/ruoyi/common/utils/crypto/AesCbcPkcs5Decrypt.java @@ -0,0 +1,76 @@ +package com.ruoyi.common.utils.crypto; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Arrays; +import java.util.Base64; + +/** + * AES-256-CBC PKCS5 加/解密成对使用。密文存库形态:整段 Base64( IV(16字节) ‖ 密文 ),无前缀。 + */ +public final class AesCbcPkcs5Decrypt { + + private static final int IV_LENGTH = 16; + private static final int AES_KEY_LENGTH = 32; + private static final String AES_CBC_PKCS5 = "AES/CBC/PKCS5Padding"; + + private AesCbcPkcs5Decrypt() { + } + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + /** + * 使用随机 IV 加密明文,输出与 {@link #decrypt(String, byte[])} 输入格式一致。 + * + * @param plainText UTF-8 明文;{@code null} 非法 + * @param aes256Key 32 字节 AES-256 密钥 + * @return Base64( IV ‖ ciphertext ),无换行 + */ + public static String encrypt(String plainText, byte[] aes256Key) throws GeneralSecurityException { + if (plainText == null) { + throw new IllegalArgumentException("plain text must not be null"); + } + if (aes256Key == null || aes256Key.length != AES_KEY_LENGTH) { + throw new IllegalArgumentException("aes key must be 32 bytes"); + } + byte[] iv = new byte[IV_LENGTH]; + SECURE_RANDOM.nextBytes(iv); + SecretKeySpec keySpec = new SecretKeySpec(aes256Key, "AES"); + Cipher cipher = Cipher.getInstance(AES_CBC_PKCS5); + cipher.init(Cipher.ENCRYPT_MODE, keySpec, new IvParameterSpec(iv)); + byte[] cipherBytes = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + byte[] combined = new byte[IV_LENGTH + cipherBytes.length]; + System.arraycopy(iv, 0, combined, 0, IV_LENGTH); + System.arraycopy(cipherBytes, 0, combined, IV_LENGTH, cipherBytes.length); + return Base64.getEncoder().encodeToString(combined); + } + + /** + * @param base64IvAndCipher Base64( IV ‖ ciphertext ) + * @param aes256Key 32 字节 AES-256 密钥 + * @return UTF-8 明文 + */ + public static String decrypt(String base64IvAndCipher, byte[] aes256Key) throws GeneralSecurityException { + if (base64IvAndCipher == null || base64IvAndCipher.isEmpty()) { + throw new IllegalArgumentException("empty payload"); + } + if (aes256Key == null || aes256Key.length != AES_KEY_LENGTH) { + throw new IllegalArgumentException("aes key must be 32 bytes"); + } + byte[] combined = Base64.getDecoder().decode(base64IvAndCipher.trim()); + if (combined.length <= IV_LENGTH) { + throw new GeneralSecurityException("payload too short after base64 decode"); + } + byte[] iv = Arrays.copyOfRange(combined, 0, IV_LENGTH); + byte[] ciphertext = Arrays.copyOfRange(combined, IV_LENGTH, combined.length); + SecretKeySpec keySpec = new SecretKeySpec(aes256Key, "AES"); + Cipher cipher = Cipher.getInstance(AES_CBC_PKCS5); + cipher.init(Cipher.DECRYPT_MODE, keySpec, new IvParameterSpec(iv)); + byte[] plain = cipher.doFinal(ciphertext); + return new String(plain, StandardCharsets.UTF_8); + } +} diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/BaseByteApiService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/BaseByteApiService.java index 7b95787..5097565 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/BaseByteApiService.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/BaseByteApiService.java @@ -3,6 +3,7 @@ package com.ruoyi.ai.service.impl; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.databind.*; import com.ruoyi.ai.mapper.AiUserMapper; +import com.ruoyi.common.EncryptionService; import com.ruoyi.common.core.domain.entity.AiUser; import com.ruoyi.common.core.domain.entity.SysDept; import com.ruoyi.common.utils.SecurityUtils; @@ -12,7 +13,6 @@ import lombok.extern.slf4j.Slf4j; import okhttp3.*; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; - import javax.annotation.PostConstruct; import javax.annotation.Resource; import javax.crypto.Mac; @@ -20,9 +20,6 @@ import javax.crypto.spec.SecretKeySpec; import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; import java.security.MessageDigest; import java.time.ZoneOffset; import java.time.ZonedDateTime; @@ -38,6 +35,8 @@ public class BaseByteApiService { private AiUserMapper userMapper; @Resource private SysDeptMapper deptMapper; + @Resource + private EncryptionService encryptionService; protected String DEPT_ANCESTORS_SPLIT = ","; @Value("${volcengine.ak}") protected String accessKeyId; @@ -88,7 +87,7 @@ public class BaseByteApiService { return null; } SysDept secondDept = deptMapper.selectDeptById(secondLvDeptId); - return secondDept.getProject(); + return encryptionService.decode(secondDept.getProject()); } /** @@ -195,13 +194,6 @@ public class BaseByteApiService { return new SignedRequest(url, xContentSha256, xDate, authorization); } - private static String esc(String s) { - if (s == null) { - return ""; - } - return s.replace("\\", "\\\\").replace("\"", "\\\""); - } - private static String canonicalQueryString(Map params) throws IOException { StringBuilder sb = new StringBuilder(); for (String key : new TreeMap<>(params).keySet()) { diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteDeptApiKeyServiceImpl.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteDeptApiKeyServiceImpl.java index 5c03a99..fd7a7b3 100644 --- a/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteDeptApiKeyServiceImpl.java +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/ai/service/impl/ByteDeptApiKeyServiceImpl.java @@ -1,21 +1,27 @@ package com.ruoyi.ai.service.impl; -import com.ruoyi.ai.service.IByteDeptApiKeyService; import com.ruoyi.ai.service.IAiUserService; +import com.ruoyi.ai.service.IByteDeptApiKeyService; +import com.ruoyi.common.EncryptionService; import com.ruoyi.common.core.domain.entity.AiUser; import com.ruoyi.common.core.domain.entity.SysDept; import com.ruoyi.common.core.redis.RedisCache; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.utils.StringUtils; import com.ruoyi.system.service.ISysDeptService; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import javax.annotation.Resource; import java.util.concurrent.TimeUnit; +@Slf4j @Service public class ByteDeptApiKeyServiceImpl implements IByteDeptApiKeyService { + @Resource + private EncryptionService encryptionService; private static final String NO_DEPT_MSG = "用户未分配部门:请在后台为门户用户设置 ai_user.dept_id(关联 sys_dept.dept_id)"; private static final String NO_DEPT_ROW_MSG = "用户所属部门不存在或已删除,请核对 ai_user.dept_id"; private static final String NO_API_KEY_MSG = "部门未配置火山 API Key:请在 sys_dept 为用户所在部门(或其上级二级部门)配置 byte_api_key"; @@ -38,7 +44,8 @@ public class ByteDeptApiKeyServiceImpl implements IByteDeptApiKeyService { String cacheKey = aiUserId + "_byte_api_key"; String cached = redisCache.getCacheObject(cacheKey); if (StringUtils.isNotEmpty(cached)) { - return cached; + // 解密:缓存保存加密后的字符串比较安全 + return encryptionService.decode(cached); } AiUser aiUser = aiUserService.selectAiUserById(aiUserId); if (aiUser == null || aiUser.getDeptId() == null) { @@ -62,8 +69,10 @@ public class ByteDeptApiKeyServiceImpl implements IByteDeptApiKeyService { if (StringUtils.isEmpty(apiKey)) { throw new ServiceException(NO_API_KEY_MSG); } + // 缓存中保存加密值 redisCache.setCacheObject(cacheKey, apiKey, CACHE_HOURS, TimeUnit.HOURS); - return apiKey; + // 返回处解密 + return encryptionService.decode(apiKey); } private static String trimKey(String raw) { diff --git a/web-api/ruoyi-system/src/main/java/com/ruoyi/common/EncryptionService.java b/web-api/ruoyi-system/src/main/java/com/ruoyi/common/EncryptionService.java new file mode 100644 index 0000000..e539e80 --- /dev/null +++ b/web-api/ruoyi-system/src/main/java/com/ruoyi/common/EncryptionService.java @@ -0,0 +1,82 @@ +package com.ruoyi.common; + +import com.ruoyi.common.utils.crypto.AesCbcPkcs5Decrypt; +import com.ruoyi.common.utils.http.OkHttpUtils; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.security.GeneralSecurityException; +import java.util.Base64; + +/** + * 加解密服务 + */ +@Slf4j +@Component +public class EncryptionService { + /** Base64 编码的 32 字节 AES-256 密钥;空表示不对 project 字段解密 */ + @Value("${volcengine.projectAesKeyBase64}") + private String projectAesKeyBase64; + + /** 解析后的密钥;未配置或非法则为 null */ + private byte[] projectAesKeyBytes; + + @PostConstruct + public void init() { + this.projectAesKeyBytes = decodeProjectAesKey(projectAesKeyBase64); + } + + private byte[] decodeProjectAesKey(String base64Key) { + if (base64Key == null || base64Key.trim().isEmpty()) { + return null; + } + try { + byte[] k = Base64.getDecoder().decode(base64Key.trim()); + if (k.length != 32) { + log.error("volcengine.projectAesKeyBase64 must decode to 32 bytes, got {}", k.length); + return null; + } + return k; + } catch (IllegalArgumentException e) { + log.error("invalid volcengine.projectAesKeyBase64", e); + return null; + } + } + + /** + * 解密 + * @param value + * @return + */ + public String decode(String value){ + if (value == null || value.isEmpty()) { + return value; + } + if (projectAesKeyBytes == null) { + return value; + } + try { + return AesCbcPkcs5Decrypt.decrypt(value.trim(), projectAesKeyBytes); + } catch (GeneralSecurityException | IllegalArgumentException e) { + log.warn("project field AES decrypt failed, using raw value: {}", e.getMessage()); + throw new RuntimeException(e); + } + } + + public String encode(String value) { + if (value == null || value.isEmpty()) { + return value; + } + if (projectAesKeyBytes == null) { + return value; + } + try { + return AesCbcPkcs5Decrypt.encrypt(value.trim(), projectAesKeyBytes); + } catch (GeneralSecurityException | IllegalArgumentException e) { + log.warn("project field AES encode failed, using raw value: {}", e.getMessage()); + throw new RuntimeException(e); + } + } +}