validation实现自定义注解校验对称解密数据长度

主要技术点:spring-boot-starter-validation、HandlerInterceptor拦截器、ThreadLocal、WebMvcConfigurer、容器的执行顺序

一、因何而起

最近想要写个类似web版的小红书,设计完架构。前端需要校验数据防普通用户输错,后端需要校验防小人攻击(虽然不打算上线此项目),前端校验很简单,主要是后端遇到了一些问题,想知道解决方案的直接跳着看。

此项目后端想全面使用spring-boot-starter-validation来校验前端传来的数据,其中涉及到了登录接口,有以下需求:

  • http协议传输的过程中关键信息不能是明文,必须加密。

  • 还原后的关键信息,例如密码,必须是5-10位。

  • 使用spring-boot-starter-validation 中的@Valid来校验json。

  • 根据前端UserLoginDTO传过来的timeStamp来充当AES解密的key和iv(这俩必须是16字节),考虑使用MD5将经过多次加密截取前16位来充当。

    给后端传的值为:userName、password(经过加密后的密码)、timeStamp

    加密规则如下:

    key=MD5(timeStamp) .substring(0,16)

    iv=MD5(key).substring(0,16)

    后端拿到的值为 AES(password,key,iv) = password 解密后获取原文长度,不符合校验规则则返回错误信息

二、遇到的问题及解决思路

因为牵扯到AES解密,且spring-boot-starter-validation中没有注解支持AES的解密加密,所以想通过自定义注解来进行实现。

1.创建注解@DecryptRuleByLength

java
package com.likebocai.common.validation.constraints;

import com.likebocai.common.validation.DecryptLengthValidation;
import jakarta.validation.Constraint;
import jakarta.validation.Payload;

import java.lang.annotation.*;

@Target({ElementType.FIELD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Constraint(validatedBy = DecryptLengthValidation.class)
public @interface DecryptRuleByLength {

复制代码
String message() default "此数据输入格式有误";

Class<?>[] groups() default {};

Class<? extends Payload>[] payload() default {};

/**
 * @author: LikeBocai
 * @description: 最小长度
*/
int minLength() default 0;

/**
 * @author: LikeBocai
 * @description: 最大长度
*/
int maxLength() default 2147483647;

/**
 * @author: LikeBocai
 * @description: 根据该字段进行解密,该字段的值是变化的,但是注解上只能写静态值
*/
String fileName() default "";

}

2.编写校验规则DecryptLengthValidation.java

java
package com.likebocai.common.validation;

import com.likebocai.common.context.RequestContext;
import com.likebocai.common.validation.constraints.DecryptRuleByLength;
import jakarta.annotation.Resource;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

import java.util.Map;

/**

  • @program: likebocai-java

  • @description: 解密后的校验规则

  • @author: LikeBocai

  • @create: 2024/12/7 19:35
    **/
    @Slf4j
    public class DecryptLengthValidation implements ConstraintValidator<DecryptRuleByLength,String> {

    private int minLength;

    private int maxLength;

    private String fileName;

    @Override
    public void initialize(DecryptRuleByLength constraintAnnotation) {
    this.minLength = constraintAnnotation.minLength();
    this.maxLength = constraintAnnotation.maxLength();
    this.fileName = constraintAnnotation.fileName();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
    // TODO: 1.这里写将加密内容还原后,判断原内容的长短
    // TODO: 2.难点为 怎么获取到该类的时间戳来进行解密
    // 可以思考一下,在这里怎么才能拿到fileName来进行解密
    if (value == null || value.length() < minLength || value.length() > maxLength) {
    return false;
    }
    return true;
    }
    }

3.实体类UserLoginDTO.java

java
package com.likebocai.oauth.dto;

import com.likebocai.common.validation.constraints.DecryptRuleByLength;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;

/**

  • @program: likebocai-java

  • @description: 接收用户登录信息

  • @author: LikeBocai

  • @create: 2024/12/7 16:33
    **/
    @Schema(name = "UserLoginDTO", title = "用户登录信息", description = "接收用户从前端传来的用户密码等")
    @Data
    public class UserLoginDTO {

    @Schema(name = "userName", title = "用户名", description = "6-30位的用户名", minLength = 6, maxLength = 30)
    @NotBlank(message = "用户名不能为空")
    @Length(min = 6, max = 30, message = "用户名的长度需在6-30")
    private String userName;

    @Schema(name = "password", title = "密码", description = "密文密码")
    @NotBlank(message = "密码不能为空")
    // fileName 意思为根据该类中哪个字段来当盐值进行解密,我这里选了timeStamp
    @DecryptRuleByLength(minLength = 5, maxLength = 10, message = "密码的长度需在{minLength}-{maxLength}位", fileName = "timeStamp")
    private String password;

    @Schema(name = "timeStamp", title = "时间戳", description = "毫秒级")
    @NotNull(message = "时间戳不能为空")
    private Long timeStamp;

}

4.遇到的问题

  • 由于注解上只能写静态值,故无法在灵活的把fileName的值在DecryptLengthValidation.java中获取到来进行解密。
  • 有些人可能想了,获取可以通过上下文来获取该UserLoginDTO.java类中所有属性的值,抱歉ConstraintValidator接口并没有实现类记录注解下字段所在类的信息,所以说也不能通过反射来获取了。

三、前置知识

想要解决这个问题你就得知道以下:

1.Spring MVC是怎么把json转成Java对象

拿@ReqeustBody举例

java
public Result<UserLoginVO> login(@RequestBody @Valid UserLoginDTO userLoginDTO)

1.Spring MVC将 JSON 转换为 Java 对象的过程主要依赖于 HttpMessageConverter

2.通过HttpMessageConverter下的MappingJackson2HttpMessageConverter 处理 JSON 和 Java 对象转换的默认转换。

3.MappingJackson2HttpMessageConverter 通过 Jackson 库实现,Jackson 会根据目标 Java 类的属性来匹配并填充 JSON 字段。

4.Spring MVC 会根据请求的 Content-Type 来选择合适的消息转换器,并自动将请求体中的 JSON 数据映射为 Java 对象。

5.将对象绑定到@RequestBody标记的实体类中(或者说Jackson根据此类型进行转换)

**特别注意:此处Spring MVC 将请求数据的流消费掉,也就是说后面再通过HttpServletRequest获取请求体是不存在的(请求的基本信息当然是存在的,因为Spring把他们抱起来封装到其他类了),会报I/O错误! **

2.validation校验数据的原理是什么

validation就是将Hibernate包了一层,原理是通过public interface ConstraintValidator<A extends java. lang. annotation. Annotation, T>绑定注解来进行校验,如果isValid()方法返回false则将message加到MethodArgumentNotValidException异常类中,等到所有字段的错误都验证完后将这个异常抛出,这个异常里存储了很多信息,后面我们会用全局异常处理这个异常来获取字段校验的错误信息。

3.validation从请求打进来到返回错误信息的过程是什么

简单说就是Spring MVC 将json转成标记@RequestBody的实体类后,立即查看是否有@Valid,通过@Valid标记自动执行验证,如果不符合@Size、@Length等注解上的规则,则将错误信息一个一个放入MethodArgumentNotValidException异常中的BindingResult里,然后将此异常抛出去,达到暂停业务的目的。

4.拦截器在什么地方执行,以及为什么不适用过滤器进行操作。

首先得知道一点,在网络请求中

1.拦截器: 属于是Spring的东西,它主要通过匹配url路径来拦截请求,在此路径下的请求去,前后来进行操作。它可以在Spring MVC处理请求的所有生命周期进行操作,但是不能影响@RequestBoby绑定数据,他可以做一些前置后置的工作,我们可以在@RequestBody绑定java实体类之后,在@Vaild校验数据之前进行操作,这也是我们选它的原因。

2.过滤器: 属于是 Servlet 容器的东西,更加底层主要围绕doFilter()方法来操作的。过滤器在请求进来之前和逻辑处理结束之后进行操作,重点是@ReqeustBody绑定java实体类是在逻辑处理阶段,如果在请求给spring MVC之前将数据流消费掉了,那么在@RequestBody绑定实体类时,将没有数据流可消费,会报I/O异常,故不选择他。

四、解决步骤

1.解决思路

  • 1.在@RequestBoby绑定Java实体类后(MappingJackson2HttpMessageConverter),@Vaild校验数据之前进行操作。
  • 2.将绑定好的所有实体类信息放入ThreadLoacl即此线程的上下文中。
  • 3.在DecryptLengthValidation中重写isValid()方法,根据fileName的信息来从此线程中的上下文中取,解密后根据原文的长度再配合注解上的minLength和maxLength来判断是否符合规则。
  • 其实主要就是重写 MappingJackson2HttpMessageConverter的read()方法,消费完数据流生成的对象后,返回给接口前加一步将数据插入上下文而已。
  • 再加一步使用拦截器将上下文的TheadLocal销毁,避免内存泄露

2.创建上下文RequestContext

java
package com.likebocai.common.context;

public class RequestContext {

复制代码
private static final ThreadLocal<Object> requestBody = new ThreadLocal<>();

public static void setRequestBody(Object body) {
    requestBody.set(body);
}

public static Object getRequestBody() {
    return requestBody.get();
}

public static void clear() {
    requestBody.remove();
}

}

解耦并提升可用性,泛型写顶级父类,到时候谁用了谁自己用根据放入的信息用instanceof关键字判断能不能强转。

3.重写MappingJackson2HttpMessageConverter

java
package com.likebocai.common.context;

import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpInputMessage;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.lang.reflect.Type;

@Component
@Slf4j
/**

  • @author: LikeBocai

  • @description: 用于重写read方法,获取对象后将它存到上下文中
    **/
    public class RequestBodyHttpMessageConverter extends MappingJackson2HttpMessageConverter {

    @Override
    public Object read(Type type, Class<?> contextClass, HttpInputMessage inputMessage) throws IOException, HttpMessageNotReadableException {
    // 调用父类方法进行常规的 JSON -> Java 对象转换
    Object result = super.read(type, contextClass, inputMessage);
    log.info("RequestBodyHttpMessageConverter -> read -> 解析json转为对象");

    复制代码
     // 无侵入式的将转换后的对象存储到 ThreadLocal 中
     if (result != null) {
         // 将转换后的对象存入 ThreadLocal
         RequestContext.setRequestBody(result);
         log.info("RequestBodyHttpMessageConverter 将转换后的对象存入 ThreadLocal");
     }
     
     // 不要改变对象,返回对象给下文
     return result;

    }
    }

4.拦截器RequestBodyInterceptor销毁上下文

java
package com.likebocai.common.context;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

@Component
@Slf4j
public class RequestBodyInterceptor implements HandlerInterceptor {

复制代码
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    // 这里肯定没上下文,打个日志给你看看
    log.info("RequestBodyInterceptor -> preHandle");
    Object threadLocalObject = RequestContext.getRequestBody();
    if (threadLocalObject != null) {
        log.info("你要是看到我,那就出大问题了" + threadLocalObject);
    }
    return true;
}

@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
    log.info("RequestBodyInterceptor -> postHandle -> clear");
    // 请求处理完后清理 ThreadLocal
    RequestContext.clear();
}

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
    log.info("RequestBodyInterceptor -> afterCompletion -> clear");
    // 请求完成后清理 ThreadLocal
    RequestContext.clear();
}

}

5.启用两个组件到WebMvcConfigurer中

java
package com.likebocai.common.context;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import java.util.List;

@Configuration
public class WebConfig implements WebMvcConfigurer {

复制代码
@Autowired
private RequestBodyHttpMessageConverter requestBodyHttpMessageConverter;
@Autowired
private RequestBodyInterceptor requestBodyInterceptor;

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(requestBodyInterceptor).addPathPatterns("/**");
}

@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    // 将自定义的消息转换器添加到 Spring MVC 的消息转换器列表中
    converters.add(requestBodyHttpMessageConverter);
}

}

6.修改DecryptLengthValidation

java
package com.likebocai.common.validation;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.likebocai.common.context.RequestContext;
import com.likebocai.common.validation.constraints.DecryptRuleByLength;
import jakarta.annotation.Resource;
import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;
import lombok.extern.slf4j.Slf4j;

import java.util.Map;

/**

  • @program: likebocai-java

  • @description: 解密后的校验规则

  • @author: LikeBocai

  • @create: 2024/12/7 19:35
    **/
    @Slf4j
    public class DecryptLengthValidation implements ConstraintValidator<DecryptRuleByLength,String> {

    private int minLength;

    private int maxLength;

    private String fileName;

    @Resource
    private ObjectMapper objectMapper;

    @Override
    public void initialize(DecryptRuleByLength constraintAnnotation) {
    this.minLength = constraintAnnotation.minLength();
    this.maxLength = constraintAnnotation.maxLength();
    this.fileName = constraintAnnotation.fileName();
    }

    @Override
    public boolean isValid(String value, ConstraintValidatorContext context) {
    // TODO: 1.这里写将加密内容还原后,判断原内容的长短
    // TODO: 2.难点为 怎么获取到该类的时间戳来进行解密
    // 这里写你自己解密的逻辑
    Object requestBody = RequestContext.getRequestBody();
    Map<String,Object> fileNameMap = objectMapper.convertValue(requestBody,Map.class);
    String fileValue = fileNameMap.get(fileName).toString();
    log.info("DecryptLengthValidation -> isValid -> 拿requestBody: {}", fileNameMap);
    log.info("fileName -> {}", fileName);
    log.info("fileName's value -> {}", fileValue);
    boolean contains = fileValue.contains("3");
    if (!contains) {
    return false;
    }
    if (value == null || value.length() < minLength || value.length() > maxLength) {
    return false;
    }
    return true;
    }
    }

7.执行过程日志

INFO 25408 --- [likebocai-oauth] [io-16281-exec-6] c.l.c.context.RequestBodyInterceptor : RequestBodyInterceptor -> preHandle
INFO 25408 --- [likebocai-oauth] [io-16281-exec-6] c.l.c.c.RequestBodyHttpMessageConverter : RequestBodyHttpMessageConverter -> read -> 解析json转为对象
INFO 25408 --- [likebocai-oauth] [io-16281-exec-6] c.l.c.c.RequestBodyHttpMessageConverter : RequestBodyHttpMessageConverter 将转换后的对象存入 ThreadLocal
INFO 25408 --- [likebocai-oauth] [io-16281-exec-6] c.l.c.v.DecryptLengthValidation : DecryptLengthValidation -> isValid -> 拿requestBody: {userName=zxcASDA, password=, timeStamp=0}
INFO 25408 --- [likebocai-oauth] [io-16281-exec-6] c.l.c.v.DecryptLengthValidation : fileName -> timeStamp
INFO 25408 --- [likebocai-oauth] [io-16281-exec-6] c.l.c.v.DecryptLengthValidation : fileName's value -> 0
INFO 25408 --- [likebocai-oauth] [io-16281-exec-6] c.l.c.context.RequestBodyInterceptor : RequestBodyInterceptor -> afterCompletion -> clear

由此可以看出修改过的整个生命周期。

站点概览
菠菜的小窝

上帝不让狗狗讲话是为了让人们知道,
爱与忠诚是要靠行动表达的。

  • 8
    文章
  • 3
    分类
  • 8
    标签
  • 2595
    总浏览