API接口安全之签名验签(一)

简介

  • 现在越来越多人关注接口安全,传统的接口在传输的过程中,容易被抓包然后更改里面的参数值达到某些目的。
  • 传统的做法是用安全框架或者在代码里面做验证,但是有些系统是不需要登录的,随时可以调。
  • 这时候我们可以通过对参数进行签名验证,如果参数与签名值不匹配,则请求不通过,直接返回错误信息

项目代码地址:

https://github.com/loafer7423/signature.git 

包含了服务端、客户端、数据库脚本,下载下来,需要修改服务端连接数据库的连接。

验签流程

API接口安全之签名验签(一)_第1张图片

案例

假设app客户端请求后台服务端的地址为:http://localhost:8080/test/list参数:{"name": "李四2"},参数是json格式。但是通常我们开发的时候,可能需要一些公共的参数如登录的用户ID(userId)、来源(form),对于这样的我们需求,我们传参可能是

{
	"name": "李四2",
	"base": {
		"from": "android",
		"userId": 12
    }
}

但是这样直接传参,可能会被别人拦截请求,直接修改参数,提交到我们的后台服务系统。对于这样的API接口,我们需要对参数进行签名验证。

首先,我们需要定义一个参数signature用来传客户端生成的签名;但是如果别人通过数据分析,知道了我们的加密形式,也可以破解进行修改参数。我们还需要定义一个参数是nonce用来生成随机数,这时候我们的签名生成公式=md5(参数列表+nonce(随机数));同时为了保证我们接口在有效的时间进行访问,我们还需要定义一个参数是timestamp用来传参客户端的时间戳,所以整个签名的公式为:

signature(签名)=md5(参数列表+nonce(随机数)+timestamp(时间戳))

所以最终我们的json参数为

{
	"name": "李四2",
	"base": {
		"from": "android",//来源
		"userId": 12,//登录用户id
		"signature": "9070D6BBE067283F2A25BE9ACBE0211E",//客户端生成的签名
		"nonce": "LkFt7hCgGSmvgl7Z",//客户端生成的随机数
		"timestamp": 1570518677803  //客户端生成的时间戳
	}
}

客户端完整代码

注意:因为我是模拟客户端的请求,所以客户端的代码我也是用Java,把请求的json数据拼装好同时生成对应的签名,利用postman请求服务端接口。

public class Demo01 {

	public static void main(String[] args) {
		String randomStr = getRandomString(16);
		String jsonstr = "{\"base\":{" + "\"nonce\":\"" + randomStr + "\"," + "\"timestamp\":"
				+ System.currentTimeMillis() + "," + "\"userId\":12," + "\"from\":\"android\"" + "},"
				+ "\"name\":\"李四2\"" + "}";
		JSONObject jsonObject = JSON.parseObject(jsonstr);
		JSONObject base = (JSONObject) jsonObject.get("base");
		Map map = generateSignStr(base);
		String param = formatUrlMap(map, true, true);
		String signature = md5(param);
//		System.out.println("客户端签名:" + signature);
		base.put("signature", signature);
		System.out.println("客户端生成的请求签名参数:"+jsonObject);

	}

	/**
	 * @description: 将参数按照字段名排序
	 * @author wangdong
	 */
	public static String formatUrlMap(Map paraMap, boolean urlEncode, boolean keyToLower) {
		String buff = "";
		Map tmpMap = paraMap;
		try {
			List> infoIds = new ArrayList>(tmpMap.entrySet());
			// 对所有传入参数按照字段名的 ASCII 码从小到大排序(字典序)
			Collections.sort(infoIds, new Comparator>() {
				@Override
				public int compare(Map.Entry o1, Map.Entry o2) {
					return (o1.getKey()).toString().compareTo(o2.getKey());
				}
			});
			// 构造URL 键值对的格式
			StringBuilder buf = new StringBuilder();
			for (Map.Entry item : infoIds) {
				if (StringUtils.isNotBlank(item.getKey())) {
					String key = item.getKey();
					Object val = item.getValue();
					if (urlEncode) {
						val = URLEncoder.encode(val.toString(), "utf-8");
					}
					if (keyToLower) {
						buf.append(key.toLowerCase() + "=" + val);
					} else {
						buf.append(key + "=" + val);
					}
					buf.append("&");
				}
			}

			buff = buf.toString();
			if (buff.isEmpty() == false) {
				buff = buff.substring(0, buff.length() - 1);
			}
		} catch (Exception e) {
			return null;
		}
		return buff;
	}

	/**
	 * @description: 将json格式转换为map对象
	 * @author wangdong
	 */
	private static Map generateSignStr(JSONObject base) {
		String timestamp = base.getString("timestamp");
		String nonce = base.getString("nonce");
		String userId = base.getString("userId");
		Map map = new HashMap();
		map.put("nonce", nonce);
		map.put("timestamp", timestamp);
		map.put("userId", userId);
		return map;
	}

	/**
	 * @description: 客户端生成随机数
	 * @author wangdong
	 */
	public static String getRandomString(int length) {
		String str = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
		Random random = new Random();
		StringBuffer sb = new StringBuffer();
		for (int i = 0; i < length; i++) {
			int number = random.nextInt(62);
			sb.append(str.charAt(number));
		}
		return sb.toString();
	}
    /**
     * @description: md5加密
     * @author wangdong
     */
	public static String md5(String content) {
		// 用于加密的字符
		char[] md5String = { '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F' };
		try {
			// 使用平台默认的字符集将md5String编码为byte序列,并将结果存储到一个新的byte数组中
			byte[] byteInput = content.getBytes();
			// 信息摘要是安全的单向哈希函数,它接收任意大小的数据,并输出固定长度的哈希值
			MessageDigest mdInst = MessageDigest.getInstance("MD5");
			// MessageDigest对象通过使用update方法处理数据,使用指定的byte数组更新摘要
			mdInst.update(byteInput);
			// 摘要更新后通过调用digest() 执行哈希计算,获得密文
			byte[] md = mdInst.digest();
			// 把密文转换成16进制的字符串形式
			int j = md.length;
			char[] str = new char[j * 2];
			int k = 0;
			for (int i = 0; i < j; i++) {
				byte byte0 = md[i];
				str[k++] = md5String[byte0 >>> 4 & 0xf];
				str[k++] = md5String[byte0 & 0xf];
			}
			// 返回加密后的字符串
			return new String(str);

		} catch (Exception e) {
			e.printStackTrace();
			return null;
		}

	}
}

运行客户端的代码,将生成的结果,放入到postman,进行请求服务端接口即可

API接口安全之签名验签(一)_第2张图片

服务端的代码

服务端的大致逻辑:

1.客户端把生成的签名已经传递到服务端,所以服务端需要和客户端的加密算法、参数排序等需要保持一致;

2.服务端生成的签名与客户端生成的签名进行比对,判断是否一致,如果一致,则说明客户端传过来的参数没有被修改;如果不一致,则证明客户端传过来的参数已经被修改。

3.因为服务端验签,不仅仅只只对于一个接口,所以我们需要将验签的过程,放在服务端的拦截器处理。

核心代码

核心代码,只介绍拦截器的代码,因为为了做demo,加密、排序等操作,我都放在拦截器对象里。服务端技术springboot+mysql。

拦截器RequestFilter.java核心代码,首先拦截器,我定义了只拦截了请求为/test/开头的地址,具体如下:

@WebFilter(filterName = "request", urlPatterns = "/test/*")
public class RequestFilter implements Filter {

其次我们需要拿到客户端请求传过来的参数,并且将参数转成字符串。

 ServletRequest requestWrapper = new BodyReaderHttpServletRequestWrapper(request);
 String bodyString = getBodyString(requestWrapper.getReader());
    /**
     * @description: 解析body数据的字符
     * @author wangdong
     * @date 2019/10/8 16:38
     */
    public static String getBodyString(BufferedReader br) {
        String inputLine;
        StringBuffer str = new StringBuffer();
        try {
            while ((inputLine = br.readLine()) != null) {
                str.append(inputLine);
            }
            br.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return str.toString();
    }

通过客户端传过来的参数,获取客户端生成的签名值

//将请求的参数转换为json对象
JSONObject jsonObject = JSON.parseObject(bodyString);
//后去参数的base里的json对象
JSONObject base = (JSONObject) jsonObject.get("base");
String signature=base.getString("signature");

将客户端传过来的签名参数删除,只保留和客户端签名的几个参数,并按照字段名首字母排序(和客户端算法保持一致)

//删除客户端穿过来的签名
base.remove("signature");
//将base参数转换为map对象
Map map = generateSignStr(base);
//拼装参数(按照字段名首字母排序)
String param = formatUrlMap(map,true,true);

验证客户端签名和服务端签名是否一致,如果不一致则给出提示信息

if(!md5(param).equals(signature)){//验证参数签名是否正确(客户端的签名和服务端根据参数重新加密生成签名,再验签)
    outputStream(servletResponse,"参数被篡改...");
    return;
}

核心完整代码如下:

/**
 * @ClassName RequestFilter
 * @Description [拦截器,验证参数签名是否通过]
 * @Author wangdong
 * @Date 2019/10/6 18:42
 * @Version V1.0
 **/

@WebFilter(filterName = "request", urlPatterns = "/test/*")
public class RequestFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }
    /**
     * @description: 拦截方法,处理业务逻辑
     * @author wangdong
     * @date 2019/10/8 16:39
     */
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;
        //获取请求地址,如:/test/list
        String requestURI = request.getRequestURI();
        //过滤哪些请求直接放行
        if (requestURI.contains("/callBack")){
            filterChain.doFilter(request, servletResponse);
            return;
        }
        ServletRequest requestWrapper = new BodyReaderHttpServletRequestWrapper(request);
        String bodyString = getBodyString(requestWrapper.getReader());
        //将请求的参数转换为json对象
        JSONObject jsonObject = JSON.parseObject(bodyString);
        //后去参数的base里的json对象
        JSONObject base = (JSONObject) jsonObject.get("base");
        String signature=base.getString("signature");
        //删除客户端穿过来的签名
        base.remove("signature");
        //将base参数转换为map对象
        Map map = generateSignStr(base);
        //拼装参数(按照字段名首字母排序)
        String param = formatUrlMap(map,true,true);
        if(!md5(param).equals(signature)){//验证参数签名是否正确(客户端的签名和服务端根据参数重新加密生成签名,再验签)
            outputStream(servletResponse,"参数被篡改...");
            return;
        }
        //比较请求的参数是否过期
        if(!validateTimeStamp(base.getLong("timestamp"))){
            outputStream(servletResponse,"请求参数已过期...");
            return;
        }
        //拦截器放行,继续执行业务方法
        filterChain.doFilter(requestWrapper, servletResponse);
        return;
    }


    /**
     * @description: 解析body数据的字符
     * @author wangdong
     * @date 2019/10/8 16:38
     */
    public static String getBodyString(BufferedReader br) {
        String inputLine;
        StringBuffer str = new StringBuffer();
        try {
            while ((inputLine = br.readLine()) != null) {
                str.append(inputLine);
            }
            br.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return str.toString();
    }

    /**
     * @description: 将参数按照字段名排序
     * @author wangdong
     * @date 2019/10/8 16:39
     */
    public static String formatUrlMap(Map paraMap, boolean urlEncode, boolean keyToLower) {
        String buff = "";
        Map tmpMap = paraMap;
        try {
            List> infoIds = new ArrayList>(tmpMap.entrySet());
            // 对所有传入参数按照字段名的 ASCII 码从小到大排序(字典序)
            Collections.sort(infoIds, new Comparator>() {
                @Override
                public int compare(Map.Entry o1, Map.Entry o2) {
                    return (o1.getKey()).toString().compareTo(o2.getKey());
                }
            });
            // 构造URL 键值对的格式
            StringBuilder buf = new StringBuilder();
            for (Map.Entry item : infoIds) {
                if (StringUtils.isNotBlank(item.getKey())) {
                    String key = item.getKey();
                    Object val = item.getValue();
                    if (urlEncode) {
                        val = URLEncoder.encode(val.toString(), "utf-8");
                    }
                    if (keyToLower) {
                        buf.append(key.toLowerCase() + "=" + val);
                    } else {
                        buf.append(key + "=" + val);
                    }
                    buf.append("&");
                }
            }

            buff = buf.toString();
            if (buff.isEmpty() == false) {
                buff = buff.substring(0, buff.length() - 1);
            }
        } catch (Exception e) {
            return null;
        }
        return buff;
    }

    /**
     * @description: 向客户端返回响应信息(json格式)
     * @author wangdong
     * @date 2019/10/8 16:46
     */
    private void outputStream(ServletResponse servletResponse,String message){
        try{
            String string = JSON.toJSONString(JSONResponse.failure(5002, message));
            servletResponse.setContentType("application/json;charset=UTF-8");
            servletResponse.getOutputStream().write(string.getBytes("UTF-8"));
            servletResponse.getOutputStream().close();
        }catch (Exception e){
            e.printStackTrace();
        }
    }

    /**
     * @description: 将json格式转换为map对象
     * @author wangdong
     * @date 2019/10/8 16:47
     */
    private Map generateSignStr(JSONObject base) {
        String timestamp = base.getString("timestamp");
        String nonce = base.getString("nonce");
        String userId = base.getString("userId");
        Map map = new HashMap();
        map.put("nonce", nonce);
        map.put("timestamp", timestamp);
        map.put("userId", userId);
        return map;
    }

    /**
     * @description: md5加密
     * @author wangdong
     * @date 2019/10/8 16:47
     */
    public static String md5(String content) {
        // 用于加密的字符
        char[] md5String = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'};
        try {
            // 使用平台默认的字符集将md5String编码为byte序列,并将结果存储到一个新的byte数组中
            byte[] byteInput = content.getBytes();
            // 信息摘要是安全的单向哈希函数,它接收任意大小的数据,并输出固定长度的哈希值
            MessageDigest mdInst = MessageDigest.getInstance("MD5");
            // MessageDigest对象通过使用update方法处理数据,使用指定的byte数组更新摘要
            mdInst.update(byteInput);
            //摘要更新后通过调用digest() 执行哈希计算,获得密文
            byte[] md = mdInst.digest();
            //把密文转换成16进制的字符串形式
            int j = md.length;
            char[] str = new char[j*2];
            int k = 0;
            for (int i=0;i>> 4 & 0xf];
                str[k++] = md5String[byte0 & 0xf];
            }
            // 返回加密后的字符串
            return new String(str);

        }catch (Exception e) {
            e.printStackTrace();
            return null;
        }

    }

    /**
     * @description: 判断客户端的请求是否超过30分钟
     * @author wangdong
     * @date 2019/10/8 16:48
     */
    public boolean validateTimeStamp(long timestamp) {
        Long tims = (System.currentTimeMillis()-timestamp) / (1000 * 60);
        //验证时间戳是否超过30分钟
        if (Math.abs(tims) >30) {
            return false;
        } else {
            return true;
        }
    }

    @Override
    public void destroy() {

    }
}

演示结果:

参数没有被修改

API接口安全之签名验签(一)_第3张图片

我们现在把userid=12改为userid=121,我们在重新请求服务端接口

API接口安全之签名验签(一)_第4张图片

结论:我们会发现,就算请求被人拦截,修改直接修改参数,提交到后台,对我们的业务系统也没有任何影响。

注意:

1.实际开发中,我们还需要考虑到参数转码的问题,我们可以利用URLEncoder在客户端转码,然后再服务端解码。

2.本实例中,我没有用全部的参数进行加密生成签名,只是用了通用的参数base{}进行签名的。实际开发可以用全部的参数进行加密生成签名。(只为了演示签名验签过程,所以很多场景没有考虑,见谅!!!)

 

 

 

 

 

 

 

 

 

 

 

你可能感兴趣的:(Java,接口安全,防篡改,签名,验签,安全)