16

I have a requirement where I have created a Custom Annotation @MaskSensitiveData. I annotate sensitive fields. like

class MyBean {
    String userName;
    @MaskSensitiveData
    String cardNumber;
    String abc;
    String xyz;
}

ObjectMapper mapper = new ObjectMapper();
    String json = null;
    AnnotationIntrospector primary = new JaxbAnnotationIntrospector();
    AnnotationIntrospector secondary = new JacksonAnnotationIntrospector();
    AnnotationIntrospector pair = new AnnotationIntrospectorPair(primary, secondary);
    mapper.setAnnotationIntrospector(pair);
    try {
        json = mapper.writeValueAsString(obj);
        /*
         * if(json != null ) { json = getLoggableString(json); }
         */
    } catch (Exception e) {
        return "Unable to convert to Json object:" + obj.toString() + " Message: " + e.getMessage();

    }

I am using Jackson ObjectMapper to convert objct to Json like. I need to customize Object Mapper to mask cardNumber field in return json. Please suggest a better way.

Saurabh Bhardwaj
  • 161
  • 1
  • 1
  • 4
  • 1
    See also https://stackoverflow.com/questions/14708386/want-to-hide-some-fields-of-an-object-that-are-being-mapped-to-json-by-jackson – Vadzim May 16 '19 at 15:36

4 Answers4

15
package stackoverflow;

import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.is;

import java.io.IOException;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;

import org.hamcrest.Matchers;
import org.junit.Test;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.AnnotationIntrospector;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
import com.fasterxml.jackson.databind.introspect.Annotated;
import com.fasterxml.jackson.databind.introspect.AnnotationIntrospectorPair;
import com.fasterxml.jackson.databind.introspect.NopAnnotationIntrospector;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;

public class MaskingAnnotationExample {
    // Define @custom Annotation
    // assumed to be used by String type field for this example
    @Retention(RetentionPolicy.RUNTIME)
    static @interface MaskSensitiveData {
    }

    public static class MyBean {
        private String userName;

        @MaskSensitiveData
        private String cardNumber;

        public MyBean() {
        }

        public String getCardNumber() {
            return cardNumber;
        }

        public String getUserName() {
            return userName;
        }

        public void setUserName(String userName) {
            this.userName = userName;
        }

        public void setCardNumber(String cardNumber) {
            this.cardNumber = cardNumber;
        }
    }

    // map the Serializer/Deserializer based on custom annotation
    public static class MaskSensitiveDataAnnotationIntrospector extends NopAnnotationIntrospector {
        private static final long serialVersionUID = 1L;

        @Override
        public Object findSerializer(Annotated am) {
            MaskSensitiveData annotation = am.getAnnotation(MaskSensitiveData.class);
            if (annotation != null) {
                return MaskSensitiveDataSerializer.class;
            }

            return null;
        }

        @Override
        public Object findDeserializer(Annotated am) {
            MaskSensitiveData annotation = am.getAnnotation(MaskSensitiveData.class);
            if (annotation != null) {
                return MaskSensitiveDataDeserializer.class;
            }

            return null;
        }
    }

    public static class MaskSensitiveDataDeserializer extends StdDeserializer<String> {
        private static final long serialVersionUID = 1L;

        public MaskSensitiveDataDeserializer() {
            super(String.class);
        }

        @Override
        public String deserialize(JsonParser p, DeserializationContext ctxt) throws IOException, JsonProcessingException {
            // un-masking logic here. in our example we are removing "MASK"
            // string
            String s = p.getValueAsString();
            return s.substring(4);
        }
    }

    public static class MaskSensitiveDataSerializer extends StdSerializer<String> {
        private static final long serialVersionUID = 1L;

        public MaskSensitiveDataSerializer() {
            super(String.class);
        }

        @Override
        public void serialize(String value, JsonGenerator gen, SerializerProvider provider) throws IOException {
            // Masking data; for our example we are adding 'MASK'
            gen.writeString("MASK" + value);
        }
    }

    @Test
    public void demo() throws Exception {
        ObjectMapper mapper = new ObjectMapper();

        AnnotationIntrospector sis = mapper.getSerializationConfig().getAnnotationIntrospector();
        AnnotationIntrospector dis = mapper.getDeserializationConfig().getAnnotationIntrospector();

        AnnotationIntrospector is1 = AnnotationIntrospectorPair.pair(sis, new MaskSensitiveDataAnnotationIntrospector());
        AnnotationIntrospector is2 = AnnotationIntrospectorPair.pair(dis, new MaskSensitiveDataAnnotationIntrospector());

        mapper.setAnnotationIntrospectors(is1, is2);

        MyBean obj = new MyBean();
        obj.setUserName("Saurabh Bhardwaj");
        obj.setCardNumber("4455-7788-9999-7777");
        String json = mapper.writeValueAsString(obj);

        String expectedJson = "{\"userName\":\"Saurabh Bhardwaj\",\"cardNumber\":\"MASK4455-7788-9999-7777\"}";
        assertThat(json, Matchers.is(expectedJson));

        MyBean cloned = mapper.readValue(json, MyBean.class);
        assertThat(cloned.getCardNumber(), is(obj.getCardNumber()));
    }
}

Hope this helps.

skadya
  • 4,330
  • 19
  • 27
6

Here is a solution to your problem using custom JsonSerializer. Steps are followed from this blog post.

Create a custom serializer

public class MaskingSerializer extends JsonSerializer < MyBean > {

  @
  Override
  public void serialize(MyBean value, JsonGenerator jGen, SerializerProvider serializers) throws IOException, JsonProcessingException {
    jGen.writeStartObject();

    Field[] fields = value.getClass().getDeclaredFields();
    for (Field field: fields) {
      field.setAccessible(true);
      MaskSensitiveData mask = field.getDeclaredAnnotation(MaskSensitiveData.class);

      try {
        if (mask != null) {
          field.setAccessible(true);
          field.set(value, field.get(value).toString().replaceAll(".", "*"));
        }

        jGen.writeStringField(field.getName(), field.get(value).toString());


      } catch (IllegalArgumentException e) {
        e.printStackTrace();
      } catch (IllegalAccessException e) {
        e.printStackTrace();
      }
    }

    jGen.writeEndObject();

  }

}

Create a module to bundle the serializer

public class MaskingModule extends SimpleModule {
    private static final String NAME = "CustomIntervalModule";
    private static final VersionUtil VERSION_UTIL = new VersionUtil() {};

    public MaskingModule() {
      super(NAME, VERSION_UTIL.version());
      addSerializer(MyBean.class, new MaskingSerializer());
    }
}

Register the module with ObjectMapper.

public class CustomObjectMapper extends ObjectMapper {
    public CustomObjectMapper() {
      registerModule(new MaskingModule());
    }
  }

Test the code

public class MyBeanTest {

    private static final CustomObjectMapper OBJECT_MAPPER = 
            new CustomObjectMapper();
    @Test
    public void testIntervalSerialization() throws Exception {
        MyBean mb = new MyBean();
        mb.setAbc("value");
        mb.setCardNumber("4441114443335551");
        mb.setUserName("User");
        mb.setXyz("value");
        String result = OBJECT_MAPPER.writeValueAsString(mb);
        System.out.println(result);
        String expected = "{\"userName\":\"User\",\"cardNumber\":\"****************\",\"abc\":\"value\",\"xyz\":\"value\"}";
        Assert.assertEquals(expected, result);
    }
}
Arun A
  • 585
  • 8
  • 14
  • 1
    This is fine as long as all attributes are of type String. If you would have abc as more complex type with its own attributes how would you print them as json? I also have this problem and Don't know the solution yet... – dune76 Sep 03 '17 at 11:13
  • https://stackoverflow.com/q/46021769/5667890 this is my current problem and I still don't have a clue – dune76 Sep 03 '17 at 11:16
0

I am using a GENERAL & SHARED ObjectMapper that configured by Spring boot, and I don't want it got polluted by setSerializerFactory() or rewrite the whole BeanSerializer. Here comes my solution:

Configure ObjectMapper

    @Configuration
    @AutoConfigureAfter(JacksonAutoConfiguration.class)
    public static class ExtJacksonConfig {

        @Autowired
        private ObjectMapper objectMapper;

        @PostConstruct
        public void postConstruct() throws JsonMappingException {
            SimpleModule module = new SimpleModule();
            module.addSerializer(ProductOrder.class, new POProductOrderSerializer(
                    (BeanSerializerBase) objectMapper.getSerializerFactory().createSerializer(
                            objectMapper.getSerializerProviderInstance(),
                            objectMapper.getSerializationConfig().constructType(ProductOrder.class))));
            objectMapper.registerModule(module);
        }
    }

A General SensitiveDataSerializer to mask sensitive fields

public class SensitiveDataSerializer<T> extends BeanSerializer {

    private final Function<T, Boolean> authorityChecker;
    private final String maskText;

    public SensitiveDataSerializer(BeanSerializerBase src, Function<T, Boolean> authorityChecker,
                                   String maskText) {
        super(src);
        this.authorityChecker = authorityChecker;
        this.maskText = Optional.ofNullable(maskText).orElse("****");
        assert(this.authorityChecker != null);
        assert(!Checker.isEmpty(sensitiveFieldNames));

        // Replace BeanPropertyWriter
        for (int i=0; i<_props.length; i++) {
            if (_props[i] != null && _props[i].getAnnotation(MaskSensitiveData.class) != null) {
                _props[i] = new SensitivePropertyWriter(_props[i]);
            }
        }

        for (int j=0; j<_filteredProps.length; j++) {
            if (_filteredProps[j] != null && _filteredProps[j].getAnnotation(MaskSensitiveData.class) != null) {
                _filteredProps[j] = new SensitivePropertyWriter(_filteredProps[j]);
            }
        }
    }

    class SensitivePropertyWriter extends BeanPropertyWriter {
        private final BeanPropertyWriter writer;

        SensitivePropertyWriter(BeanPropertyWriter writer) {
            super(writer);
            this.writer = writer;
        }

        @Override
        public void serializeAsField(Object bean,
                                     JsonGenerator gen,
                                     SerializerProvider prov) throws Exception {
            if (authorityChecker.apply((T) bean)) {
                super.serializeAsField(bean, gen, prov);
                return;
            }
            gen.writeStringField(writer.getName(), maskText);
        }
    }
}

Finally, the concrete Serializer you need

public class ProductOrderSerializer extends SensitiveDataSerializer<ProductOrder> {

    public POProductOrderSerializer(BeanSerializerBase src) {
        super(src, (productOrder -> {
            return true; // put your permission checking code here
        }), "****");
    }

}
Hank
  • 1,318
  • 10
  • 29
0

On top of @Skandya answer I had a requirement of adding a prefix and suffix to mask the data. here are the different implementations I did.

@Retention(RetentionPolicy.RUNTIME)
public @interface SensitiveData {
    int prefix() default 0;

    int suffix() default 0;
}

For the AnnotationIntrospector minor difference is to pass the prefix and suffix so that it can picked up by the Serializer.

import com.fasterxml.jackson.databind.introspect.Annotated;
import com.fasterxml.jackson.databind.introspect.NopAnnotationIntrospector;

public class SensitiveDataAnnotationIntrospector extends NopAnnotationIntrospector {
    private static final long serialVersionUID = 1L;

    @Override
    public Object findSerializer(Annotated am) {
        SensitiveData annotation = am.getAnnotation(SensitiveData.class);
        if (annotation != null) {
            return new SensitiveDataSerializer(annotation.prefix(), annotation.suffix());
        }

        return null;
    }

    @Override
    public Object findDeserializer(Annotated am) {
        SensitiveData annotation = am.getAnnotation(SensitiveData.class);
        if (annotation != null) {
            return SensitiveDataDeserializer.class;
        }

        return null;
    }
}

This is my serializer class which is masking based on the prefix and suffix

import java.io.IOException;

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;

public class SensitiveDataSerializer extends StdSerializer<String> {
    private static final long serialVersionUID = 1L;

    private int prefix = 0;
    private int suffix = 0;

    public SensitiveDataSerializer(int prefix, int suffix) {
        super(String.class);
        this.prefix = prefix;
        this.suffix = suffix;
    }

    @Override
    public void serialize(String value, JsonGenerator gen, SerializerProvider provider) throws IOException {
        // Masking data; for our example we are masking all characters'
        gen.writeString(getFormattedString(value));
    }

    private String getFormattedString(String value) {
        return value.substring(0, prefix)
                .concat(value.substring(prefix, value.length() - suffix).replaceAll(".", "*"))
                .concat(value.substring(value.length() - suffix));
    }
}

The Tag and the object mapper would look like this

private class Bean {

public void setId(String id) {
    this.id = id;
}

public void setEmailId(String emailId) {
    this.emailId = emailId;
}

String id;
@SensitiveData(prefix = 1, suffix = 4)
String emailId;

}

And a Test would be

 @Test
    public void beanMappingTest() throws Exception {

        Bean testBean = new Bean();
        testBean.setId("1234");
        testBean.setEmailId("test@test.com");

        ObjectMapper om = new ObjectMapper();
        AnnotationIntrospector is1 = AnnotationIntrospector.pair(
                om.getSerializationConfig().getAnnotationIntrospector(), new SensitiveDataAnnotationIntrospector());
        AnnotationIntrospector is2 = AnnotationIntrospector.pair(
                om.getDeserializationConfig().getAnnotationIntrospector(), new SensitiveDataAnnotationIntrospector());
        om.setAnnotationIntrospectors(is1, is2);
        String expectedJson = "{\"id\":\"1234\",\"emailId\":\"t********.com\"}";
        String maskedJson = om.writeValueAsString(testBean);
        assertEquals(maskedJson, expectedJson);
    }
Dharman
  • 30,962
  • 25
  • 85
  • 135