Home  >  Article  >  Technology peripherals  >  Spring Boot interface data encryption and decryption should be designed like this~

Spring Boot interface data encryption and decryption should be designed like this~

王林
王林forward
2023-05-13 10:58:05926browse

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

Spring Boot 接口数据加解密就该这样设计~

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

  • Minimize changes as much as possible. Affects the previous business logic;
  • 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 lower, so two sets of keys are allocated for H5 and Android and IOS;
  • must be compatible with the lower version of the interface, and the newly developed interfaces later do not need to be compatible;
  • The interface has GET and POST Both interfaces need to be encrypted and decrypted;

Requirement analysis:

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

Simply restore according to this requirement Question, 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;
}
}

Call: localhost:8080/user/list

The query results are as follows, nothing wrong:

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

Currently, ControllerAdvice is mainly used to intercept requests and response bodies. It mainly defines SecretRequestAdvice to encrypt the request and SecretResponseAdvice to encrypt the response (the actual situation is a little more complicated. There is also a GET type request in the project, and a Filter is customized for 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. The above code:

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

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){
// 判断是否需要加密
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:

请求方法:
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
}

OK, client requests encryption -> Initiate request -> Server decryption -> Business processing -> Server response encryption -> Client decryption display, it seems there is no problem, but in fact, I spent 2 hours on the request the afternoon before. It took almost 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 jointly debug tomorrow morning (no small things, everyone has discovered that there is nothing fishy by this time) , I was indeed negligent at the time and overturned...)

The next day, the Android side reported that there was a problem with your encryption and decryption. The decrypted data format is different from before. Take a closer look. , rub, there is something wrong with this userType and registerTime, and I started thinking: What could be the problem with this? After 1s, the initial positioning was 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 was a problem with the conversion of JSON.toJSONString(o). Then when converting JSON Are there advanced properties that can be configured to generate the desired serialization format? FastJson provides overloading 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). The other WriteEnumUsingToString is the 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用户"),
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 + '"' +
'}';
}
}

The converted data is of string type "{"code":"COMMON", "type":"Normal User"}". This method does not 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);
 换为:
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, which is comfortable , == It doesn’t seem right. Why 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 lossy modification, which is not advisable, so we went to the Jackson official website Search related 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": "普通用户"
},
"registerTime": "2022-03-29 22:57:33"
 }],
 "msg": "用户列表查询成功"
}

OK,和非加密版的终于一致了,完了吗?感觉还是可能存在些什么问题,首先业务代码的时间序列化需求不一样,有"yyyy-MM-dd hh:mm:ss"的,也有"yyyy-MM-dd"的,还可能其他配置思考不到位的,导致和之前非加密版返回数据不一致的问题,到时候联调测出来了也麻烦,有没有一劳永逸的办法呢?哎,这个时候如果你看过 Spring 源码的话,就应该知道spring框架自身是怎么序列化的,照着配置应该就行嘛,好像有点道理,我这里不从0开始分析源码了。

跟着执行链路,找到具体的响应序列化,重点就是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);
 }
}

进而通过实例化的AbstractJackson2HttpMessageConverter对象找到执行序列化的核心方法

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

那么,可以看出SpringMVC在进行响应序列化的时候是从容器中获取的ObjectMapper实例对象,并会根据不同的默认配置条件进行序列化,那处理方法就简单了,我也可以从Spring容器拿数据进行序列化啊。SecretResponseAdvice进行如下进一步改造:

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

经测试,响应数据和非加密版万全一致啦,还有GET部分的请求加密,以及后面加解密惨遭跨域问题,后面有空再和大家聊聊。

The above is the detailed content of Spring Boot interface data encryption and decryption should be designed like this~. For more information, please follow other related articles on the PHP Chinese website!

Statement:
This article is reproduced at:51cto.com. If there is any infringement, please contact admin@php.cn delete