接口交互提供一个开发的接口地址和接口文档,
知道了url,知道了参数怎么传,坏人就来了:
1.可以任意请求任意参数值,调用你的接口。
2.频繁请求、恶意攻击,让你一直处理接口对应的业务逻辑。
3.拦截某个请求,拿到参数信息,重复对发起请求(正常业务一个请求只能处理一次吧),如果此接口是写入某个业务数据到数据库,那你数据库的数据越来越多,数据还是一样的。。。
........................
因此:还需要约定签名算法规则等内容,来防止各种问题。
AK/SK:
AK:Access Key Id,用于标示用户。
SK:Secret Access Key,是用户用于加密认证字符串和用来验证认证字符串的密钥,其中SK必须保密。
通过使用Access Key Id / Secret Access Key加密的方法来验证某个请求的发送者身份。
基本思路:
1.客户端需要在认证服务器中预先设置 access key(AK 或叫 app ID) 和 secure key(SK)。
2.在调用 API 时,客户端需要对参数和 access key 等信息结合 secure key 进行签名生成一个额外的 sign字符串。
3.服务器接收到用户的请求后,系统将使用AK对应的相同的SK和同样的认证机制生成认证字符串,并与用户请求中包含的认证字符串进行比对。如果认证字符串相同,系统认为用户拥有指定的操作权限,并执行相关操作;如果认证字符串不同,系统将忽略该操作并返回错误码。
基于上文的思路,客户端和服务器端分别如何实现?
package xxx;
import java.security.MessageDigest;
import java.util.HashMap;
import java.util.Map;
import java.util.TreeMap;
import java.util.UUID;
import org.springframework.util.CollectionUtils;
import xxx.MapUtil;
import xxx.StringUtil;
public class SignUtil {
/**
* 通过请求参数,包装请求header信息(含签名信息)(客户端用)
*
* @Title:wrapperHeader
* @Description: TODO
* @date 2021年6月11日 下午1:52:35
* @author yqwang
* @param appId
* @param appSecret
* @param reqParam
* @return {appId=000000, sign=B562FFD6FC691A42CD7F46D068B3F74A, nonce=d50e301d-ee2c-446e-8f28-013f0fee09fb, ts=1623388123195}
*/
public static Map wrapperHeader(String appId, String appSecret, Map reqParam) {
Long ts = System.currentTimeMillis();
String nonce = UUID.randomUUID().toString();
Map header = new HashMap();
header.put("ts", ts);// 进行接口调用时的时间戳,即当前时间戳(毫秒),服务端会校验时间戳,例如时间差超过20分钟则认为请求无效,防止重复请求的攻击
header.put("nonce", nonce);// 每个请求提供一个唯一的标识符,服务器能够防止请求被多次使用
header.put("appId", appId);// 用于标识哪个三方系统发来的请求
String sign = getSign(appId, appSecret, ts, nonce, reqParam);// 按签名算法获取sign
header.put("sign", sign);
return header;
}
/**
* 按签名算法获取sign(客户端和服务器端算法一致,都需要用)
*
* @Title:getSign
* @Description: TODO
* @date 2021年6月11日 下午1:15:13
* @author yqwang
* @param appId
* @param appSecret
* @param ts
* 时间戳
* @param nonce
* 请求唯一标识
* @param reqParam
* 请求参数
* @return
*/
public static String getSign(String appId, String appSecret, Long ts, String nonce, Map reqParam) {
// 计算签名规则:sign = md5("ts=1623388123195&noce=d50e301d-ee2c-446e-8f28-013f0fee09fb&appSecret=9ZLEzugQHfQd11vS8pd68lxzA¶m1=1¶m2=2")
// 其他说明:这个规则双方来定,也可以不把reqParam带入计算
// 1.请求参数key升序
Map treeMap = new TreeMap<>();
treeMap.putAll(reqParam);
// 2.待加密字符串
StringBuffer s = new StringBuffer();
s.append("ts=").append(ts).append("&noce=").append(nonce).append("&appSecret=").append(appSecret);
// append : ¶m1=1¶m2=2
treeMap.forEach((k, v) -> s.append("&").append(k).append("=").append(v));
// 3.对待加密字符串进行加密(对字符串md5处理,得到sign值)
return string2MD5(s.toString());
}
/**
* 验证请求是否有效(服务器端用)
*
* @Title:checkReqInfo
* @Description: TODO
* @date 2021年6月11日 下午1:19:21
* @author yqwang
* @param reqHeader
* @param reqParam
* @return 是否有效(方便测试我用Boolean,可根据业务需要,返回对应错误信息,不一定用Boolean)
*/
public static Boolean checkReqInfo(Map reqHeader, Map reqParam) {
// 1.没有header : 无效请求
if (CollectionUtils.isEmpty(reqHeader)) return false;
// 2.没有ts(请求时间戳):无效请求
Long ts = MapUtil.get(reqHeader, "ts", Long.class);
if (ts == null) return false;
// 3.超过20分钟:无效请求
if (System.currentTimeMillis() - ts > 20 * 60 * 1000) return false;
// 4.如果带有请求唯一标识,则需要先验证此标识是否已经被处理过,防止重复请求(不一定每个项目都要求考虑此项,这里只是一个思路,需要的可以加上);如果处理过,返回false,如果没处理过,则把这个唯一标识存到redis(随意)
String nonce = MapUtil.getStr(reqHeader, "nonce");
/* if (StringUtil.isNotBlank(nonce)) {
// 判断是否重复请求
Boolean isRepeat = isRepeatReq(nonce);
if (isRepeat) {
return false;// 4.1重复请求:无效请求
} else {
saveToRedis(nonce);// 4.2标记此请求正在被处理或已被处理
}
}*/
// 5.appId是否存在(用户是否存在),不存在则算无效请求
String appId = MapUtil.getStr(reqHeader, "appId");
if(StringUtil.isBlank(appId))return false;
// 5.1去库中或配置中获取appId对应的appSecret (这里方便测试,先写死)
String appSecret = "9ZLEzugQHfQd11vS8pd68lxzA";//getAppSecretByAppId(appId);
// 5.2没有此appId对应信息:无效请求
if(StringUtil.isBlank(appSecret))return false;
// 6.sign验证
// 6.1 没传sign:无效请求
String sign = MapUtil.getStr(reqHeader, "sign","");
if(StringUtil.isBlank(sign))return false;
//6.2最后验证sign值(按约定的sign计算方式,服务器端也算出一个sign,将这里计算出的sign和请求中的sign比较,是否一致)
String srvSign = getSign(appId, appSecret, ts, nonce, reqParam);
System.out.println(sign);
System.out.println(srvSign);
// 目前能想到的安全验证就这些,或许大家还能想到其他验证,让接口更加安全
return sign.equalsIgnoreCase(srvSign);
}
/**
* MD5加码 生成32位md5码
*/
public static String string2MD5(String inStr) {
MessageDigest md5 = null;
try {
md5 = MessageDigest.getInstance("MD5");
} catch (Exception e) {
System.err.println("MD5加码失败");
return "";
}
char[] charArray = inStr.toCharArray();
byte[] byteArray = new byte[charArray.length];
for (int i = 0; i < charArray.length; i++)
byteArray[i] = (byte) charArray[i];
byte[] md5Bytes = md5.digest(byteArray);
StringBuilder hexValue = new StringBuilder();
for (int i = 0; i < md5Bytes.length; i++) {
int val = ((int) md5Bytes[i]) & 0xff;
if (val < 16) hexValue.append("0");
hexValue.append(Integer.toHexString(val));
}
return hexValue.toString().toUpperCase();
}
// 测试
public static void main(String[] args) {
// A.客户端:请求(header+param)
// A.1请求参数
Map reqParam = new HashMap();
reqParam.put("param2", "2");
reqParam.put("param1", "1");
// A.2请求头(行sign值等信息)
String appId = "000000";// AK appId相当于用户名
String appSecret = "9ZLEzugQHfQd11vS8pd68lxzA";// SK 相当于密码,提供给对方一个随机密码,不直接体现在请求中
Map reqHeader = wrapperHeader(appId, appSecret, reqParam);
// {appId=000000, sign=B562FFD6FC691A42CD7F46D068B3F74A, nonce=d50e301d-ee2c-446e-8f28-013f0fee09fb, ts=1623388123195}
System.out.println(reqHeader);
// ==================客户端发起请求,参数param,并把header带入请求中
// ============================服务器端,收到请求
// 1.验证请求信息,2处理业务逻辑,3.返回数据到客户端
// 1.验证请求信息(方便测试,不再赘述如何获取请求的header和参数信息!直接用上文定义的reqHeader, reqParam)
Boolean valid = checkReqInfo(reqHeader, reqParam);
if(!valid){
//无效,不再处理业务信息,返回失败
System.out.println("无效");
}
System.out.println("有效请求,继续处理...");
//2处理业务逻辑,3.返回数据到客户端...省略
}
}
请直接看main()方法!!
注意:
文中用到了2个工具类,都可以按自己项目中的工具或变一下方式去实现,我就不贴出来了,大家都看得懂。
1.MapUtil 目前就是从map中获取某个值,这个可以自行处理。
2.StringUtil是我们内部的字符串工具类,可更换为org.apache.commons.lang.StringUtils或用其他方式判断字符串是否为空等。