mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-09 14:36:00 +08:00
feat(cache): 敏感缓存 L2 整包 AES-GCM 加密
将 SecureAesGcmEncryptor 下沉至 common-config 并注册唯一 Bean,与 DB 字段加密共用。secure: 前缀的 cacheName 在 Redis 中整包加密 value;加密未启用时仅 L1 不写 Redis。
This commit is contained in:
@@ -39,6 +39,10 @@ daxpay:
|
||||
maximum-size: 10000 # L1 本地缓存最大容量
|
||||
l2:
|
||||
default-ttl: 1800 # L2 Redis 缓存默认过期时间(秒)
|
||||
secure-prefix: "secure:" # 敏感缓存名前缀,匹配的 L2 value 整包 AES-GCM 加密
|
||||
# secure-names: [] # 额外精确敏感 cacheName 列表(可选)
|
||||
# 敏感 L2 加密复用 DB 字段加密密钥(全进程同一 SecureAesGcmEncryptor)
|
||||
# platform.config.encrypt.enable / keys
|
||||
```
|
||||
|
||||
## 架构设计
|
||||
@@ -178,6 +182,8 @@ public void clearUserCache() {
|
||||
@Cacheable(value = "merchant:config", key = "#mchNo")
|
||||
```
|
||||
|
||||
**含密钥/证书等敏感数据的缓存名必须以 `secure:` 开头**(见下文「敏感缓存」)。
|
||||
|
||||
### 7. 如何处理缓存穿透?
|
||||
|
||||
当前实现不缓存 null 值(`disableCachingNullValues()`),如需防止缓存穿透:
|
||||
@@ -208,3 +214,57 @@ public void printCacheStats(String cacheName) {
|
||||
log.info("命中次数: {}, 未命中次数: {}", stats.hitCount(), stats.missCount());
|
||||
}
|
||||
```
|
||||
|
||||
## 敏感缓存(secure:)
|
||||
|
||||
通道密钥、商户对接密钥等敏感对象若使用 Spring Cache,**必须**使用 `secure:` 前缀的 cacheName,基础设施会自动对 L2 Redis value 做**整包 AES-GCM 加密**。
|
||||
|
||||
### 与 DB 字段加密的区别
|
||||
|
||||
| 层 | 粒度 | 说明 |
|
||||
|----|------|------|
|
||||
| DB(DataEncryptTypeHandler) | **字段级** | 仅 `privateKey` / `apiKey` 等列密文,其它列明文 |
|
||||
| Redis L2(secure:) | **整包** | 整个缓存对象 JSON 一次加密,Redis 中不可见任何字段明文 |
|
||||
| L1 本地 | 明文对象 | 进程内,与普通缓存一致 |
|
||||
|
||||
密钥管理复用 `daxpay.platform.config.encrypt`(与 DB 同一 `SecureAesGcmEncryptor` Bean)。
|
||||
|
||||
### 业务写法(与普通缓存相同)
|
||||
|
||||
```java
|
||||
// 读:默认 CacheManager,仅 cacheName 用 secure: 前缀
|
||||
@Cacheable(value = "secure:channel-key:adapay-direct",
|
||||
key = "#channelMchNo + ':' + #sandbox")
|
||||
public AdapayDirectKeyConfig getForPay(String channelMchNo, boolean sandbox) {
|
||||
return manager.find(...).orElseThrow(...);
|
||||
}
|
||||
|
||||
// 写:必须同名同 key 失效
|
||||
@CacheEvict(value = "secure:channel-key:adapay-direct",
|
||||
key = "#param.channelMchNo + ':' + #param.sandbox")
|
||||
public void saveConfig(AdapayDirectKeyConfigParam param) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
命名建议:
|
||||
|
||||
```
|
||||
secure:channel-key:wechat-isv
|
||||
secure:channel-key:adapay-direct
|
||||
secure:merchant-credential
|
||||
secure:platform-encrypt-config
|
||||
```
|
||||
|
||||
### 数据加密未启用时
|
||||
|
||||
若 `daxpay.platform.config.encrypt.enable=false`:
|
||||
|
||||
- `secure:*` **禁止写 Redis**(L1-only 降级),避免明文密钥落盘
|
||||
- 启动日志会 warn 提示
|
||||
|
||||
### 禁止事项
|
||||
|
||||
- 禁止对含密钥的实体使用非 `secure:` 的 cacheName(会明文进 Redis)
|
||||
- 禁止缓存完整 `*SdkCredential` 到普通 cacheName
|
||||
- 不需要也不应指定第二个 `cacheManager`:统一用默认二级缓存即可
|
||||
|
||||
@@ -45,5 +45,10 @@
|
||||
<artifactId>common-artemis</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
package cn.daxpay.open.platform.capability.cache;
|
||||
|
||||
import cn.daxpay.open.platform.common.config.ConfigAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
|
||||
/// # 缓存配置
|
||||
///
|
||||
/// 依赖 [ConfigAutoConfiguration] 先注册 SecureAesGcmEncryptor,供敏感缓存 L2 加密使用。
|
||||
@ComponentScan
|
||||
@ConfigurationPropertiesScan
|
||||
@AutoConfiguration
|
||||
@AutoConfiguration(after = ConfigAutoConfiguration.class)
|
||||
public class CacheAutoConfiguration {
|
||||
|
||||
}
|
||||
|
||||
@@ -4,9 +4,14 @@ import cn.daxpay.open.platform.capability.cache.core.MultiLevelCacheManager;
|
||||
import cn.daxpay.open.platform.capability.cache.core.DaxpayRedisCacheManager;
|
||||
import cn.daxpay.open.platform.capability.cache.core.LocalCacheRegistry;
|
||||
import cn.daxpay.open.platform.capability.cache.notify.publisher.CacheInvalidationPublisher;
|
||||
import cn.daxpay.open.platform.capability.cache.secure.EncryptingRedisSerializer;
|
||||
import cn.daxpay.open.platform.capability.cache.secure.SecureCacheNameMatcher;
|
||||
import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor;
|
||||
import cn.daxpay.open.platform.common.config.properties.PlatformCommonProperties;
|
||||
import cn.daxpay.open.platform.common.redis.serializer.JacksonRedisSerializer;
|
||||
import cn.daxpay.open.platform.common.artemis.service.ArtemisTemplateService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
@@ -35,6 +40,8 @@ import java.util.stream.Collectors;
|
||||
/// - L1 本地缓存始终启用,作为性能加速层
|
||||
/// - L2 Redis 作为共享缓存主层
|
||||
/// - 缓存失效通知通过 Artemis 广播,始终启用
|
||||
/// - 名称匹配 secure: 的缓存:L2 整包 AES-GCM 加密;加密未启用时仅 L1
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
@EnableConfigurationProperties(PlatformCommonProperties.class)
|
||||
@@ -65,16 +72,44 @@ public class CachingConfiguration implements CachingConfigurer {
|
||||
};
|
||||
}
|
||||
|
||||
/// 敏感缓存名匹配器
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public SecureCacheNameMatcher secureCacheNameMatcher() {
|
||||
var cache = platformCommonProperties.getCache();
|
||||
return new SecureCacheNameMatcher(cache.getSecurePrefix(), cache.getSecureNames());
|
||||
}
|
||||
|
||||
/// Redis 缓存管理器(L2)
|
||||
///
|
||||
/// 作为二级缓存的 L2 层,负责跨节点数据共享
|
||||
///
|
||||
/// 普通 cacheName 使用明文 JSON;敏感 cacheName 在 encryptor 可用时使用整包加密序列化。
|
||||
@Bean
|
||||
@ConditionalOnMissingBean(DaxpayRedisCacheManager.class)
|
||||
public DaxpayRedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
|
||||
public DaxpayRedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory,
|
||||
SecureCacheNameMatcher secureCacheNameMatcher,
|
||||
ObjectProvider<SecureAesGcmEncryptor> encryptorProvider) {
|
||||
var l2Config = platformCommonProperties.getCache().getL2();
|
||||
Duration ttl = Duration.ofSeconds(l2Config.getDefaultTtl());
|
||||
RedisCacheConfiguration plainConfig = this.plainValueConfig(ttl);
|
||||
|
||||
SecureAesGcmEncryptor encryptor = encryptorProvider.getIfAvailable();
|
||||
RedisCacheConfiguration secureConfig = null;
|
||||
if (encryptor != null) {
|
||||
EncryptingRedisSerializer encryptingSerializer = new EncryptingRedisSerializer(encryptor);
|
||||
secureConfig = this.secureValueConfig(ttl, encryptingSerializer);
|
||||
log.info("敏感缓存 L2 整包加密已启用,前缀: {}", secureCacheNameMatcher.getSecurePrefix());
|
||||
} else {
|
||||
log.warn("未启用数据加密:名称匹配 {} 的敏感缓存将仅使用 L1,不写 Redis",
|
||||
secureCacheNameMatcher.getSecurePrefix());
|
||||
}
|
||||
|
||||
return new DaxpayRedisCacheManager(
|
||||
RedisCacheWriter.nonLockingRedisCacheWriter(redisConnectionFactory),
|
||||
this.getRedisCacheConfigurationWithTtl(Duration.ofSeconds(l2Config.getDefaultTtl())));
|
||||
plainConfig,
|
||||
secureConfig,
|
||||
secureCacheNameMatcher);
|
||||
}
|
||||
|
||||
/// 本地缓存注册表
|
||||
@@ -106,16 +141,21 @@ public class CachingConfiguration implements CachingConfigurer {
|
||||
@Primary
|
||||
public CacheManager cacheManager(DaxpayRedisCacheManager redisCacheManager,
|
||||
LocalCacheRegistry localCacheRegistry,
|
||||
CacheInvalidationPublisher publisher) {
|
||||
CacheInvalidationPublisher publisher,
|
||||
SecureCacheNameMatcher secureCacheNameMatcher,
|
||||
ObjectProvider<SecureAesGcmEncryptor> encryptorProvider) {
|
||||
boolean secureL2Enabled = encryptorProvider.getIfAvailable() != null;
|
||||
return new MultiLevelCacheManager(
|
||||
redisCacheManager,
|
||||
localCacheRegistry,
|
||||
publisher
|
||||
publisher,
|
||||
secureCacheNameMatcher,
|
||||
secureL2Enabled
|
||||
);
|
||||
}
|
||||
|
||||
/// 缓存管理器策略过期时间配置
|
||||
private RedisCacheConfiguration getRedisCacheConfigurationWithTtl(Duration duration) {
|
||||
/// 缓存管理器策略过期时间配置(普通缓存:明文 JSON value)
|
||||
private RedisCacheConfiguration plainValueConfig(Duration duration) {
|
||||
// redis缓存配置
|
||||
return RedisCacheConfiguration.defaultCacheConfig()
|
||||
// 设置key为String
|
||||
@@ -130,5 +170,20 @@ public class CachingConfiguration implements CachingConfigurer {
|
||||
.entryTtl(duration);
|
||||
}
|
||||
|
||||
}
|
||||
/// 缓存管理器策略过期时间配置(敏感缓存:整包 AES-GCM 加密 value)
|
||||
private RedisCacheConfiguration secureValueConfig(Duration duration, EncryptingRedisSerializer encryptingSerializer) {
|
||||
// redis缓存配置
|
||||
return RedisCacheConfiguration.defaultCacheConfig()
|
||||
// 设置key为String
|
||||
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
|
||||
// 设置value 序列化方式为整包加密(JSON + AES-GCM)
|
||||
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(encryptingSerializer))
|
||||
// 不缓存null
|
||||
.disableCachingNullValues()
|
||||
// 覆盖默认的构造key,否则会多出一个冒号
|
||||
.computePrefixWith(name -> name + ":")
|
||||
// 过期时间
|
||||
.entryTtl(duration);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,52 +1,59 @@
|
||||
package cn.daxpay.open.platform.capability.cache.core;
|
||||
|
||||
import cn.daxpay.open.platform.capability.cache.secure.SecureCacheNameMatcher;
|
||||
import org.springframework.data.redis.cache.RedisCache;
|
||||
import org.springframework.data.redis.cache.RedisCacheConfiguration;
|
||||
import org.springframework.data.redis.cache.RedisCacheManager;
|
||||
import org.springframework.data.redis.cache.RedisCacheWriter;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/// # 自定义 Redis 缓存管理器
|
||||
///
|
||||
/// 作为二级缓存的 L2 层,负责跨节点数据共享
|
||||
///
|
||||
/// 按 cacheName 选择序列化策略:
|
||||
/// - 敏感名(SecureCacheNameMatcher 命中)且已配置 secureConfig:整包 AES-GCM 加密
|
||||
/// - 其它:明文 JSON
|
||||
public class DaxpayRedisCacheManager extends RedisCacheManager {
|
||||
|
||||
private final RedisCacheWriter cacheWriter;
|
||||
private final RedisCacheConfiguration defaultConfig;
|
||||
private final RedisCacheConfiguration secureConfig;
|
||||
private final SecureCacheNameMatcher secureMatcher;
|
||||
|
||||
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration) {
|
||||
/// @param cacheWriter Redis 写入器
|
||||
/// @param defaultCacheConfiguration 普通缓存配置(明文 JSON)
|
||||
/// @param secureCacheConfiguration 敏感缓存配置(整包加密);encrypt 未启用时可为 null
|
||||
/// @param secureMatcher 敏感缓存名匹配器
|
||||
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter,
|
||||
RedisCacheConfiguration defaultCacheConfiguration,
|
||||
RedisCacheConfiguration secureCacheConfiguration,
|
||||
SecureCacheNameMatcher secureMatcher) {
|
||||
super(cacheWriter, defaultCacheConfiguration);
|
||||
this.cacheWriter = cacheWriter;
|
||||
}
|
||||
|
||||
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration, String... initialCacheNames) {
|
||||
super(cacheWriter, defaultCacheConfiguration, initialCacheNames);
|
||||
this.cacheWriter = cacheWriter;
|
||||
}
|
||||
|
||||
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration,
|
||||
boolean allowInFlightCacheCreation, String... initialCacheNames) {
|
||||
super(cacheWriter, defaultCacheConfiguration, allowInFlightCacheCreation, initialCacheNames);
|
||||
this.cacheWriter = cacheWriter;
|
||||
}
|
||||
|
||||
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration,
|
||||
Map<String, RedisCacheConfiguration> initialCacheConfigurations) {
|
||||
super(cacheWriter, defaultCacheConfiguration, initialCacheConfigurations);
|
||||
this.cacheWriter = cacheWriter;
|
||||
}
|
||||
|
||||
public DaxpayRedisCacheManager(RedisCacheWriter cacheWriter, RedisCacheConfiguration defaultCacheConfiguration,
|
||||
Map<String, RedisCacheConfiguration> initialCacheConfigurations, boolean allowInFlightCacheCreation) {
|
||||
super(cacheWriter, defaultCacheConfiguration, allowInFlightCacheCreation, initialCacheConfigurations);
|
||||
this.cacheWriter = cacheWriter;
|
||||
this.defaultConfig = defaultCacheConfiguration;
|
||||
this.secureConfig = secureCacheConfiguration;
|
||||
this.secureMatcher = secureMatcher;
|
||||
}
|
||||
|
||||
/// 创建 Redis 缓存
|
||||
@Override
|
||||
@SuppressWarnings({ "ConstantConditions", "NullableProblems" })
|
||||
protected RedisCache createRedisCache(String name, RedisCacheConfiguration cacheConfig) {
|
||||
return new DaxpayRedisCache(name, this.cacheWriter, cacheConfig);
|
||||
RedisCacheConfiguration config = this.resolveConfig(name, cacheConfig);
|
||||
return new DaxpayRedisCache(name, this.cacheWriter, config);
|
||||
}
|
||||
|
||||
/// 按 cacheName 解析最终 RedisCacheConfiguration
|
||||
private RedisCacheConfiguration resolveConfig(String name, RedisCacheConfiguration cacheConfig) {
|
||||
// 敏感缓存且加密 L2 可用:强制使用 secure 序列化,避免 initialCacheConfigurations 漏配
|
||||
if (this.secureMatcher != null
|
||||
&& this.secureMatcher.matches(name)
|
||||
&& this.secureConfig != null) {
|
||||
return this.secureConfig;
|
||||
}
|
||||
if (cacheConfig != null) {
|
||||
return cacheConfig;
|
||||
}
|
||||
return this.defaultConfig;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,8 +14,9 @@ import java.util.concurrent.Callable;
|
||||
/// 设计要点:
|
||||
/// - L2 Redis 是共享缓存主层,负责跨节点数据共享
|
||||
/// - L1 Caffeine 是性能加速层,仅在本节点生效
|
||||
/// - 缓存失效通过 RocketMQ 广播通知其他节点删除本地 L1
|
||||
/// - 缓存失效通过 Artemis 广播通知其他节点删除本地 L1
|
||||
/// - 本地缓存 key 必须统一使用字符串形式,保证跨节点广播删除一致性
|
||||
/// - 敏感缓存(secure:)在数据加密未启用时仅使用 L1,禁止明文写 Redis
|
||||
@Slf4j
|
||||
public class MultiLevelCache implements Cache {
|
||||
|
||||
@@ -27,14 +28,27 @@ public class MultiLevelCache implements Cache {
|
||||
|
||||
private final CacheInvalidationPublisher publisher;
|
||||
|
||||
/// 是否为敏感缓存名
|
||||
private final boolean secureCache;
|
||||
|
||||
/// 敏感缓存是否允许写/读 L2(依赖数据加密已启用)
|
||||
private final boolean secureL2Enabled;
|
||||
|
||||
public MultiLevelCache(String name,
|
||||
LocalCacheRegistry localCacheRegistry,
|
||||
Cache redisCache,
|
||||
CacheInvalidationPublisher publisher) {
|
||||
CacheInvalidationPublisher publisher,
|
||||
boolean secureCache,
|
||||
boolean secureL2Enabled) {
|
||||
this.name = name;
|
||||
this.localCache = localCacheRegistry.getOrCreate(name);
|
||||
this.redisCache = redisCache;
|
||||
this.publisher = publisher;
|
||||
this.secureCache = secureCache;
|
||||
this.secureL2Enabled = secureL2Enabled;
|
||||
if (secureCache && !secureL2Enabled) {
|
||||
log.warn("敏感缓存 [{}] 因未启用数据加密,仅使用 L1 本地缓存,不写 Redis", name);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -47,6 +61,11 @@ public class MultiLevelCache implements Cache {
|
||||
return this.localCache;
|
||||
}
|
||||
|
||||
/// 是否跳过 L2(敏感且未开加密)
|
||||
private boolean isL1Only() {
|
||||
return this.secureCache && !this.secureL2Enabled;
|
||||
}
|
||||
|
||||
/// 读取缓存,优先从 L1 本地缓存读取,未命中则从 L2 Redis 读取并回填 L1
|
||||
///
|
||||
/// 读取流程:
|
||||
@@ -54,6 +73,8 @@ public class MultiLevelCache implements Cache {
|
||||
/// - L1 未命中则查 L2 Redis
|
||||
/// - L2 命中则回填 L1
|
||||
/// - 全未命中返回 null
|
||||
///
|
||||
/// 敏感缓存且未启用加密时跳过 L2,仅查 L1
|
||||
@Override
|
||||
public ValueWrapper get(Object key) {
|
||||
String localKey = this.toLocalKey(key);
|
||||
@@ -62,6 +83,12 @@ public class MultiLevelCache implements Cache {
|
||||
return () -> localValue;
|
||||
}
|
||||
|
||||
// 敏感缓存未开加密:禁止读 Redis,避免历史明文或无法解密的脏数据
|
||||
if (this.isL1Only()) {
|
||||
log.debug("敏感缓存 L1-only 未命中: cacheName={}, key={}", this.name, key);
|
||||
return null;
|
||||
}
|
||||
|
||||
ValueWrapper redisValue = this.redisCache.get(key);
|
||||
if (redisValue != null) {
|
||||
this.localCache.put(localKey, Objects.requireNonNull(redisValue.get()));
|
||||
@@ -103,6 +130,8 @@ public class MultiLevelCache implements Cache {
|
||||
/// 写入缓存,同时写入 L2 Redis 和 L1 本地缓存
|
||||
///
|
||||
/// 注意:写入操作不广播通知其他节点,其他节点在读取时会从 L2 加载最新值
|
||||
///
|
||||
/// 敏感缓存且未启用加密时仅写 L1,禁止明文写 Redis
|
||||
@Override
|
||||
public void put(Object key, Object value) {
|
||||
if (value == null) {
|
||||
@@ -110,6 +139,11 @@ public class MultiLevelCache implements Cache {
|
||||
return;
|
||||
}
|
||||
String localKey = this.toLocalKey(key);
|
||||
if (this.isL1Only()) {
|
||||
this.localCache.put(localKey, value);
|
||||
log.debug("写入 L1-only 敏感缓存: cacheName={}, key={}", this.name, key);
|
||||
return;
|
||||
}
|
||||
this.redisCache.put(key, value);
|
||||
this.localCache.put(localKey, value);
|
||||
log.debug("写入二级缓存: cacheName={}, key={}", this.name, key);
|
||||
@@ -120,11 +154,12 @@ public class MultiLevelCache implements Cache {
|
||||
/// 删除流程:
|
||||
/// - 删除 L2 Redis
|
||||
/// - 删除本机 L1
|
||||
/// - 发布 RocketMQ 广播消息
|
||||
/// - 发布 Artemis 广播消息
|
||||
/// - 其他节点收到消息后删除各自 L1
|
||||
@Override
|
||||
public void evict(Object key) {
|
||||
String localKey = this.toLocalKey(key);
|
||||
// 仍尝试删 Redis,清理可能存在的历史数据
|
||||
this.redisCache.evict(key);
|
||||
this.localCache.invalidate(localKey);
|
||||
log.debug("删除二级缓存: cacheName={}, key={}", this.name, key);
|
||||
@@ -144,7 +179,7 @@ public class MultiLevelCache implements Cache {
|
||||
///
|
||||
/// 为什么必须统一使用字符串 key:
|
||||
/// - 本地缓存原始 key 可能是任意对象类型(Long、String、自定义对象等)
|
||||
/// - RocketMQ 广播消息中的 key 只能是字符串
|
||||
/// - Artemis 广播消息中的 key 只能是字符串
|
||||
/// - 如果本地缓存使用原始对象作为 key,广播消息使用字符串,会导致跨节点删除失败
|
||||
/// - 例如:本机 key=Long(1),广播 key="1",远端无法匹配
|
||||
///
|
||||
@@ -159,4 +194,3 @@ public class MultiLevelCache implements Cache {
|
||||
return String.valueOf(key);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.daxpay.open.platform.capability.cache.core;
|
||||
|
||||
import cn.daxpay.open.platform.capability.cache.notify.publisher.CacheInvalidationPublisher;
|
||||
import cn.daxpay.open.platform.capability.cache.secure.SecureCacheNameMatcher;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.Cache;
|
||||
@@ -18,7 +19,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
/// 设计要点:
|
||||
/// - 作为 Spring 默认 CacheManager,通过 @Primary 标识
|
||||
/// - L1 本地缓存始终启用,不需要开关控制
|
||||
/// - 缓存失效通知通过 RocketMQ 广播,始终启用
|
||||
/// - 缓存失效通知通过 Artemis 广播,始终启用
|
||||
/// - 敏感 cacheName 结合 secureL2Enabled 决定是否写 Redis
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class MultiLevelCacheManager implements CacheManager {
|
||||
@@ -29,6 +31,11 @@ public class MultiLevelCacheManager implements CacheManager {
|
||||
|
||||
private final CacheInvalidationPublisher publisher;
|
||||
|
||||
private final SecureCacheNameMatcher secureMatcher;
|
||||
|
||||
/// 敏感缓存是否允许 L2(数据加密已启用时为 true)
|
||||
private final boolean secureL2Enabled;
|
||||
|
||||
private final Map<String, MultiLevelCache> cacheMap = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
@@ -50,13 +57,15 @@ public class MultiLevelCacheManager implements CacheManager {
|
||||
throw new IllegalStateException("Redis cache not found: " + name);
|
||||
}
|
||||
|
||||
log.debug("创建二级缓存: name={}", name);
|
||||
boolean secureCache = this.secureMatcher != null && this.secureMatcher.matches(name);
|
||||
log.debug("创建二级缓存: name={}, secure={}, secureL2Enabled={}", name, secureCache, this.secureL2Enabled);
|
||||
return new MultiLevelCache(
|
||||
name,
|
||||
this.localCacheRegistry,
|
||||
redisCache,
|
||||
this.publisher
|
||||
this.publisher,
|
||||
secureCache,
|
||||
this.secureL2Enabled
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package cn.daxpay.open.platform.capability.cache.secure;
|
||||
|
||||
import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor;
|
||||
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
|
||||
import tools.jackson.core.JacksonException;
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.redis.serializer.RedisSerializer;
|
||||
import org.springframework.data.redis.serializer.SerializationException;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/// # 敏感缓存 L2 整包加密序列化器
|
||||
///
|
||||
/// 将缓存对象序列化为 JSON 后,对**整段 JSON 字符串**做 AES-256-GCM 加密再写入 Redis。
|
||||
/// 非字段级加密:密钥字段与非敏感字段一起进入同一密文包。
|
||||
///
|
||||
/// 流程:
|
||||
/// - serialize: Object → JSON → encrypt → `v{n}:...` bytes
|
||||
/// - deserialize: bytes → decrypt → JSON → Object
|
||||
///
|
||||
/// JSON 编解码与 JacksonRedisSerializer 一致,使用平台标准 ObjectMapper。
|
||||
@Slf4j
|
||||
public class EncryptingRedisSerializer implements RedisSerializer<Object> {
|
||||
|
||||
private final SecureAesGcmEncryptor encryptor;
|
||||
|
||||
public EncryptingRedisSerializer(SecureAesGcmEncryptor encryptor) {
|
||||
if (encryptor == null) {
|
||||
throw new IllegalArgumentException("SecureAesGcmEncryptor 不能为空");
|
||||
}
|
||||
this.encryptor = encryptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] serialize(Object value) throws SerializationException {
|
||||
if (value == null) {
|
||||
return new byte[0];
|
||||
}
|
||||
try {
|
||||
ObjectMapper objectMapper = JacksonUtil.getObjectMapper();
|
||||
String json = objectMapper.writeValueAsString(value);
|
||||
String cipher = encryptor.encrypt(json);
|
||||
return cipher.getBytes(StandardCharsets.UTF_8);
|
||||
} catch (JacksonException e) {
|
||||
log.error("敏感缓存 JSON 序列化失败: {}", e.getMessage(), e);
|
||||
throw new SerializationException("Could not serialize to JSON for encrypt: " + e.getMessage(), e);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("敏感缓存加密序列化失败: {}", e.getMessage(), e);
|
||||
throw new SerializationException("Could not encrypt-serialize cache value: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object deserialize(byte[] bytes) throws SerializationException {
|
||||
if (bytes == null || bytes.length == 0) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
String cipher = new String(bytes, StandardCharsets.UTF_8);
|
||||
String json = encryptor.decrypt(cipher);
|
||||
if (json == null) {
|
||||
throw new SerializationException("敏感缓存解密失败,密文格式或密钥版本无效");
|
||||
}
|
||||
ObjectMapper objectMapper = JacksonUtil.getObjectMapper();
|
||||
return objectMapper.readValue(json, Object.class);
|
||||
} catch (SerializationException e) {
|
||||
throw e;
|
||||
} catch (JacksonException e) {
|
||||
log.error("敏感缓存解密后 JSON 反序列化失败: {}", e.getMessage(), e);
|
||||
throw new SerializationException("Could not deserialize decrypted cache value: " + e.getMessage(), e);
|
||||
} catch (Exception e) {
|
||||
log.error("敏感缓存解密反序列化失败: {}", e.getMessage(), e);
|
||||
throw new SerializationException("Could not decrypt-deserialize cache value: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package cn.daxpay.open.platform.capability.cache.secure;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/// # 敏感缓存名匹配器
|
||||
///
|
||||
/// 判断 cacheName 是否为敏感缓存(S2):
|
||||
/// - 名称以 [securePrefix] 开头(默认 `secure:`)
|
||||
/// - 或落在 [secureNames] 精确列表中
|
||||
///
|
||||
/// 匹配的缓存 L2 value 使用整包 AES-GCM 加密;未启用数据加密时则仅 L1、不写 Redis。
|
||||
public class SecureCacheNameMatcher {
|
||||
|
||||
private final String securePrefix;
|
||||
private final Set<String> secureNames;
|
||||
|
||||
public SecureCacheNameMatcher(String securePrefix, List<String> secureNames) {
|
||||
this.securePrefix = StrUtil.blankToDefault(securePrefix, "secure:");
|
||||
if (CollUtil.isEmpty(secureNames)) {
|
||||
this.secureNames = Collections.emptySet();
|
||||
} else {
|
||||
this.secureNames = new HashSet<>(secureNames);
|
||||
}
|
||||
}
|
||||
|
||||
/// 是否为敏感缓存名
|
||||
public boolean matches(String cacheName) {
|
||||
if (StrUtil.isBlank(cacheName)) {
|
||||
return false;
|
||||
}
|
||||
if (cacheName.startsWith(this.securePrefix)) {
|
||||
return true;
|
||||
}
|
||||
return this.secureNames.contains(cacheName);
|
||||
}
|
||||
|
||||
public String getSecurePrefix() {
|
||||
return this.securePrefix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package cn.daxpay.open.platform.capability.cache.secure;
|
||||
|
||||
import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor;
|
||||
import cn.daxpay.open.platform.common.config.properties.EncryptKeyInfo;
|
||||
import cn.daxpay.open.platform.common.json.util.JacksonUtil;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.data.redis.serializer.SerializationException;
|
||||
import tools.jackson.databind.json.JsonMapper;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/// # 敏感缓存整包加密序列化器测试
|
||||
class EncryptingRedisSerializerTest {
|
||||
|
||||
private EncryptingRedisSerializer serializer;
|
||||
|
||||
@BeforeAll
|
||||
static void initJackson() {
|
||||
if (JacksonUtil.getObjectMapper() == null) {
|
||||
JacksonUtil.setObjectMapper(JsonMapper.builder().build());
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
EncryptKeyInfo keyInfo = new EncryptKeyInfo();
|
||||
keyInfo.setVersion(1);
|
||||
keyInfo.setKey(generateKey(32));
|
||||
SecureAesGcmEncryptor encryptor = new SecureAesGcmEncryptor(List.of(keyInfo));
|
||||
serializer = new EncryptingRedisSerializer(encryptor);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("整包加解密 round-trip 保留字段值")
|
||||
void shouldRoundTrip() {
|
||||
Map<String, Object> value = new LinkedHashMap<>();
|
||||
value.put("channelMchNo", "MCH001");
|
||||
value.put("apiKey", "sk_live_secret_key_value");
|
||||
value.put("privateKey", "-----BEGIN PRIVATE KEY-----\nABCxyz\n-----END PRIVATE KEY-----");
|
||||
|
||||
byte[] bytes = serializer.serialize(value);
|
||||
Object restored = serializer.deserialize(bytes);
|
||||
|
||||
assertInstanceOf(Map.class, restored);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, Object> map = (Map<String, Object>) restored;
|
||||
assertEquals("MCH001", map.get("channelMchNo"));
|
||||
assertEquals("sk_live_secret_key_value", map.get("apiKey"));
|
||||
assertTrue(String.valueOf(map.get("privateKey")).contains("BEGIN PRIVATE KEY"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("序列化结果为 v1: 密文且不含明文密钥")
|
||||
void shouldProduceCipherWithoutPlainSecrets() {
|
||||
Map<String, Object> value = new LinkedHashMap<>();
|
||||
value.put("privateKey", "-----BEGIN PRIVATE KEY-----\nSUPER_SECRET\n-----END PRIVATE KEY-----");
|
||||
value.put("apiKey", "sk_live_plain_should_not_appear");
|
||||
|
||||
byte[] bytes = serializer.serialize(value);
|
||||
String stored = new String(bytes, StandardCharsets.UTF_8);
|
||||
|
||||
assertTrue(stored.startsWith("v1:"), "密文应带版本前缀 v1:");
|
||||
assertFalse(stored.contains("BEGIN PRIVATE KEY"), "Redis 存储不应含 PEM 明文");
|
||||
assertFalse(stored.contains("SUPER_SECRET"), "Redis 存储不应含私钥内容");
|
||||
assertFalse(stored.contains("sk_live_plain_should_not_appear"), "Redis 存储不应含 apiKey 明文");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("null 序列化为空字节")
|
||||
void shouldSerializeNullAsEmpty() {
|
||||
assertEquals(0, serializer.serialize(null).length);
|
||||
assertNull(serializer.deserialize(null));
|
||||
assertNull(serializer.deserialize(new byte[0]));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非法密文反序列化抛 SerializationException")
|
||||
void shouldFailOnInvalidCipher() {
|
||||
assertThrows(SerializationException.class,
|
||||
() -> serializer.deserialize("not-a-cipher".getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
|
||||
private static String generateKey(int length) {
|
||||
byte[] keyBytes = new byte[length];
|
||||
new SecureRandom().nextBytes(keyBytes);
|
||||
return Base64.getEncoder().encodeToString(keyBytes).substring(0, length);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cn.daxpay.open.platform.capability.cache.secure;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/// # 敏感缓存名匹配器测试
|
||||
class SecureCacheNameMatcherTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("前缀匹配 secure:")
|
||||
void shouldMatchSecurePrefix() {
|
||||
SecureCacheNameMatcher matcher = new SecureCacheNameMatcher("secure:", List.of());
|
||||
assertTrue(matcher.matches("secure:channel-key:wechat-isv"));
|
||||
assertTrue(matcher.matches("secure:merchant-credential"));
|
||||
assertFalse(matcher.matches("system:dict"));
|
||||
assertFalse(matcher.matches("payment:channel-merchant"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("精确列表命中")
|
||||
void shouldMatchExactNames() {
|
||||
SecureCacheNameMatcher matcher = new SecureCacheNameMatcher("secure:", List.of("legacy:secret-config"));
|
||||
assertTrue(matcher.matches("legacy:secret-config"));
|
||||
assertFalse(matcher.matches("legacy:other"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("空名称不匹配")
|
||||
void shouldNotMatchBlank() {
|
||||
SecureCacheNameMatcher matcher = new SecureCacheNameMatcher("secure:", null);
|
||||
assertFalse(matcher.matches(null));
|
||||
assertFalse(matcher.matches(""));
|
||||
assertFalse(matcher.matches(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("默认前缀")
|
||||
void shouldDefaultPrefixWhenBlank() {
|
||||
SecureCacheNameMatcher matcher = new SecureCacheNameMatcher(null, List.of());
|
||||
assertEquals("secure:", matcher.getSecurePrefix());
|
||||
assertTrue(matcher.matches("secure:demo"));
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,16 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<!-- EncryptorConfiguration 条件装配 -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-autoconfigure</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package cn.daxpay.open.platform.common.config;
|
||||
|
||||
import cn.daxpay.open.platform.common.config.encrypt.EncryptorConfiguration;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
/// # 配置自动配置
|
||||
///
|
||||
@Slf4j
|
||||
@AutoConfiguration
|
||||
@ConfigurationPropertiesScan
|
||||
@Import(EncryptorConfiguration.class)
|
||||
public class ConfigAutoConfiguration {
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package cn.daxpay.open.platform.common.config.encrypt;
|
||||
|
||||
import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/// # 数据加密器 Spring 配置
|
||||
///
|
||||
/// 全进程唯一 [SecureAesGcmEncryptor] 实例,供 DB TypeHandler 与缓存 L2 加密序列化共用,
|
||||
/// 避免两套密钥实例不一致。
|
||||
///
|
||||
/// 仅在 `daxpay.platform.config.encrypt.enable=true` 时注册 Bean。
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(PlatformConfigProperties.class)
|
||||
public class EncryptorConfiguration {
|
||||
|
||||
/// 创建 AES-GCM 加密器(启用加密时必须配置 keys)
|
||||
@Bean
|
||||
@ConditionalOnProperty(prefix = "daxpay.platform.config.encrypt", name = "enable", havingValue = "true")
|
||||
public SecureAesGcmEncryptor secureAesGcmEncryptor(PlatformConfigProperties platformConfigProperties) {
|
||||
var keys = platformConfigProperties.getEncrypt().getKeys();
|
||||
if (CollUtil.isEmpty(keys)) {
|
||||
throw new IllegalStateException(
|
||||
"启用数据加密时必须配置至少一个密钥,请配置 daxpay.platform.config.encrypt.keys");
|
||||
}
|
||||
SecureAesGcmEncryptor encryptor = new SecureAesGcmEncryptor(keys);
|
||||
log.info("已注册 SecureAesGcmEncryptor Bean,当前密钥版本: v{}", encryptor.getCurrentVersion());
|
||||
return encryptor;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt;
|
||||
package cn.daxpay.open.platform.common.config.encrypt;
|
||||
|
||||
import cn.daxpay.open.platform.common.config.properties.EncryptKeyInfo;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
@@ -16,7 +16,7 @@ import java.util.stream.Collectors;
|
||||
|
||||
/// # 安全的 AES-256-GCM 加密工具(支持多密钥版本)
|
||||
///
|
||||
/// 适用于支付系统中加密各类敏感数据使用
|
||||
/// 适用于支付系统中加密各类敏感数据:DB 字段 TypeHandler、缓存 L2 整包 value 等。
|
||||
///
|
||||
/// 密文格式:v{version}:{base64(IV + AES-GCM-Encrypt(plaintext))}
|
||||
/// 示例:v2:7Kf8jD2mNpQrStUvWxYz...
|
||||
@@ -47,32 +47,32 @@ public class SecureAesGcmEncryptor {
|
||||
if (CollUtil.isEmpty(keys)) {
|
||||
throw new IllegalArgumentException("密钥列表不能为空");
|
||||
}
|
||||
|
||||
|
||||
// 校验密钥配置
|
||||
validateKeys(keys);
|
||||
|
||||
|
||||
// 第一个密钥为当前密钥
|
||||
this.currentKey = keys.getFirst();
|
||||
|
||||
|
||||
// 构建密钥映射表
|
||||
this.keyMap = keys.stream()
|
||||
.collect(Collectors.toMap(EncryptKeyInfo::getVersion, k -> k, (k1, k2) -> {
|
||||
throw new IllegalArgumentException("存在重复的密钥版本号: " + k1.getVersion());
|
||||
}));
|
||||
|
||||
|
||||
// 初始化密钥缓存
|
||||
this.secretKeyCache = new HashMap<>();
|
||||
for (EncryptKeyInfo keyInfo : keys) {
|
||||
secretKeyCache.put(keyInfo.getVersion(), createSecretKey(keyInfo.getKey()));
|
||||
}
|
||||
|
||||
|
||||
// 记录日志
|
||||
List<Integer> historyVersions = keys.stream()
|
||||
.skip(1)
|
||||
.map(EncryptKeyInfo::getVersion)
|
||||
.toList();
|
||||
log.info("加密器初始化成功,当前版本: v{},历史版本: {}",
|
||||
currentKey.getVersion(),
|
||||
log.info("加密器初始化成功,当前版本: v{},历史版本: {}",
|
||||
currentKey.getVersion(),
|
||||
historyVersions.isEmpty() ? "无" : historyVersions.stream().map(v -> "v" + v).toList());
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ public class SecureAesGcmEncryptor {
|
||||
throw new IllegalArgumentException("存在重复的密钥版本号: " + keyInfo.getVersion());
|
||||
}
|
||||
versions.add(keyInfo.getVersion());
|
||||
|
||||
|
||||
// 校验密钥长度
|
||||
if (keyInfo.getKey() == null || keyInfo.getKey().length() != KEY_LENGTH) {
|
||||
throw new IllegalArgumentException("密钥版本 v" + keyInfo.getVersion() + " 的密钥长度需要为32位");
|
||||
@@ -146,13 +146,13 @@ public class SecureAesGcmEncryptor {
|
||||
log.warn("密文格式错误,缺少版本前缀");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
int separatorIndex = ciphertext.indexOf(VERSION_SEPARATOR);
|
||||
if (separatorIndex == -1) {
|
||||
log.warn("密文格式错误,缺少版本分隔符");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
int version;
|
||||
try {
|
||||
version = Integer.parseInt(ciphertext.substring(1, separatorIndex));
|
||||
@@ -160,16 +160,16 @@ public class SecureAesGcmEncryptor {
|
||||
log.warn("密文版本号格式错误");
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
String encryptedBase64 = ciphertext.substring(separatorIndex + 1);
|
||||
|
||||
|
||||
// 获取对应版本的密钥
|
||||
SecretKey secretKey = secretKeyCache.get(version);
|
||||
if (secretKey == null) {
|
||||
log.warn("找不到版本 v{} 对应的密钥", version);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// 解密
|
||||
byte[] combined = Base64.getDecoder().decode(encryptedBase64);
|
||||
byte[] iv = new byte[GCM_IV_LENGTH];
|
||||
@@ -196,4 +196,3 @@ public class SecureAesGcmEncryptor {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/// # 平台通用配置属性
|
||||
///
|
||||
/// 整合了缓存、异常处理、Spring 和 Swagger 配置
|
||||
@@ -28,6 +31,10 @@ public class PlatformCommonProperties {
|
||||
private L1 l1 = new L1();
|
||||
/// L2 Redis 缓存配置
|
||||
private L2 l2 = new L2();
|
||||
/// 敏感缓存名前缀;匹配的 L2 value 整包 AES-GCM 加密(默认 secure:)
|
||||
private String securePrefix = "secure:";
|
||||
/// 额外视为敏感的 cacheName 精确列表(可选)
|
||||
private List<String> secureNames = new ArrayList<>();
|
||||
|
||||
/// # L1 本地缓存配置
|
||||
///
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt;
|
||||
package cn.daxpay.open.platform.common.config.encrypt;
|
||||
|
||||
import cn.daxpay.open.platform.common.config.properties.EncryptKeyInfo;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -1,12 +1,14 @@
|
||||
package cn.daxpay.open.platform.common.mybatisplus;
|
||||
|
||||
import cn.daxpay.open.platform.common.config.ConfigAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
import org.springframework.context.annotation.ComponentScan;
|
||||
|
||||
/// # mybatis自动配置
|
||||
///
|
||||
@AutoConfiguration
|
||||
/// 依赖 [ConfigAutoConfiguration] 先注册 SecureAesGcmEncryptor,供 DataEncryptTypeHandler 使用。
|
||||
@AutoConfiguration(after = ConfigAutoConfiguration.class)
|
||||
@ComponentScan
|
||||
@ConfigurationPropertiesScan
|
||||
public class MybatisPlusCommonAutoConfiguration {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt;
|
||||
|
||||
import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.ibatis.type.BaseTypeHandler;
|
||||
@@ -13,6 +14,7 @@ import java.sql.SQLException;
|
||||
/// # 数据加密类型处理器, 使用 AES-256-GCM 加密
|
||||
///
|
||||
/// 支持多密钥版本,密文格式:v{version}:{encrypted}
|
||||
/// 加密器由 [DataEncryptTypeHandlerConfiguration] 注入全进程唯一的 [SecureAesGcmEncryptor] Bean。
|
||||
@Slf4j
|
||||
public class DataEncryptTypeHandler extends BaseTypeHandler<String> {
|
||||
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package cn.daxpay.open.platform.common.mybatisplus.handler.encrypt;
|
||||
|
||||
import cn.daxpay.open.platform.common.config.encrypt.SecureAesGcmEncryptor;
|
||||
import cn.daxpay.open.platform.common.config.properties.PlatformConfigProperties;
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
/// # 数据加密类型处理器初始化配置
|
||||
///
|
||||
/// 复用 [SecureAesGcmEncryptor] Spring Bean(由 [EncryptorConfiguration] 创建),
|
||||
/// 保证与缓存 L2 加密使用同一加密器实例。
|
||||
@Slf4j
|
||||
@Configuration
|
||||
@RequiredArgsConstructor
|
||||
@@ -17,6 +20,7 @@ import org.springframework.context.annotation.Configuration;
|
||||
public class DataEncryptTypeHandlerConfiguration {
|
||||
|
||||
private final PlatformConfigProperties platformConfigProperties;
|
||||
private final ObjectProvider<SecureAesGcmEncryptor> encryptorProvider;
|
||||
|
||||
@PostConstruct
|
||||
public void initEncryptTypeHandler() {
|
||||
@@ -27,12 +31,11 @@ public class DataEncryptTypeHandlerConfiguration {
|
||||
return;
|
||||
}
|
||||
|
||||
var keys = encrypt.getKeys();
|
||||
if (CollUtil.isEmpty(keys)) {
|
||||
throw new IllegalStateException("启用数据加密时必须配置至少一个密钥,请配置 daxpay.platform.config.encrypt.keys");
|
||||
SecureAesGcmEncryptor encryptor = encryptorProvider.getIfAvailable();
|
||||
if (encryptor == null) {
|
||||
throw new IllegalStateException(
|
||||
"已启用数据加密但未找到 SecureAesGcmEncryptor Bean,请检查 daxpay.platform.config.encrypt 配置");
|
||||
}
|
||||
|
||||
var encryptor = new SecureAesGcmEncryptor(keys);
|
||||
DataEncryptTypeHandler.initialize(encryptor, true);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user