DZone
Thanks for visiting DZone today,
Edit Profile
  • Manage Email Subscriptions
  • How to Post to DZone
  • Article Submission Guidelines
Sign Out View Profile
  • Post an Article
  • Manage My Drafts
Over 2 million developers have joined DZone.
Log In / Join
Refcards Trend Reports
Events Video Library
Refcards
Trend Reports

Events

View Events Video Library

Related

  • Optimizing High-Volume REST APIs Using Redis Caching and Spring Boot (With Load Testing Code)
  • Fast Data Access Part 2: From Manual Hacks to Modern Stacks
  • Caching Mechanisms Using Spring Boot With Redis or AWS ElastiCache
  • Beyond Keys and Values: Structuring Data in Redis

Trending

  • Architecting Sub-Microsecond HFT Systems With C++ and Zero-Copy IPC
  • Java Backend Development in the Era of Kubernetes and Docker
  • Integrating AI-Driven Decision-Making in Agile Frameworks: A Deep Dive into Real-World Applications and Challenges
  • How to Prevent Data Loss in C#
  1. DZone
  2. Software Design and Architecture
  3. Microservices
  4. Creating a Service for Sensitive Data With Spring and Redis

Creating a Service for Sensitive Data With Spring and Redis

In some cases, one cannot store user-sensitive data permanently. Let's create a simple application that handles sensitive data leveraging Spring and Redis.

By 
Alexander Rumyantsev user avatar
Alexander Rumyantsev
·
Jan. 31, 25 · Tutorial
Likes (2)
Comment
Save
Tweet
Share
4.9K Views

Join the DZone community and get the full member experience.

Join For Free

Many companies work with user-sensitive data that can’t be stored permanently due to legal restrictions. Usually, this can happen in fintech companies. The data must not be stored for longer than a predefined time period and should preferably be deleted after it has been used for service purposes. There are multiple possible options to solve this problem. In this post, I would like to present a simplified example of an application that handles sensitive data leveraging Spring and Redis.

Redis is a high-performance NoSQL database. Usually, it is used as an in-memory caching solution because of its speed. However, in this example, we will be using it as the primary datastore. It perfectly fits our problem’s needs and has a good integration with Spring Data.

We will create an application that manages a user's full name and card details (as an example of sensitive data). Card details will be passed (POST request) to the application as an encrypted string (just a normal string for simplicity). The data will be stored in the DB for five minutes only. After the data is read (GET request), it will be automatically deleted.

The app is designed as an internal microservice of the company without public access. The user’s data can be passed from a user-facing service. Card details can then be requested by other internal microservices, ensuring sensitive data is kept secure and inaccessible from external services.

Initialize Spring Boot Project

Let’s start creating the project with Spring Initializr. We will need Spring Web, Spring Data Redis, and Lombok. I also added Spring Boot Actuator as it would definitely be useful in a real microservice.

After initializing the service, we should add other dependencies. To be able to delete the data automatically after it has been read we will be using AspectJ. I also added some other dependencies that are helpful for the service and make it look more realistic (for a real-world service, you would definitely add some validation, for example).

The final build.gradle would look like this:

Groovy
 
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()
}


We need to set up a connection to Redis. Spring Data Redis properties in application.yml:

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


Domain

CardInfo is the data object that we will be working with. To make it more realistic let’s make card details to be passed in the service as encrypted data. We need to decrypt, validate, and then store incoming data. There will be three layers in the domain:

  • DTO: request-level, used in controllers
  • Model: service-level, used in business logic
  • Entity: persistent-level, used in repositories

DTO is converted to Model and vice versa in CardInfoConverter. Model is converted to Entity and vice versa in CardInfoEntityMapper. We use Lombok for convenience.

DTO

Java
 
@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;
}


Where UserNameDto

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


Card details here represent an encrypted string, and fullName is a separate object that is passed as it is. Notice how the cardDetails field is excluded from the toString() method. Since the data is sensitive, it shouldn’t be accidentally logged.

Model

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


Java
 
@Data
@Builder
public class UserName {
   private String firstName;
   private String lastName;
}


CardInfo is the same as CardInfoRequestDto except cardDetails (converted in CardInfoEntityMapper). CardDetails now is a decrypted object that has two sensitive fields: pan (card number) and CVV (security number):

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


See again that we excluded sensitive pan and CVV fields from toString() method.

Entity

Java
 
@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;
}


In order for Redis to create a hash key of an entity, one needs to add @RedisHash annotation along with @Id annotation.

This is how dto -> model conversion happens:

Java
 
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);
   }
}


In this case, the getDecryptedCardDetails method just maps a string to a CardDetails object. In a real application, the decryption logic would be implemented within this method.

Repository

Spring Data is used to create a repository. The CardInfo in the service is retrieved by its ID, so there is no need to define custom methods, and the code looks like this:

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


Redis Configuration

We need the entity to be stored only for five minutes. To achieve this, we have to set up TTL (time-to-live). We can do it by introducing a field in CardInfoEntity and adding the annotation @TimeToLive on top. It can also be achieved by adding the value to @RedisHash: @RedisHash(timeToLive = 5*60).

Both ways have some flaws. In the first case, we have to introduce a field that doesn’t relate to business logic. In the second case, the value is hardcoded. There is another option: implement KeyspaceConfiguration. With this approach, we can use property in application.yml to set TTL and, if needed, other Redis properties.

Java
 
@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";
   }
}


To make Redis delete entities with TTL, one has to add enableKeyspaceEvents = RedisKeyValueAdapter.EnableKeyspaceEvents.ON_STARTUP to @EnableRedisRepositories annotation. I introduced the CacheName class to use constants as entity names and to reflect that there can be multiple entities that can be configured differently if needed.

TTL value is taken from RedisKeysProperties object:

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


Here, there is only cardInfo, but there can be other entities.

TTL properties in application.yml:

YAML
 
redis:
 keys:
   cardInfo:
     timeToLive: PT5M


Controller

Let’s add API to the service to be able to store and access the data by HTTP.

Java
 
@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)));
   }
}


Auto Deletion With AOP

We want the entity to be deleted right after it was successfully read with a GET request. It can be done with AOP and AspectJ. We need to create Spring Bean and annotate it with @Aspect.

Java
 
@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);
   }
}


A @Pointcut defines the place where the logic is applied. Or, in other words, what triggers the logic to execute. The deleteCard method is where the logic is defined. It deletes the cardInfo entity by ID using CardInfoRepository. The @AfterReturning annotation means that the method should run after a successful return from the method that is defined in the value attribute.

I also annotated the class with @ConditionalOnExpression to be able to switch on/off this functionality from properties.

Testing

We will write web tests using MockMvc and Testcontainers.

Testcontainers Initializer for Redis

Java
 
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));
   }
}


With @DynamicPropertySource, we can set properties from the started Redis Docker container. Afterwards, the properties will be read by the app to set up a connection to Redis.

Here are basic tests for POST and GET requests:

Java
 
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)))
       ;
   }
}


And the test to check auto deletion with AOP:

Java
 
@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())
   ;
}


I annotated this test with @EnabledIf as AOP logic can be switched off in properties, and the annotation determines whether the test should be run.

Links

The source code of the full version of this service is available on GitHub.

Data (computing) Redis (company) Spring Boot

Published at DZone with permission of Alexander Rumyantsev. See the original article here.

Opinions expressed by DZone contributors are their own.

Related

  • Optimizing High-Volume REST APIs Using Redis Caching and Spring Boot (With Load Testing Code)
  • Fast Data Access Part 2: From Manual Hacks to Modern Stacks
  • Caching Mechanisms Using Spring Boot With Redis or AWS ElastiCache
  • Beyond Keys and Values: Structuring Data in Redis

Partner Resources

×

Comments

The likes didn't load as expected. Please refresh the page and try again.

  • RSS
  • X
  • Facebook

ABOUT US

  • About DZone
  • Support and feedback
  • Community research

ADVERTISE

  • Advertise with DZone

CONTRIBUTE ON DZONE

  • Article Submission Guidelines
  • Become a Contributor
  • Core Program
  • Visit the Writers' Zone

LEGAL

  • Terms of Service
  • Privacy Policy

CONTACT US

  • 3343 Perimeter Hill Drive
  • Suite 215
  • Nashville, TN 37211
  • [email protected]

Let's be friends:

  • RSS
  • X
  • Facebook