Пример использования Spring Data и Redis для временного хранения персональных данных

в 7:15, , рубрики: aop, aspectj, java, redis, spring, spring boot, testcontainers

Некоторые компании, работающие с персональными данными пользователей, сталкиваются с невозможностью хранить их в течение долгого периода времени из-за правовых ограничений. Такое часто можно встретить в финтехах. Позволяется сохранить данные на очень короткое время, которые также должны быть удалены сразу после использования в целях сервиса. Существует несколько вариантов решения этой задачи. В данном посте я показываю упрощенный пример микросервиса, работающего с чувствительной информацией, используя Spring и Redis

Redis это высокопроизводительная NoSQL база данных, которая обычно используется в качестве решения для кеширования данных. В данном примере мы будем использовать Redis как основную БД сервиса. Она хорошо подходит для нашей задачи, а также имеет удобную интеграцию с Spring Data. Мы создадим микросервис, который взаимодействует с данными пользователей: ФИО пользователя и данные кредитной карты (как пример чувствительных данных). Карточные данные передаются в сервис (POST запрос) в виде закодированной строки (обычная строка для простоты нашего примера). Информация хранится в БД только 5 минут. После того, как данные впоследствии прочитаны (GET запрос), они сразу же автоматически удаляются.

Приложение спроектировано, как внутренний микросервис компании без публичного доступа к нему из вне. Пользовательские данные могут передаваться в него из сервиса, непосредственно работающего с пользователями. Карточные данные далее могут быть запрошены другим микросервисом из внутреннего контура, гарантируя, что пользовательская информация не покидает контур и недоступна для внешних сервисов.

Инициализация Spring Boot проекта

Для начала создадим приложение используя Spring initializr. Нам понадобятся Spring Web, Spring Data Redis, Lombok. Я также добавил Spring Boot Actuator, т.к. он явно бы понадобился в реальном приложении.
После инициализации сервиса добавим необходимые зависимости. Для того чтобы иметь возможность автоматически удалять данные после того, как они были прочитаны, мы будем использовать AspectJ. Я также добавил некоторые другие полезные зависимости, которые делают приложения более реалистичным (например, вам точно понадобилась бы валидация в настоящем сервисе).

Финальная версия build.gradle выглядит следующим образом:

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. Spring Data Redis проперти в application.yml:

spring:
 data:
   redis:
     host: localhost
     port: 6379

Модель

CardInfo представляет собой главный объект, с которым мы будем работать. Для большей реалистичности данные кредитной карты будем передавать в виде закодированной строки. Мы должны раскодировать, провалидировать и затем сохранить входящие данные. Всего в приложении 3 доменных слоя:

  • DTO - уровень запросов, используется в контроллерах

  • Model - сервисный уровень, используется в бизнес-логике

  • Entity - персистентный уровень, используется репозиториями

DTO конвертируется в Model и наоборот в CardInfoConverter.
Model конвертируется в Entity и наоборот в CardInfoEntityMapper.

Для удобства используем Lombok.

DTO:

@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:

@Builder
@Getter
@ToString
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class UserNameDto {
   @NotBlank
   private String firstName;
   @NotBlank
   private String lastName;
}

Карточные данные представляют собой строку, а fullName это отдельный объект, передаваемые в виде обычного Json.
Обратите внимание, как поле cardDetails исключено из метода toString(). Персональные данные не должны быть случайно залогированы.

Model:

@Data
@Builder
public class CardInfo {

   @NotBlank
   private String id;
   @Valid
   private UserName userName;
   @Valid
   private CardDetails cardDetails;
}
@Data
@Builder
public class UserName {

   private String firstName;
   private String lastName;
}

CardInfo является таким же объектом, как и CardInfoRequestDto кроме поля cardDetails (сконвертировано в CardInfoEntityMapper). CardDetails здесь - объект, десериализованный из строки, у которого есть 2 поля: pan (номер карты) и cvv (проверочный код)

@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@ToString(exclude = {"pan", "cvv"})
public class CardDetails {
   @NotBlank
   private String pan;
   private String cvv;
}

Снова обратите внимание, что мы исключили pan и cvv из метода toString().

Entity:

@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:

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 объект. В реальном приложении здесь была бы логика по декодированию строки.

Repository

Для создания Repository используем Spring Data. CardInfo извлекается по id, так что нам не нужно создавать кастомные методы:

@Repository
public interface CardInfoRepository extends CrudRepository<CardInfoEntity, String> {
}

Конфигурация Redis

Мы должны хранить данные только 5 минут. Для это мы должны задать TTL (time to leave). Мы можем сделать это введя новое поле в CardInfoEntity и добавив над ним аннотацию @TimeToLive. Так же, мы могли бы добиться этого задав значение атрибута timeToLive в аннотации @RedisHash: @RedisHash(timeToLive = 5*60). У обоих способов есть недостатки. В первом случае нам приходится добавлять поле, которое не имеет отношения к бизнес-логике. Во втором случае значение TTL захардкожено. Мы воспользуемся другим способом: имплементируем KeyspaceConfiguration. В этом случае мы можем использовать значение проперти из application.yml, чтобы установить TTL и, если необходимо, другие настройки Redis.

@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<KeyspaceSettings> initialConfiguration() {
           return Collections.singleton(customKeyspaceSettings(CardInfoEntity.class, CacheName.CARD_INFO));
       }

       private <T> KeyspaceSettings customKeyspaceSettings(Class<T> 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 необходимо добавить enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP в аннотацию @EnableRedisRepositories annotation.
Я создал класс CacheName для использования констант в качестве имен entity, а также, чтобы показать, что может быть несколько entity, которые могут быть сконфигурированы по-разному при необходимости.

Значение TTL извлекается из RedisKeysProperties:

@Data
@Component
@ConfigurationProperties("redis.keys")
@Validated
public class RedisKeysProperties {
   @NotNull
   private KeyParameters cardInfo;

   @Data
   @Validated
   public static class KeyParameters {
       @NotNull
       private Duration timeToLive;
   }
}

В данном примере у нас есть только cardInfo, но могут быть и другие entity.

Значение TTL в application.yml:

redis:
 keys:
   cardInfo:
     timeToLive: PT5M

Controller

Добавим API в сервис, чтобы иметь возможность работать с данными через HTTP.

@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<CardInfoResponseDto> getCard(@PathVariable("id") String id) {
       return ResponseEntity.ok(cardInfoConverter.toDto(cardService.getCard(id)));
   }
}

Автоматическое удаление данных с AOP

Нам нужно, чтобы данные удалялись сразу после того, как были успешно возвращены в GET запросе. Мы можем сделать это с помощью AOP и AspectJ. Необходимо создать Spring Bean и аннотировать его @Aspect

@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() определятся, что должно выполнятся. Он удаляет cardInfo по id используя для этого CardInfoRepository. Аннотация @AfterReturning означает, что этот метод должны запускаться после успешного возвращения значения из метода, указанного в value (cardController(id)). 
Я также аннотировал класс @ConditionalOnExpression для включения/отключения этого функционала из пропертей.

Testing

Мы будем тестировать используя MockMvc и Testcontainers.
Testcontainers initializer для Redis:

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 запросов:

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:

@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 может быть отключена в пропертях и эта аннотация определяет нужно ли запускать тест.

Ссылки

Полная версия микросервиса доступна на GitHub

Автор: alex_rum

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js