api接口使用MD5加密加盐签名校验
最近一个A系统需要向B系统推送数据,因为数据每天不一定有多少,有时候多有时候少,且由UGC生成,需要B系统做一些处理,用mq比较麻烦,且公司用的付费rocketmq。除了重要数据一般不使用mq同步数据,所以该用接口调用的方式,A系统需要向B系统推送数据,所以需要B提供接口,A直接将数据通过接口的方式推过去,因为对外的接口所以需要对api接口的参数进行签名校验,防止篡改请求于攻击。
这里使用最简单的md5加密,稍微复杂一点的可以使用RSA通过公钥私钥来进行签名,再保险一点可以使用每个用户的公钥私钥加时间戳等等来达到安全保证
MD5加密算法
MD5信息摘要算法(MD5 Message-Digest Algorithm)种被广泛使用的密码散列函数,可以产生出一个128位(16字节)的散列值(hash value)用来确保信息传输完整一致性。
特点是非对称加密,不可逆,知道密文,无法知道原文,
长度固定,任意长度的数据,算出来的md5都是长度固定的
细微性,相同值的md5值一样相同,对原文进行任何改动,md5会有很大的变化
但是像简单的一些值md5可以被解密出来,我们使用接口签名时主要防止恶意请求,如果别人知道了接口的数据,以及请求参数的格式,用md5算法一加密,就可以轻松的请求成功,所以我们固定盐值,盐值只有服务提供方和接收方知道,一定程度上可以保证接口的安全性
这里因为是单个微服务提供接口,不是全局的接口使用,所以不需要网关签名,直接在项目里添加过滤器进行拦截做签名处理
主要原理为,与请求的客户端约定签名格式,在讲固定格式的参数值加固定盐值进行md5加密,将参数值与md5值一直传递;服务端接受到请求同样也会对请求的参数根据约定的格式进行md5签名;最后将服务端签名后的值与客户端传过来的签名值进行校验,如果相同,则通过,如果签名值不一样,则校验失败返回请求失败标识;
拦截器
这里通过WebMvcConfiger来注册进去一个签名拦截器
addInterceptor:需要一个实现HandlerInterceptor接口的拦截器实例
addPathPatterns:用于设置拦截器的过滤路径规则;addPathPatterns("/**")对所有请求都拦截
excludePathPatterns:用于设置不需要拦截的过滤规则
所以这里我们先写一个拦截器
@Slf4j
public class PhpSignatureInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
HandlerMethod handlerMethod = (HandlerMethod) handler;
Signature signatureAnnotation = handlerMethod.getMethodAnnotation(Signature.class);
if (null == signatureAnnotation) {
return true;
}
String sign = request.getHeader("sign");
if (StrUtil.isBlank(sign)) {
log.error("sign is empty, check sign failed");
writeErrorResponseCode(response);
return false;
}
if (ServletUtil.isGetMethod(request)) {
String queryString = request.getQueryString();
Map<String, String> paramMap = HttpUtil.decodeParamMap(queryString, StandardCharsets.UTF_8);
return checkSignature(sign, paramMap, response);
}
if (ServletUtil.isPostMethod(request)) {
String contentType = request.getContentType();
Map<String, String> parameterMap;
if (StrUtil.equals(contentType, MediaType.APPLICATION_JSON_VALUE)) {
RequestBodyCopyFilter.RequestBodyWrapper wrapper = (RequestBodyCopyFilter.RequestBodyWrapper) request;
String body = wrapper.getBody();
if (log.isDebugEnabled()) {
log.info("post request body: {}", body);
}
parameterMap = SignUtils.signParseMap(body);
} else {
parameterMap = ServletUtil.getParamMap(request);
}
return checkSignature(sign, parameterMap, response);
}
return true;
}
private static final String MD5_SALT = "gLvzYnRhwCFKG_WP";
private static final MD5 getMd5() {
return new MD5(MD5_SALT.getBytes(StandardCharsets.UTF_8));
}
private boolean checkSignature(String signStr, Map<String, String> paramMap, HttpServletResponse response) {
return checkSignature(signStr, SignUtils.mapConvertString(paramMap), response);
}
private boolean checkSignature(String signStr, String params, HttpServletResponse response) {
if (log.isDebugEnabled()) {
log.info("sign is {}, params is {}", signStr, params);
}
String str = getMd5().digestHex(params, StandardCharsets.UTF_8);
if (Objects.equals(signStr, str)) {
return true;
}
writeErrorResponseCode(response);
return false;
}
private void writeErrorResponseCode(HttpServletResponse response) {
response.setStatus(403);
}
这里我们只重写preHandle方法就可以,依赖于hutool的部分工具,这里主要签名get请求和post的部分请求类型,post请求的application/json类型只处理了map格式的json,list格式的json暂时未处理
整体的逻辑同上面所说,客户端先md5签名,这里我们约定好将签名后的md5值添加到请求头sign里面,在处理的时候为了不再修改拦截器的接口拦截规则,以及为了签名的扩展性,添加了注解@Signature用于区分此接口是否需要签名校验,也可以在添加配置实现签名接口的白名单
/**
* 是否需要签名标识
* @author liufuqiang
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Signature {
}
签名工具类
public class SignUtils {
public SignUtils () {
}
/**
* 针对json字符串格式解析为map数据格式
*
* @param jsonString json字符串
* @return
*/
public static Map<String, String> signParseMap(String jsonString) {
Map<String, String> parameterMap = new LinkedHashMap<>(16);
JSONObject jsonObject = JSONUtil.parseObj(jsonString, false, true);
Map<String, Object> map = JSONUtil.toBean(jsonObject, new TypeReference<LinkedHashMap<String, Object>>() {
}.getType(), false);
Map<String, String> finalParameterMap = parameterMap;
map.forEach((key, value) -> {
if (value instanceof JSONArray) {
JSONArray array = (JSONArray) value;
finalParameterMap.put(key,
array.stream().map(String::valueOf).collect(Collectors.joining(StrUtil.COMMA)));
} else {
finalParameterMap.put(key, value + "");
}
});
return finalParameterMap;
}
/**
* 数据格式转换
* <pre>k1=v1&k2=v2</pre>
*
* @param map
* @return
*/
public static String mapConvertString(Map<String, String> map) {
if (CollUtil.isEmpty(map)) {
return StrUtil.EMPTY;
}
// 排序字典并拼接,字典序 a=a&b=b
List<String> paramsTest = new ArrayList<>();
map.entrySet().stream().sorted(Map.Entry.comparingByKey())
.forEachOrdered(x -> paramsTest.add(x.getKey() + "=" + x.getValue()));
return String.join("&", paramsTest);
}
}
如果是post请求内容,涉及请求内容重写问题,所以复制了一份出来
public class RequestBodyCopyFilter implements Filter {
@Override
public void doFilter (ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
ServletRequest requestWrapper = null;
if(servletRequest instanceof HttpServletRequest) {
if (ServletUtil.isPostMethod((HttpServletRequest) servletRequest)) {
requestWrapper = new RequestBodyWrapper((HttpServletRequest) servletRequest);
}
}
if(requestWrapper == null) {
filterChain.doFilter(servletRequest, servletResponse);
} else {
filterChain.doFilter(requestWrapper, servletResponse);
}
}
public static class RequestBodyWrapper extends HttpServletRequestWrapper {
private @Nullable byte[] body;
public RequestBodyWrapper(HttpServletRequest request) throws IOException {
super(request);
if (ServletUtil.isMultipart(request)) {
return;
}
if (!StrUtil.equals(request.getContentType(), MediaType.APPLICATION_JSON_VALUE)) {
return;
}
if (ServletUtil.isPostMethod(request)) {
StringBuilder sb = new StringBuilder();
String line;
BufferedReader reader = request.getReader();
while ((line = reader.readLine()) != null) {
sb.append(line);
}
String body = sb.toString();
this.body = body.getBytes(StandardCharsets.UTF_8);
System.out.println(1);
}
}
public String getBody() {
return new String(this.body , StandardCharsets.UTF_8) ;
}
@Override
public ServletInputStream getInputStream() {
final ByteArrayInputStream bais = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
@Override
public int read(){
return bais.read();
}
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new InputStreamReader(this.getInputStream()));
}
}
}
拦截器写完,我们将这个拦截器注册到WebMvcConfigurer里面
@Override
public void addInterceptors(InterceptorRegistry registry) {
PhpSignatureInterceptor phpSignatureInterceptor = new PhpSignatureInterceptor();
registry.addInterceptor(phpSignatureInterceptor).addPathPatterns("/test/**");
}
@Bean
public RequestBodyCopyFilter getBodyWrapperFilter() {
return new RequestBodyCopyFilter();
}
@Bean("bodyCopyFilter")
public FilterRegistrationBean<RequestBodyCopyFilter> bodyCopyFilter(RequestBodyCopyFilter bodyWrapperFilter) {
FilterRegistrationBean<RequestBodyCopyFilter> registrationBean = new FilterRegistrationBean();
registrationBean.setFilter(bodyWrapperFilter);
registrationBean.addUrlPatterns("/test/*");
registrationBean.setOrder(1);
registrationBean.setAsyncSupported(true);
return registrationBean;
}
这里拦截的接口为/test开头的接口
测试代码如下:
@GetMapping("/test/testGet")
@Signature
public R testGetSign(@RequestParam String username) {
log.info("params :{}", username);
return R.ok("success");
}
如果这里不添加请求头sign,或者sign不正确,则显示http状态码403
我们将签名结果添加到请求头
curl --location 'http://127.0.0.1:8080/test/testGet?username=liufuqiang' \
--header 'sign: 24ec455a315fb5ea3e73909b0114b851'