许多公司(如:金融科技公司)处理的用户敏感数据由于法律限制不能永久存储。根据规定,这些数据的存储时间不能超过预设期限,并且最好在用于服务目的之后就将其删除。解决这个问题有多种可能的方案。在本文中,我想展示一个利用 Spring 和 Redis 处理敏感数据的应用程序的简化示例。
Redis 是一种高性能的 NoSQL 数据库。通常,它被用作内存缓存解决方案,因为它的速度非常快。然而,在这个示例中,我们将把它用作主要的数据存储。它完美地符合我们问题的需求,并且与 Spring Data 有很好的集成。
我们将创建一个管理用户全名和卡详细信息(作为敏感数据的示例)的应用程序。卡详细信息将以加密字符串的形式通过 POST 请求传递给应用程序。数据将仅在数据库中存储五分钟。在通过 GET 请求读取数据之后,数据将被自动删除。
该应用程序被设计为公司内部的微服务,不提供公共访问权限。用户的数据可以从面向用户的服务传递过来。然后,其他内部微服务可以请求卡详细信息,确保敏感数据保持安全,且无法从外部服务访问。
初始化 Spring Boot 项目
让我们开始使用 Spring Initializr 创建项目。我们需要 Spring Web、Spring Data Redis 和 Lombok。我还添加了 Spring Boot Actuator,因为在真实微服务中它肯定会很有用。
在初始化服务之后,我们应该添加其他依赖项。为了能够在读取数据后自动删除数据,我们将使用 AspectJ。我还添加了一些其他对服务有帮助的依赖项,使它看起来更接近真实的服务。
最终的 build.gradle
文件如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | plugins { id 'java' id 'org.springframework.boot' version '3.3.3' id 'io.spring.dependency-management' version '1.1.6' id "io.freefair.lombok" version "8.10.2" } java { toolchain { languageVersion = JavaLanguageVersion.of(22) } } repositories { mavenCentral() } ext { springBootVersion = '3.3.3' springCloudVersion = '2023.0.3' dependencyManagementVersion = '1.1.6' aopVersion = "1.9.19" hibernateValidatorVersion = '8.0.1.Final' testcontainersVersion = '1.20.2' jacksonVersion = '2.18.0' javaxValidationVersion = '3.1.0' } dependencyManagement { imports { mavenBom "org.springframework.boot:spring-boot-dependencies:${springBootVersion}" mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" } } dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-actuator' implementation "org.aspectj:aspectjweaver:${aopVersion}" implementation "com.fasterxml.jackson.core:jackson-core:${jacksonVersion}" implementation "com.fasterxml.jackson.core:jackson-databind:${jacksonVersion}" implementation "com.fasterxml.jackson.core:jackson-annotations:${jacksonVersion}" implementation "jakarta.validation:jakarta.validation-api:${javaxValidationVersion}" implementation "org.hibernate:hibernate-validator:${hibernateValidatorVersion}" testImplementation('org.springframework.boot:spring-boot-starter-test') { exclude group: 'org.junit.vintage' } testImplementation "org.testcontainers:testcontainers:${testcontainersVersion}" testImplementation 'org.junit.jupiter:junit-jupiter' } tasks.named('test') { useJUnitPlatform() } |
我们需要设置与 Redis 的连接。application.yml
中的 Spring Data Redis 属性如下:
1 2 3 4 5 | spring: data: redis: host: localhost port: 6379 |
领域模型
CardInfo
是我们将要处理的数据对象。为了使其更加真实,我们让卡详细信息作为加密数据传递到服务中。我们需要解密、验证,然后存储传入的数据。领域模型将有三个层次:
- DTO:请求级别,用于控制器
- Model:服务级别,用于业务逻辑
- Entity:持久化级别,用于仓库
DTO 和 Model 之间的转换在 CardInfoConverter
中完成。Model 和 Entity 之间的转换在 CardInfoEntityMapper
中完成。我们使用 Lombok 以方便开发。
DTO
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | @Builder @Getter @ToString (exclude = "cardDetails" ) @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties (ignoreUnknown = true ) public class CardInfoRequestDto { @NotBlank private String id; @Valid private UserNameDto fullName; @NotNull private String cardDetails; } |
其中 UserNameDto
1 2 3 4 5 6 7 8 9 10 11 12 | @Builder @Getter @ToString @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties (ignoreUnknown = true ) public class UserNameDto { @NotBlank private String firstName; @NotBlank private String lastName; } |
这里的卡详细信息表示一个加密字符串,而 fullName
是作为一个单独的对象传递的。注意 cardDetails
字段是如何从 toString()
方法中排除的。由于数据是敏感的,不应意外记录。
Model
1 2 3 4 5 6 7 8 9 10 | @Data @Builder public class CardInfo { @NotBlank private String id; @Valid private UserName userName; @Valid private CardDetails cardDetails; } |
1 2 3 4 5 6 | @Data @Builder public class UserName { private String firstName; private String lastName; } |
CardInfo
与 CardInfoRequestDto
相同,只是 cardDetails
已经被转换(在 CardInfoEntityMapper
中完成)。CardDetails
现在是一个解密后的对象,它有两个敏感字段:pan(卡号)和 CVV(安全码):
1 2 3 4 5 6 7 8 9 10 | @Data @Builder @NoArgsConstructor @AllArgsConstructor @ToString (exclude = { "pan" , "cvv" }) public class CardDetails { @NotBlank private String pan; private String cvv; } |
再次看到,我们从 toString()
方法中排除了敏感的 pan 和 CVV 字段。
Entity
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | @Getter @Setter @ToString (exclude = "cardDetails" ) @NoArgsConstructor @AllArgsConstructor @Builder @RedisHash public class CardInfoEntity { @Id private String id; private String cardDetails; private String firstName; private String lastName; } |
为了让 Redis 为实体创建哈希键,需要添加 @RedisHash
注解以及 @Id
注解。
以下是 DTO 转换为 Model 的方式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | public CardInfo toModel( @NonNull CardInfoRequestDto dto) { final UserNameDto userName = dto.getFullName(); return CardInfo.builder() .id(dto.getId()) .userName(UserName.builder() .firstName(ofNullable(userName).map(UserNameDto::getFirstName).orElse( null )) .lastName(ofNullable(userName).map(UserNameDto::getLastName).orElse( null )) .build()) .cardDetails(getDecryptedCardDetails(dto.getCardDetails())) .build(); } private CardDetails getDecryptedCardDetails( @NonNull String cardDetails) { try { return objectMapper.readValue(cardDetails, CardDetails. class ); } catch (IOException e) { throw new IllegalArgumentException( "Card details string cannot be transformed to Json object" , e); } } |
在这个例子中,getDecryptedCardDetails
方法只是将字符串映射到 CardDetails
对象。在真实的应用程序中,解密逻辑将在这个方法中实现。
仓库
使用 Spring Data 创建仓库。服务中的 CardInfo
通过其 ID 检索,因此不需要定义自定义方法,代码如下所示:
1 2 3 | @Repository public interface CardInfoRepository extends CrudRepository { } |
Redis 配置
我们需要实体只存储五分钟。为了实现这一点,我们需要设置 TTL(生存时间)。我们可以通过在 CardInfoEntity
中引入一个字段并添加 @TimeToLive
注解来实现。也可以通过在 @RedisHash
上添加值来实现:@RedisHash(timeToLive = 5*60)
。
这两种方法都有些缺点。在第一种情况下,我们需要引入一个与业务逻辑无关的字段。在第二种情况下,值是硬编码的。还有另一种选择:实现 KeyspaceConfiguration
。通过这种方法,我们可以使用 application.yml
中的属性来设置 TTL,如果需要的话,还可以设置其他 Redis 属性。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 | @Configuration @RequiredArgsConstructor @EnableRedisRepositories (enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP) public class RedisConfiguration { private final RedisKeysProperties properties; @Bean public RedisMappingContext keyValueMappingContext() { return new RedisMappingContext( new MappingConfiguration( new IndexConfiguration(), new CustomKeyspaceConfiguration())); } public class CustomKeyspaceConfiguration extends KeyspaceConfiguration { @Override protected Iterable initialConfiguration() { return Collections.singleton(customKeyspaceSettings(CardInfoEntity. class , CacheName.CARD_INFO)); } private KeyspaceSettings customKeyspaceSettings(Class type, String keyspace) { final KeyspaceSettings keyspaceSettings = new KeyspaceSettings(type, keyspace); keyspaceSettings.setTimeToLive(properties.getCardInfo().getTimeToLive().toSeconds()); return keyspaceSettings; } } @NoArgsConstructor (access = AccessLevel.PRIVATE) public static class CacheName { public static final String CARD_INFO = "cardInfo" ; } } |
为了使 Redis 能够根据 TTL 删除实体,需要在 @EnableRedisRepositories
注解中添加 enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP
。我引入了 CacheName
类,以便使用常量作为实体名称,并反映如果需要的话可以对多个实体进行不同的配置。
TTL 的值是从 RedisKeysProperties
对象中获取的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | @Data @Component @ConfigurationProperties ( "redis.keys" ) @Validated public class RedisKeysProperties { @NotNull private KeyParameters cardInfo; @Data @Validated public static class KeyParameters { @NotNull private Duration timeToLive; } } |
这里只有 cardInfo 这个实体,但可能还有其他实体存在。 应用.yml 中的 TTL 属性:
1 2 3 4 | redis: keys: cardInfo: timeToLive: PT5M |
Controller
让我们为该服务添加 API,以便能够通过 HTTP 存储和访问数据。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | @RestController @RequiredArgsConstructor @RequestMapping ( "/api/cards" ) public class CardController { private final CardService cardService; private final CardInfoConverter cardInfoConverter; @PostMapping @ResponseStatus (CREATED) public void createCard( @Valid @RequestBody CardInfoRequestDto cardInfoRequest) { cardService.createCard(cardInfoConverter.toModel(cardInfoRequest)); } @GetMapping ( "/{id}" ) public ResponseEntity getCard( @PathVariable ( "id" ) String id) { return ResponseEntity.ok(cardInfoConverter.toDto(cardService.getCard(id))); } } |
基于 AOP 的自动删除功能
我们希望在通过 GET 请求成功读取该实体之后立即对其进行删除。这可以通过 AOP 和 AspectJ 来实现。我们需要创建一个 Spring Bean 并用 @Aspect
进行注解。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @Aspect @Component @RequiredArgsConstructor @ConditionalOnExpression ( "${aspect.cardRemove.enabled:false}" ) public class CardRemoveAspect { private final CardInfoRepository repository; @Pointcut ( "execution(* com.cards.manager.controllers.CardController.getCard(..)) && args(id)" ) public void cardController(String id) { } @AfterReturning (value = "cardController(id)" , argNames = "id" ) public void deleteCard(String id) { repository.deleteById(id); } } |
@Pointcut
定义了逻辑应用的切入点。换句话说,它决定了触发逻辑执行的时机。deleteCard
方法定义了具体的逻辑,它通过 CardInfoRepository
按照 ID 删除 cardInfo
实体。@AfterReturning
注解表明该方法会在 value
属性中定义的方法成功返回后执行。
此外,我还使用了 @ConditionalOnExpression
注解来根据配置属性开启或关闭这一功能。
测试
我们将使用 MockMvc 和 Testcontainers 来编写 test case。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | public abstract class RedisContainerInitializer { private static final int PORT = 6379 ; private static final String DOCKER_IMAGE = "redis:6.2.6" ; private static final GenericContainer REDIS_CONTAINER = new GenericContainer(DockerImageName.parse(DOCKER_IMAGE)) .withExposedPorts(PORT) .withReuse( true ); static { REDIS_CONTAINER.start(); } @DynamicPropertySource static void properties(DynamicPropertyRegistry registry) { registry.add( "spring.data.redis.host" , REDIS_CONTAINER::getHost); registry.add( "spring.data.redis.port" , () -> REDIS_CONTAINER.getMappedPort(PORT)); } } |
通过 @DynamicPropertySource
,我们可以从启动的 Redis Docker 容器中设置属性。随后,这些属性将被应用程序读取,以建立与 Redis 的连接。
以下是针对 POST 和 GET 请求的基本测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | public class CardControllerTest extends BaseTest { private static final String CARDS_URL = "/api/cards" ; private static final String CARDS_ID_URL = CARDS_URL + "/{id}" ; @Autowired private CardInfoRepository repository; @BeforeEach public void setUp() { repository.deleteAll(); } @Test public void createCard_success() throws Exception { final CardInfoRequestDto request = aCardInfoRequestDto().build(); mockMvc.perform(post(CARDS_URL) .contentType(APPLICATION_JSON) .content(objectMapper.writeValueAsBytes(request))) .andExpect(status().isCreated()) ; assertCardInfoEntitySaved(request); } @Test public void getCard_success() throws Exception { final CardInfoEntity entity = aCardInfoEntityBuilder().build(); prepareCardInfoEntity(entity); mockMvc.perform(get(CARDS_ID_URL, entity.getId())) .andExpect(status().isOk()) .andExpect(jsonPath( "$.id" , is(entity.getId()))) .andExpect(jsonPath( "$.cardDetails" , notNullValue())) .andExpect(jsonPath( "$.cardDetails.cvv" , is(CVV))) ; } } |
通过 AOP 进行自动删除功能测试:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | @Test @EnabledIf ( expression = "${aspect.cardRemove.enabled}" , loadContext = true ) public void getCard_deletedAfterRead() throws Exception { final CardInfoEntity entity = aCardInfoEntityBuilder().build(); prepareCardInfoEntity(entity); mockMvc.perform(get(CARDS_ID_URL, entity.getId())) .andExpect(status().isOk()); mockMvc.perform(get(CARDS_ID_URL, entity.getId())) .andExpect(status().isNotFound()) ; } |
我为这个测试添加了 @EnabledIf
注解,因为 AOP 逻辑可以在配置文件中关闭,而该注解则用于决定是否要运行该测试。
以上就是使用Spring和Redis创建处理敏感数据的服务的示例代码的详细内容,更多关于Spring Redis处理敏感数据的资料请关注IT俱乐部其它相关文章!