主要技术点: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的解密加密,所以想通过自定义注解来进行实现。
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 "";
}
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;
}
}
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;
}
想要解决这个问题你就得知道以下:
拿@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错误! **
validation就是将Hibernate包了一层,原理是通过public interface ConstraintValidator<A extends java. lang. annotation. Annotation, T>绑定注解来进行校验,如果isValid()方法返回false则将message加到MethodArgumentNotValidException异常类中,等到所有字段的错误都验证完后将这个异常抛出,这个异常里存储了很多信息,后面我们会用全局异常处理这个异常来获取字段校验的错误信息。
简单说就是Spring MVC 将json转成标记@RequestBody的实体类后,立即查看是否有@Valid,通过@Valid标记自动执行验证,如果不符合@Size、@Length等注解上的规则,则将错误信息一个一个放入MethodArgumentNotValidException异常中的BindingResult里,然后将此异常抛出去,达到暂停业务的目的。
首先得知道一点,在网络请求中
1.拦截器: 属于是Spring的东西,它主要通过匹配url路径来拦截请求,在此路径下的请求去,前后来进行操作。它可以在Spring MVC处理请求的所有生命周期进行操作,但是不能影响@RequestBoby绑定数据,他可以做一些前置后置的工作,我们可以在@RequestBody绑定java实体类之后,在@Vaild校验数据之前进行操作,这也是我们选它的原因。
2.过滤器: 属于是 Servlet 容器的东西,更加底层主要围绕doFilter()方法来操作的。过滤器在请求进来之前和逻辑处理结束之后进行操作,重点是@ReqeustBody绑定java实体类是在逻辑处理阶段,如果在请求给spring MVC之前将数据流消费掉了,那么在@RequestBody绑定实体类时,将没有数据流可消费,会报I/O异常,故不选择他。
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关键字判断能不能强转。
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;
}
}
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();
}
}
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);
}
}
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;
}
}
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
由此可以看出修改过的整个生命周期。