SpringBoot interface data encryption and decryption skills, so easy!

Today, I just finished reading the 22 code, and I was about to take out my mobile phone to fish and relax. I saw the boss walking towards me and showing a "goodwill" smile. Xingwei, the xx project has security issues, and needs to The interface is encrypted as a whole. You are more experienced in this area, so I will arrange it for you. Let’s see if the test is OK within this week..., uh, touching the dangling and sparse long hair on my head, I feel like I love it.

After discussing the external needs of products and front-end students, we sorted out the relevant technical solutions. The main requirements are as follows:

  • Minimize changes as much as possible without affecting the previous business logic;
  • Considering the urgency of time, a symmetric encryption method can be used. The service needs to be connected to Android, IOS, and H5 terminals. In addition, considering that the storage key security of the H5 terminal is relatively low, it is divided into H5, Android, and IOS distribution. two sets of keys;
  • To be compatible with low-version interfaces, new interfaces developed later do not need to be compatible;
  • There are two interfaces, GET and POST, which need to be encrypted and decrypted;

Requirements analysis:

  • The server, client and H5 intercept encryption and decryption in a unified manner. There are mature solutions on the Internet, and the encryption and decryption processes implemented in other services can also be implemented;
  • Use AES to relax encryption. Considering that the storage key security on the H5 side is relatively low, two sets of keys are allocated for H5, Android, and IOS;
  • This time involves the overall transformation of the client and server. After discussion, the new interface is uniformly prefixed with /secret/ to distinguish

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用户"),
    COMMON("普通用户");
    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("用户列表查询成功");
        return response;
    }
}

transfer:localhost:8080/user/list

The query results are as follows, no problem:

{
    
    
 "code": 200,
 "data": [{
    
    
  "id": 1,
  "name": "boyka",
  "userType": {
    
    
   "code": "COMMON",
   "type": "普通用户"
  },
  "registerTime": "2022-03-24 23:58:39"
 }],
 "msg": "用户列表查询成功"
}

At present, it is mainly used ControllerAdviceto intercept the request and response body, mainly defining SecretRequestAdvicethe encryption of the request and SecretResponseAdvicethe encryption of the response (the actual situation will be a little more complicated, and there are GET type requests in the project, and a Filter is customized for different request decryption processing ).

Well, ControllerAdvicethere are a lot of usage examples on the Internet. I will show you the two core methods. I believe that the big guys will understand it at a glance, so there is no need to say more. Above code:

SecretRequestAdviceRequest 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 {
    
    
        //如果支持加密消息,进行消息解密。
        String httpBody;
        if (Boolean.TRUE.equals(SecretFilter.secretThreadLocal.get())) {
    
    
            httpBody = decryptBody(inputMessage);
        } else {
    
    
            httpBody = StreamUtils.copyToString(inputMessage.getBody(), Charset.defaultCharset());
        }
        //返回处理后的消息体给messageConvert
        return new SecretHttpMessage(new ByteArrayInputStream(httpBody.getBytes()), inputMessage.getHeaders());
    }

    /**
     * 解密消息体
     *
     * @param inputMessage 消息体
     * @return 明文
     */
    private String decryptBody(HttpInputMessage inputMessage) throws IOException {
    
    
        InputStream encryptStream = inputMessage.getBody();
        String requestBody = StreamUtils.copyToString(encryptStream, Charset.defaultCharset());
        // 验签过程
        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, "请求解密参数错误,clientType、timestamp、salt、signature等参数传递是否正确传递");
        }

        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)) {
    
    
            // 验签失败
            throw new ResultException(SECRET_API_ERROR, "验签失败,请确认加密方式是否正确");
        }

        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, "解密失败");
    }
}

SecretResponseAdviceResponse 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) {
    
    
        // 判断是否需要加密
        Boolean respSecret = SecretFilter.secretThreadLocal.get();
        String secretKey = SecretFilter.clientPrivateKeyThreadLocal.get();
        // 清理本地缓存
        SecretFilter.secretThreadLocal.remove();
        SecretFilter.clientPrivateKeyThreadLocal.remove();
        if (null != respSecret && respSecret) {
    
    
            if (o instanceof ResponseBasic) {
    
    
                // 外层加密级异常
                if (SECRET_API_ERROR == ((ResponseBasic) o).getCode()) {
    
    
                    return SecretResponseBasic.fail(((ResponseBasic) o).getCode(), ((ResponseBasic) o).getData(), ((ResponseBasic) o).getMsg());
                }
                // 业务逻辑
                try {
    
    
                    String data = EncryptUtils.aesEncrypt(JSON.toJSONString(o), secretKey);
                    // 增加签名
                    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, "", "服务端处理结果数据异常");
                }
            }
        }
        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:

// 原始请求体
{
    
    
 "page": 1,
 "size": 10
}
// 加密后的请求体
{
    
    
 "data": "1ZBecdnDuMocxAiW9UtBrJzlvVbueP9K0MsIxQccmU3OPG92oRinVm0GxBwdlXXJ"
}

// 加密响应体:
{
    
    
    "data": "fxHYvnIE54eAXDbErdrDryEsIYNvsOOkyEKYB1iBcre/QU1wMowHE2BNX/je6OP3NlsCtAeDqcp7J1N332el8q2FokixLvdxAPyW5Un9JiT0LQ3MB8p+nN23pTSIvh9VS92lCA8KULWg2nViSFL5X1VwKrF0K/dcVVZnpw5h227UywP6ezSHjHdA+Q0eKZFGTEv3IzNXWqq/otx5fl1gKQ==",
    "code": 200,
    "signature": "aa61f19da0eb5d99f13c145a40a7746b",
    "msg": "",
    "timestamp": 1648480034,
    "salt": 632648
}

// 解密后的响应体:
{
    
    
 "code": 200,
 "data": [{
    
    
  "id": 1,
  "name": "boyka",
  "registerTime": "2022-03-27T00:19:43.699",
  "userType": "COMMON"
 }],
 "msg": "用户列表查询成功",
 "salt": 0
}"boyka",  "registerTime": "2022-03-27T00:19:43.699",  "userType": "COMMON" }], "msg": "用户列表查询成功", "salt": 0}

OK, 客户端请求加密->发起请求->服务端解密->业务处理->服务端响应加密->客户端解密展示, it seems that there is no problem. In fact, it took 2 hours to meet the requirements the afternoon before, and it took almost 1 hour to write the demo test, and then processed all the interfaces in a unified way. The whole afternoon should be enough, tell H5 Joint investigation with Android classmate Minger in the morning (everyone who is not young at this time found out that there is nothing tricky, and it was indeed negligent at the time, and the cart was overturned...)

The next day, the Android side reported that there is a problem with your encryption and decryption. The format of the decrypted data is different from before. After a closer look, the userType sum is wrong. I began to think: Where could this be the problem registerTime? After 1s, the preliminary positioning should be JSON.toJSONStringthe problem of the response body:

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

Debug breakpoint debugging, sure enough, there is JSON.toJSONString(o)a problem with the conversion in this step, is there an advanced property that can be configured to generate the desired serialization format during JSON conversion?

FastJson provides an overload method during serialization. Find one of the " SerializerFeature" parameters to ponder. This parameter can be configured for serialization. It provides many configuration types. Among them, I feel that these are relatively close:

WriteEnumUsingToString,
WriteEnumUsingName,
UseISO8601DateFormat

For the enumeration type, the default is to use WriteEnumUsingName(the name of the enumeration), and the other WriteEnumUsingToStringis the re-toString method, which can theoretically be converted into what you want, that is, this:

@Getter
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum UserType {
    
    
    VIP("VIP用户"),
    COMMON("普通用户");
    private String code;
    private String type;

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

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

As a result, the converted data is of the string type " {"code":"COMMON", "type":"普通用户"}". This method does not seem to work. What else is there to do?

After thinking about it, read the User and UserType classes defined at the beginning of the article, mark the data serialization format @JsonFormat, and suddenly think of some articles I have seen before. The bottom layer of SpringMVC is serialized by default Jackson. Well, let’s Jacksongimplement it SecretResponseAdvice. The serialization method replaces:

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

Replace it with:

String data =EncryptUtils.aesEncrypt(new ObjectMapper().writeValueAsString(o), secretKey);

Re-run a wave and start:

{
    
    
 "code": 200,
 "data": [{
    
    
  "id": 1,
  "name": "boyka",
  "userType": {
    
    
   "code": "COMMON",
   "type": "普通用户"
  },
  "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": "用户列表查询成功"
}

The decrypted userType enumeration type is the same as the non-encrypted version, comfortable, == seems not right, registerTimehow did it become like this? Originally in 2022-03-24 23:58:39the format of " ", Jackson之LocalDateTimeit is converted without changing the entity class. This article talks about this problem and proposes a solution.

However, for our current needs, it is a lossy modification, which is not advisable, so I went to the Jackson official website to find relevant documents. Of course, the serialization configuration Jacksonis also provided , and the configuration object ObjectMapperis re-initialized :ObjectMpper

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": "普通用户"
  },
  "registerTime": "2022-03-29 22:57:33"
 }],
 "msg": "用户列表查询成功"
}

OK, it is finally consistent with the non-encrypted version, is it over? I feel that there may still be some problems. First of all, the time serialization requirements of the business code are different. There are " " and yyyy-MM-dd hh:mm:ss" yyyy-MM-dd", and other configurations may not be well thought out, resulting in inconsistent data from the previous non-encrypted version. At that time, it will be troublesome if the joint commissioning test comes out. Is there a solution once and for all?

A colleague enlightened me with one sentence, let's see how the spring framework itself is serialized, it should be fine according to the configuration, it seems to make sense.

Follow the execution link to find the specific response serialization, the key point is RequestResponseBodyMethodProcessor,

protected <T> void writeWithMessageConverters(@Nullable T value, MethodParameter returnType, ServletServerHttpRequest inputMessage, ServletServerHttpResponse outputMessage) throws IOException, HttpMediaTypeNotAcceptableException, HttpMessageNotWritableException {
    
    
        // 获取响应的拦截器链并执行beforeBodyWrite方法,也就是执行了我们自定义的SecretResponseAdvice中的beforeBodyWrite啦
  body = this.getAdvice().beforeBodyWrite(body, returnType, selectedMediaType, converter.getClass(), inputMessage, outputMessage);
  if (body != null) {
    
    
      // 执行响应体序列化工作
   if (genericConverter != null) {
    
    
    genericConverter.write(body, (Type)targetType, selectedMediaType, outputMessage);
   } else {
    
    
    converter.write(body, selectedMediaType, outputMessage);
   }
    }

AbstractJackson2HttpMessageConverterThen find the core method to perform serialization through the instantiated 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();
     
    }
 -> 找到Jackson序列化 AbstractJackson2HttpMessageConverter:
 // 从spring容器中获取并设置的ObjectMapper实例
 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 && 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 && javaType.isContainerType()) {
    
    
   objectWriter = objectWriter.forType(javaType);
  }

  SerializationConfig config = objectWriter.getConfig();
  if (contentType != null && contentType.isCompatibleWith(MediaType.TEXT_EVENT_STREAM) && config.isEnabled(SerializationFeature.INDENT_OUTPUT)) {
    
    
   objectWriter = objectWriter.with(this.ssePrettyPrinter);
  }
        // 重点进行序列化
  objectWriter.writeValue(generator, value);
  this.writeSuffix(generator, object);
  generator.flush();
    }

Then, it can be seen that when SpringMVC serializes the response, it obtains the ObjectMapperinstance object from the container, and serializes it according to different default configuration conditions. The processing method is simple, and I can also get the data from the Spring container. Serialization. SecretResponseAdviceCarry out further modification 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 encryption and decryption suffered from cross-domain problems. I will talk to you later when I have time.

おすすめ

転載: blog.csdn.net/qq_43842093/article/details/131712880