Spring Boot interface data encryption and decryption, so easy!

Bucai Chen Coder Technology Column 2023-11-09 09:01 Published in Zhejiang

Included in the collection #Spring Boot Advanced 99

Hello everyone, I am Bucai Chen~

Today’s article talks about interface security issues, involving interface encryption and decryption.

Picture

After discussing external demands with students on products and front-end, we sorted out relevant technical solutions. The main demand points are as follows:

  1. Make as few changes as possible without affecting the previous business logic;

  2. Considering the urgency of time, symmetric encryption can be used. The service needs to be connected to Android, IOS, and H5. In addition, considering that the security of the storage key on the H5 side is relatively low, it is distributed separately for H5, Android, and IOS. Two sets of keys;

  3. To be compatible with lower version interfaces, newly developed interfaces do not need to be compatible;

  4. There are two interfaces: GET and POST, both of which need to be encrypted and decrypted;

Requirements analysis:

  1. The server, client and H5 uniformly intercept encryption and decryption. There are mature solutions online, or you can follow the encryption and decryption processes implemented in other services;

  2. Use AES to relax encryption. Considering that the security of H5-side storage keys is relatively low, two sets of keys are allocated for H5 and Android and IOS;

  3. This time involves the overall transformation of the client and server. After discussion, the new interfaces are unified with the /secret/ prefix to distinguish them.

To simply restore the problem according to this requirement, define two objects, which will be used later.

User class:

@Data
public class User {
    private Integer id;
    private String name;
    private UserType userType = UserType.COMMON;
    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
    private LocalDateTime registerTime;
}

User type enumeration class:

@Getter
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum UserType {
    VIP("VIP user"),
    COMMON("common user");
    private String code;
    private String type;

    UserType(String type) {
        this.code = name();
        this.type = type;
    }
}

Construct a simple user list query example:

@RestController
@RequestMapping(value = {"/user", "/secret/user"})
public class UserController {
    @RequestMapping("/list")
    ResponseEntity<List<User>> listUser() {
        List<User> users = new ArrayList<>();
        User u = new User();
        u.setId(1);
        u.setName("boyka");
        u.setRegisterTime(LocalDateTime.now());
        u.setUserType(UserType.COMMON);
        users.add(u);
        ResponseEntity<List<User>> response = new ResponseEntity<>();
        response.setCode(200);
        response.setData(users);
        response.setMsg("User list query successful");
        return response;
    }
}

Call: localhost:8080/user/list

The query results are as follows, nothing wrong:

{
 "code": 200,
 "data": [{
  "id": 1,
  "name": "boyka",
  "userType": {
   "code": "COMMON",
   "type": "ordinary user"
  },
  "registerTime": "2022-03-24 23:58:39"
 }],
 "msg": "User list query successful"
}

At present, ControllerAdvice is mainly used to intercept requests and response bodies. It mainly defines SecretRequestAdvice to encrypt requests and SecretResponseAdvice to encrypt responses (the actual situation is a little more complicated. There are GET type requests in the project, and a Filter is customized to perform different Request decryption processing).

Okay, there are a lot of ControllerAdvice usage examples on the Internet. I will show you the two core methods. I believe the big guys will know it at a glance. No need to say more. Above code:

SecretRequestAdvice requests decryption:

@ControllerAdvice
@Order(Ordered.HIGHEST_PRECEDENCE)
@Slf4j
public class SecretRequestAdvice extends RequestBodyAdviceAdapter {
    @Override
    public boolean supports(MethodParameter methodParameter, Type type, Class<? extends HttpMessageConverter<?>> aClass) {
        return true;
    }

    @Override
    public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, Class<? extends HttpMessageConverter<?>> converterType) throws IOException {
        //If encrypted messages are supported, decrypt the messages.
        String httpBody;
        if (Boolean.TRUE.equals(SecretFilter.secretThreadLocal.get())) {
            httpBody = decryptBody(inputMessage);
        } else {
            httpBody = StreamUtils.copyToString(inputMessage.getBody(), Charset.defaultCharset());
        }
        //Return the processed message body to messageConvert
        return new SecretHttpMessage(new ByteArrayInputStream(httpBody.getBytes()), inputMessage.getHeaders());
    }

    /**
     * Decrypt message body
     *
     * @param inputMessage message body
     * @return plain text
     */
    private String decryptBody(HttpInputMessage inputMessage) throws IOException {
        InputStream encryptStream = inputMessage.getBody();
        String requestBody = StreamUtils.copyToString(encryptStream, Charset.defaultCharset());
        //signature verification process
        HttpHeaders headers = inputMessage.getHeaders();
        if (CollectionUtils.isEmpty(headers.get("clientType"))
                || CollectionUtils.isEmpty(headers.get("timestamp"))
                || CollectionUtils.isEmpty(headers.get("salt"))
                || CollectionUtils.isEmpty(headers.get("signature"))) {
            throw new ResultException(SECRET_API_ERROR, "The request for decryption parameters is wrong, whether the clientType, timestamp, salt, signature and other parameters are passed correctly");
        }

        String timestamp = String.valueOf(Objects.requireNonNull(headers.get("timestamp")).get(0));
        String salt = String.valueOf(Objects.requireNonNull(headers.get("salt")).get(0));
        String signature = String.valueOf(Objects.requireNonNull(headers.get("signature")).get(0));
        String privateKey = SecretFilter.clientPrivateKeyThreadLocal.get();
        ReqSecret reqSecret = JSON.parseObject(requestBody, ReqSecret.class);
        String data = reqSecret.getData();
        String newSignature = "";
        if (!StringUtils.isEmpty(privateKey)) {
            newSignature = Md5Utils.genSignature(timestamp + salt + data + privateKey);
        }
        if (!newSignature.equals(signature)) {
            //Signature verification failed
            throw new ResultException(SECRET_API_ERROR, "Signature verification failed, please confirm whether the encryption method is correct");
        }

        try {
            String decrypt = EncryptUtils.aesDecrypt(data, privateKey);
            if (StringUtils.isEmpty(decrypt)) {
                decrypt = "{}";
            }
            return decrypt;
        } catch (Exception e) {
            log.error("error: ", e);
        }
        throw new ResultException(SECRET_API_ERROR, "Decryption failed");
    }
}

SecretResponseAdvice response encryption:

@ControllerAdvice
public class SecretResponseAdvice implements ResponseBodyAdvice {
    private Logger logger = LoggerFactory.getLogger(SecretResponseAdvice.class);

    @Override
    public boolean supports(MethodParameter methodParameter, Class aClass) {
        return true;
    }

    @Override
    public Object beforeBodyWrite(Object o, MethodParameter methodParameter, MediaType mediaType, Class aClass, ServerHttpRequest serverHttpRequest, ServerHttpResponse serverHttpResponse) {
        // Determine whether encryption is required
        Boolean respSecret = SecretFilter.secretThreadLocal.get();
        String secretKey = SecretFilter.clientPrivateKeyThreadLocal.get();
        // Clear local cache
        SecretFilter.secretThreadLocal.remove();
        SecretFilter.clientPrivateKeyThreadLocal.remove();
        if (null != respSecret & amp; & amp; respSecret) {
            if (o instanceof ResponseBasic) {
                // Outer encryption level exception
                if (SECRET_API_ERROR == ((ResponseBasic) o).getCode()) {
                    return SecretResponseBasic.fail(((ResponseBasic) o).getCode(), ((ResponseBasic) o).getData(), ((ResponseBasic) o).getMsg());
                }
                // Business logic
                try {
                    String data = EncryptUtils.aesEncrypt(JSON.toJSONString(o), secretKey);
                    //Add signature
                    long timestamp = System.currentTimeMillis() / 1000;
                    int salt = EncryptUtils.genSalt();
                    String dataNew = timestamp + "" + salt + "" + data + secretKey;
                    String newSignature = Md5Utils.genSignature(dataNew);
                    return SecretResponseBasic.success(data, timestamp, salt, newSignature);
                } catch (Exception e) {
                    logger.error("beforeBodyWrite error:", e);
                    return SecretResponseBasic.fail(SECRET_API_ERROR, "", "Server-side processing result data exception");
                }
            }
        }
        return o;
    }
}

OK, the code demo is ready, let’s try it out:

Request method:
localhost:8080/secret/user/list

header:
Content-Type:application/json
signature:55efb04a83ca083dd1e6003cde127c45
timestamp:1648308048
salt:123456
clientType:ANDORID

body:
//Original request body
{
 "page": 1,
 "size": 10
}
//Encrypted request body
{
 "data": "1ZBecdnDuMocxAiW9UtBrJzlvVbueP9K0MsIxQccmU3OPG92oRinVm0GxBwdlXXJ"
}

// Encrypted response body:
{
    "data": "fxHYvnIE54eAXDbErdrDryEsIYNvsOOkyEKYB1iBcre/QU1wMowHE2BNX/je6OP3NlsCtAeDqcp7J1N332el8q2FokixLvdxAPyW5Un9JiT0LQ3MB8p + nN23pTSIvh9VS92lCA8KULWg2nVi SFL5X1VwKrF0K/dcVVZnpw5h227UywP6ezSHjHdA + Q0eKZFGTEv3IzNXWqq/otx5fl1gKQ==",
    "code": 200,
    "signature": "aa61f19da0eb5d99f13c145a40a7746b",
    "msg": "",
    "timestamp": 1648480034,
    "salt": 632648
}

//Decrypted response body:
{
 "code": 200,
 "data": [{
  "id": 1,
  "name": "boyka",
  "registerTime": "2022-03-27T00:19:43.699",
  "userType": "COMMON"
 }],
 "msg": "User list query successful",
 "salt": 0
}

OK, client requests encryption -> Initiate request -> Server decryption -> Business processing -> Server response encryption -> Client decryption display, it seems that there is no problem, but in fact, I spent 2 hours meeting the requirements the afternoon before, almost It took me an hour to write the demo test, and then processed all the interfaces in a unified manner. It should be enough to finish the whole afternoon, and tell the H5 and Android students to do joint debugging tomorrow morning (you will find out if there is anything fishy by this time. I was indeed negligent at that time and overturned the car…)

The next day, the Android side reported that there was a problem with your encryption and decryption. The decrypted data format was different from before. After a closer look, I realized that there was something wrong with the userType and registerTime. I started thinking: What could be the problem? After 1s, the initial positioning is that it should be a problem with JSON.toJSONString in the response body:

String data = EncryptUtils.aesEncrypt(JSON.toJSONString(o)),

Debug breakpoint debugging, sure enough, there is a problem with the conversion step of JSON.toJSONString(o). Is there any advanced attribute during JSON conversion that can be configured to generate the desired serialization format? FastJson provides overloaded methods during serialization. Find one of the “SerializerFeature” parameters and think about it. This parameter can be configured for serialization. It provides many configuration types, among which I feel that these are relatively relevant:

WriteEnumUsingToString,
WriteEnumUsingName,
UseISO8601DateFormat

For enumeration types, the default is to use WriteEnumUsingName (the name of the enumeration). Another type of WriteEnumUsingToString is a re-toString method, which can theoretically be converted into what you want, that is, like this:

@Getter
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum UserType {
    VIP("VIP user"),
    COMMON("common user");
    private String code;
    private String type;

    UserType(String type) {
        this.code = name();
        this.type = type;
    }

    @Override
    public String toString() {
        return "{" +
                ""code":"" + name() + '"' +
                ", "type":"" + type + '"' +
                '}';
    }
}

The converted data is of string type “{“code”:”COMMON”, “type”:”ordinary user”}”. This method doesn’t seem to work. Is there any other good way? After thinking about it, I looked at the User and UserType classes defined at the beginning of the article and marked the data serialization format @JsonFormat. Then I suddenly remembered some articles I had seen before. The bottom layer of SpringMVC uses Jackson for serialization by default. Well, just use Jackson to implement it. Well, replace the serialization method in SecretResponseAdvice:

String data = EncryptUtils.aesEncrypt(JSON.toJSONString(o), secretKey);
 Replace with:
String data =EncryptUtils.aesEncrypt(new ObjectMapper().writeValueAsString(o), secretKey);

Run the wave again and start:

{
 "code": 200,
 "data": [{
  "id": 1,
  "name": "boyka",
  "userType": {
   "code": "COMMON",
   "type": "ordinary user"
  },
  "registerTime": {
   "month": "MARCH",
   "year": 2022,
   "dayOfMonth": 29,
   "dayOfWeek": "TUESDAY",
   "dayOfYear": 88,
   "monthValue": 3,
   "hour": 22,
   "minute": 30,
   "nano": 453000000,
   "second": 36,
   "chronology": {
    "id": "ISO",
    "calendarType": "iso8601"
   }
  }
 }],
 "msg": "User list query successful"
}

The decrypted userType enumeration type is the same as the non-encrypted version, which is comfortable. == seems to be wrong. How did registerTime become like this? It was originally in the format of “2022-03-24 23:58:39”. There are many solutions on the Internet, but when used in our current needs, it is a lossy modification, which is not advisable, so we went to the Jackson official website Search the relevant documents. Of course, Jackson also provides serialization configuration of ObjectMapper. Re-initialize and configure the ObjectMpper object:

String DATE_TIME_FORMATTER = "yyyy-MM-dd HH:mm:ss";
ObjectMapper objectMapper = new Jackson2ObjectMapperBuilder()
                            .findModulesViaServiceLoader(true)
                            .serializerByType(LocalDateTime.class, new LocalDateTimeSerializer(
                                    DateTimeFormatter.ofPattern(DATE_TIME_FORMATTER)))
                            .deserializerByType(LocalDateTime.class, new LocalDateTimeDeserializer(
                                    DateTimeFormatter.ofPattern(DATE_TIME_FORMATTER)))
                            .build();

Conversion result:

{
 "code": 200,
 "data": [{
  "id": 1,
  "name": "boyka",
  "userType": {
   "code": "COMMON",
   "type": "ordinary user"
  },
  "registerTime": "2022-03-29 22:57:33"
 }],
 "msg": "User list query successful"
}

OK, it’s finally consistent with the non-encrypted version. Is it over? I still feel that there may be some problems. First of all, the time serialization requirements of business codes are different. Some are “yyyy-MM-dd hh:mm:ss” and some are “yyyy-MM-dd”. There may be other configurations that cannot be considered. If it is in place, it will lead to the problem of inconsistency with the data returned by the previous non-encrypted version. It will be troublesome when joint debugging is done. Is there any way to solve it once and for all? Hey, if you have read the Spring source code at this time, you should know how the spring framework itself is serialized. You should just follow the configuration. It seems to make sense. I will not analyze the source code from 0 here.

Follow the execution link and find the specific response serialization. The focus is RequestResponseBodyMethodProcessor.

protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType, ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage) throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
        // Get the response interceptor chain and execute the beforeBodyWrite method, which is to execute beforeBodyWrite in our customized SecretResponseAdvice.
  body = this.getAdvice().beforeBodyWrite(body, returnType, selectedMediaType, converter.getClass(), inputMessage, outputMessage);
  if (body != null) {
      //Perform response body serialization work
   if (genericConverter != null) {
    genericConverter.write(body, (Type)targetType, selectedMediaType, outputMessage);
   } else {
    converter.write(body, selectedMediaType, outputMessage);
   }
    }

Then find the core method to perform serialization through the instantiated AbstractJackson2HttpMessageConverter object

-> AbstractGenericHttpMessageConverter:
 
 public final void write(T t, @Nullable Type type, @Nullable MediaType contentType, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
        ...
  this.writeInternal(t, type, outputMessage);
  outputMessage.getBody().flush();
     
    }
 -> Find Jackson serialization AbstractJackson2HttpMessageConverter:
 //The ObjectMapper instance obtained and set from the spring container
 protected ObjectMapper objectMapper;
 
 protected void writeInternal(Object object, @Nullable Type type, HttpOutputMessage outputMessage) throws IOException, HttpMessageNotWritableException {
        MediaType contentType = outputMessage.getHeaders().getContentType();
        JsonEncoding encoding = this.getJsonEncoding(contentType);
        JsonGenerator generator = this.objectMapper.getFactory().createGenerator(outputMessage.getBody(), encoding);

  this.writePrefix(generator, object);
  Object value = object;
  Class<?> serializationView = null;
  FilterProvider filters = null;
  JavaType javaType = null;
  if (object instanceof MappingJacksonValue) {
   MappingJacksonValue container = (MappingJacksonValue)object;
   value = container.getValue();
   serializationView = container.getSerializationView();
   filters = container.getFilters();
  }

  if (type != null & amp; & amp; TypeUtils.isAssignable(type, value.getClass())) {
   javaType = this.getJavaType(type, (Class)null);
  }

  ObjectWriter objectWriter = serializationView != null ? this.objectMapper.writerWithView(serializationView) : this.objectMapper.writer();
  if (filters != null) {
   objectWriter = objectWriter.with(filters);
  }

  if (javaType != null & amp; & amp; javaType.isContainerType()) {
   objectWriter = objectWriter.forType(javaType);
  }

  SerializationConfig config = objectWriter.getConfig();
  if (contentType != null & amp; & amp; contentType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM) & amp; & amp; config.isEnabled(SerializationFeature.INDENT_OUTPUT)) {
   objectWriter = objectWriter.with(this.ssePrettyPrinter);
  }
        // Focus on serialization
  objectWriter.writeValue(generator, value);
  this.writeSuffix(generator, object);
  generator.flush();
    }

Then, it can be seen that SpringMVC obtains the ObjectMapper instance object from the container when serializing the response, and serializes it according to different default configuration conditions. Then the processing method is simple. I can also get data from the Spring container. Perform serialization. SecretResponseAdvice is further modified as follows:

@ControllerAdvice
public class SecretResponseAdvice implements ResponseBodyAdvice {

    @Autowired
    private ObjectMapper objectMapper;
     
      @Override
    public Object beforeBodyWrite(....) {
        .....
        String dataStr =objectMapper.writeValueAsString(o);
        String data = EncryptUtils.aesEncrypt(dataStr, secretKey);
        .....
    }
 }

After testing, the response data is completely consistent with the non-encrypted version. There is also the encryption of the GET part of the request, and the subsequent cross-domain problems with encryption and decryption. I will talk to you later when I have time.

One final word (don’t use whoring for nothing, please pay attention)

Every article written by Chen is carefully written. If this article is helpful or inspiring to you, please like, read, repost, and collect it. Your support is my biggest motivation to persevere!