https://github.com/loafer7423/signature.git
包含了服务端、客户端、数据库脚本,下载下来,需要修改服务端连接数据库的连接。
假设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,进行请求服务端接口即可
服务端的大致逻辑:
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() {
}
}
演示结果:
参数没有被修改
我们现在把userid=12改为userid=121,我们在重新请求服务端接口
结论:我们会发现,就算请求被人拦截,修改直接修改参数,提交到后台,对我们的业务系统也没有任何影响。
注意:
1.实际开发中,我们还需要考虑到参数转码的问题,我们可以利用URLEncoder在客户端转码,然后再服务端解码。
2.本实例中,我没有用全部的参数进行加密生成签名,只是用了通用的参数base{}进行签名的。实际开发可以用全部的参数进行加密生成签名。(只为了演示签名验签过程,所以很多场景没有考虑,见谅!!!)