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.
Join the DZone community and get the full member experience.
Join For FreeMany 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:
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
:
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
@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
@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
@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
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):
@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
@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:
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:
@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.
@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:
@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
:
redis:
keys:
cardInfo:
timeToLive: PT5M
Controller
Let’s add API to the service to be able to store and access the data by 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)));
}
}
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
.
@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
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:
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:
@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.
Published at DZone with permission of Alexander Rumyantsev. See the original article here.
Opinions expressed by DZone contributors are their own.
Comments