问题背景
接口项目本身约定数据都是json
格式,使用@RequestBody
接收
@RestController
@RequestMapping("/app")
@Transactional(rollbackFor=Exception.class)
public class AppAction {
@PostMapping("/login")
public LoginResp login(@RequestBody @Valid LoginReq req)throws Exception{
throw new LogicException(Code.APP_VERSION_LOWER.getCode(),Code
.APP_VERSION_LOWER.getMessage());
}
}
但是请求安全需要,请求和响应数据加密解密处理,也就是在http
发送post
请求的时候,服务端收到的本身是密文,需要解密就才是 json
。
ShO7s5vmTis0Yp4OmnqHZL+pIP9YkHZ4hw/7XfSK2QAKIY0kWsGMlNG92RVQLf079/D7iFsCNf5yilklH+BhJQo6J7iuEaxvZFQfp+vPW3A=
因此使用RequestBodyAdvice
和ResponseBodyAdvice
里面做了解密 和 加密处理,
最近在接钉钉小程序出现了问题,因为钉钉在发送请求的是时候,会校验content-type
,因为本身发的密文,不是json 格式,所以content-type=application/json;
的时候,发送密文传是失败的,因此,只能按照content-type=application/x-www-form-urlencoded
发送,
报错如下:
org.springframework.web.HttpMediaTypeNotSupportedException: Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported
服务端肯定失败,因为只会处理 json
解决思路:
既然,发送的请求类型不对,那么我在服务端处理之前包装下requet,让它变成json格式,也就是问题变成,如果包装requst ,把content-type
变成application/json
-
拦截器
-
Aop
-
过滤器
经过测试,最后选择使用过滤器,用
HttpServletRequestWrapper
包装request
后往后传递处理。
新建过滤器
@Component
@WebFilter(urlPatterns = "/app/**")
public class MyRequestFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
ServletRequest wrapperRequest = null;
if (request instanceof HttpServletRequest) {
wrapperRequest = new MyRequestWrapper((HttpServletRequest) request);
}
chain.doFilter(wrapperRequest, response);
}
class MyRequestWrapper extends HttpServletRequestWrapper {
public MyRequestWrapper(HttpServletRequest request) {
super(request);
}
@Override
public Enumeration<String> getHeaders(String name) {
if (null != name && name.equals("content-type")) {
return new Enumeration<String>() {
private boolean hasGetted = false;
@Override
public String nextElement() {
if (hasGetted) {
throw new NoSuchElementException();
} else {
hasGetted = true;
return MediaType.APPLICATION_JSON_VALUE;
}
}
@Override
public boolean hasMoreElements() {
return !hasGetted;
}
};
}
return super.getHeaders(name);
}
}
}
调试发现,还是不行
调试分析
调用链
RequestMappingHandlerAdapter#invokeHandlerMethod
ServletInvocableHandlerMethod#invokeAndHandle
InvocableHandlerMethod#invokeForRequest
InvocableHandlerMethod#getMethodArgumentValues
HandlerMethodArgumentResolverComposite#resolveArgument
RequestResponseBodyMethodProcessor#resolveArgument
RequestResponseBodyMethodProcessor#readWithMessageConverters
AbstractMessageConverterMethodArgumentResolver#readWithMessageConverters
调试分析
@SuppressWarnings("unchecked")
@Nullable
protected <T> Object readWithMessageConverters(HttpInputMessage inputMessage, MethodParameter parameter,Type targetType)
throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
MediaType contentType;
boolean noContentType = false;
try {
// 这里的确是从 header 里面获取的,我们包装类在就是在这里修改了content-type
contentType = inputMessage.getHeaders().getContentType();
}
catch (InvalidMediaTypeException ex) {
throw new HttpMediaTypeNotSupportedException(ex.getMessage());
}
if (contentType == null) {
noContentType = true;
// 没有conent-type, 默认是 字节流处理 application/octet-stream
contentType = MediaType.APPLICATION_OCTET_STREAM;
}
......
HttpMethod httpMethod = (inputMessage instanceof HttpRequest ? ((HttpRequest) inputMessage).getMethod() : null);
Object body = NO_VALUE;
EmptyBodyCheckingHttpInputMessage message;
try {
// 这里是关键,待会分析
message = new EmptyBodyCheckingHttpInputMessage(inputMessage);
// 下面就是 各种 HttpMessageConverter 调用,什么json string 都是在这里处理
// json ---MappingJackson2HttpMessageConverter
// 自定义的 faston ---- FastonHttpMessageConverter
// string ----StringHttpMessageConverter
// form ----FormHttpMessageConverter
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter =
(converter instanceof GenericHttpMessageConverter ? (GenericHttpMessageConverter<?>) converter : null);
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null && converter.canRead(targetClass, contentType))) {
// 处理各种 RequestBodyAdvice
if (message.hasBody()) {
// 前置处理 beforeBodyRead
HttpInputMessage msgToUse =
getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
// 后置处理 afterBodyRead
body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
}
else {
body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
}
break;
}
}
}
catch (IOException ex) {
throw new HttpMessageNotReadableException("I/O error while reading input message", ex, inputMessage);
}
// body == null 经常报错 Request body is missing 就是在这里了
if (body == NO_VALUE) {
if (httpMethod == null || !SUPPORTED_METHODS.contains(httpMethod) ||
(noContentType && !message.hasBody())) {
return null;
}
// 开始的那个报错 Content type 'application/x-www-form-urlencoded;charset=UTF-8' not supported 就是这里抛出的
throw new HttpMediaTypeNotSupportedException(contentType, this.allSupportedMediaTypes);
}
// 以上步骤都对
MediaType selectedContentType = contentType;
Object theBody = body;
// 日志输出 请求封装结果
LogFormatUtils.traceDebug(logger, traceOn -> {
String formatted = LogFormatUtils.formatValue(theBody, !traceOn);
//Read "application/json;charset=UTF-8" to [LoginReq{userName='dura-sale-1', password='96e79218965eb72c92a549dd5a330112', lon=null, lat=null, ci (truncated)...]
return "Read \"" + selectedContentType + "\" to [" + formatted + "]";
});
return body;
}
继续分析EmptyBodyCheckingHttpInputMessage
// 把HttpInputMessage 包装为 EmptyBodyCheckingHttpInputMessage
message = new EmptyBodyCheckingHttpInputMessage(inputMessage);
所以,其实,各种请求的信息,还是从HttpInputMessage
里面获取的
查看构造函数
public EmptyBodyCheckingHttpInputMessage(HttpInputMessage inputMessage) throws IOException {
this.headers = inputMessage.getHeaders();
// 这里很关键,直接去获取请求的 inputStream了
InputStream inputStream = inputMessage.getBody();
......
}
继续查看getBody
//ServletServerHttpRequest#getBody
@Override
public InputStream getBody() throws IOException {
// 判断是不是 form 表单,
//也就是content-type=application/x-www-form-urlencoded;charset=UTF-8
if (isFormPost(this.servletRequest)) {
// 是的话,获取的 request.getParameterMaps()
return getBodyFromServletRequestParameters(this.servletRequest);
}
else {
// 不是的话,获取 request.getInputStream();
return this.servletRequest.getInputStream();
}
}
但是很坑的是 isFormPost
的判断
//ServletServerHttpRequest#isFormPost
private static boolean isFormPost(HttpServletRequest request) {
// 直接获取的是 request.getContentType(),而不是 request.getHeader("content-type")
String contentType = request.getContentType();
return (contentType != null && contentType.contains(FORM_CONTENT_TYPE) &&
HttpMethod.POST.matches(request.getMethod()));
}
这就很坑了啊,说明,我们在HttpServletRequestWrapper
修改的header
里面的conent-type
没起到作用啊 ,所以肯定走到逻辑request.getInputStream();
去处理了
这里的处理,后续也可以,但是仍然有个很坑的问题,参数处理中直接把特殊字符转义了,导致解密逻辑失败
比如密文是:
ShO7s5vmTis0Yp4OmnqHZL+pIP9YkHZ4hw/7XfSK2QAKIY0kWsGMlNG92RVQLf079/D7iFsCNf5yilklH+BhJQo6J7iuEaxvZFQfp+vPW3A=
//被转义的密文
ShO7s5vmTis0Yp4OmnqHZL+pIP9YkHZ4hw%2F7XfSK2QAKIY0kWsGMlNG92RVQLf079%2FD7iFsCNf5yilklH+BhJQo6J7iuEaxvZFQfp+vPW3A=
这还是只是/
被转义了,其他没有用到的,还不知道多少坑呢,所以换个角度处理
上面分析,既然判断逻辑是request.getContentType()
,所以,覆盖一下嘛,修改MyRequestWrapper
,添加如下:
public String getContentType() {
return MediaType.APPLICATION_JSON_UTF8_VALUE;
}
继续调试,报错如下:
org.springframework.http.converter.HttpMessageNotReadableException: Required request body is missing:
为什么呢,因为刚才判断isFormPost=false
,是从inputStream
里面获取输入流,所以,也要覆写一下,继续修改,添加如下:
@Override
public ServletInputStream getInputStream() throws IOException {
return getRequest().getInputStream();
}
但是,还是提示 Required request body is missing
,继续调试分析
奇怪,getBody()
返回的是inputStream
,但是上面已经添加了getInputStream()
,到底几个意思啊,也就是说,我们getRequest().getInputStream();
返回的输入流不对?
public EmptyBodyCheckingHttpInputMessage(HttpInputMessage inputMessage) throws IOException {
this.headers = inputMessage.getHeaders();
InputStream inputStream = inputMessage.getBody();
// 输入流,是否支持?
if (inputStream.markSupported()) {
inputStream.mark(1);
this.body = (inputStream.read() != -1 ? inputStream : null);
inputStream.reset();
}
else {
// 走到这个逻辑了
PushbackInputStream pushbackInputStream = new PushbackInputStream(inputStream);
int b = pushbackInputStream.read();
if (b == -1) {
// 所以 报错 Required request body is missing
this.body = null;
}
else {
this.body = pushbackInputStream;
pushbackInputStream.unread(b);
}
}
}
最开始的的cotent-type=application/x-www-form-urlencoded;charset=UTF-8
,属于表单提交数据,所以是用request.getParamMaps()
去获取参数处理,所以request.getInputStream.markSupported()=false
,而我们只是强制修改了content-type=application-json;
,可以去获取request.getInputStream()
,但是确是不支持的,也就是不可read
的
思考,在包装类MyRequestWrapper
里面,自己获取一下内容,然后转换为inputStream
返回,继续修改,完整代码如下:
class MyRequestWrapper extends HttpServletRequestWrapper {
private String body;
public MyRequestWrapper(HttpServletRequest request) {
super(request);
try {
String encrypt = IOUtils.toString(request.getInputStream());
log.debug("请求密文:" + encrypt);
this.body = encrypt;
} catch (IOException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
}
@Override
public Enumeration<String> getHeaders(String name) {
if (null != name && name.equals("content-type")) {
return new Enumeration<String>() {
private boolean hasGetted = false;
@Override
public String nextElement() {
if (hasGetted) {
throw new NoSuchElementException();
} else {
hasGetted = true;
return MediaType.APPLICATION_JSON_VALUE;
}
}
@Override
public boolean hasMoreElements() {
return !hasGetted;
}
};
}
return super.getHeaders(name);
}
public String getContentType() {
return MediaType.APPLICATION_JSON_UTF8_VALUE;
}
@Override
public ServletInputStream getInputStream() throws IOException {
InputStream inputStream = IOUtils.toInputStream(this.getBody());
ServletInputStream servletInputStream = new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read() throws IOException {
return inputStream.read();
}
};
return servletInputStream;
}
public String getBody() {
return this.body;
}
}
再次调试,果然可以了,
另外,虽然判断isFormPost
的逻辑是使用request.getContentType
来判断,但是判断支持的content-ype
类型,却的确是从header
里面获取判断的,略坑,所以我们最初包装类里面设置header
的代码还必须保留,不然报错