一般情况下,我们针对一些敏感的参数,例如密码、身份证号等,给它加密,防止报文明文传输,加密可以分为大体的两类,对称加密和非对称加密,下面,简单介绍下这两种方式。
对称加密:加密使用的密钥和解密使用的密钥是同一个,例如sm4加密
这样的加密方式简单,只需要加解密双方都有密钥即可,但是这样很不安全,一旦密钥泄漏,数据就会被解密。
非对称加密:顾名思义,非对称加密就是加密使用一个密钥(一般称为公钥),解密使用另一个密钥(一般称为私钥),常见的算法有RSA算法、sm2算法
这种情况下,私钥一般由解密方独立保存,极大提高了数据的安全性。如果要对所有请求参数加密,推荐使用https请求,因为https请求原理上也是非对称加密实现的,这里不做过多赘述。
我们对参数进行了加密,那么数据是否安全了呢?答案是否定的,因为我们只是保证了传入参数不被别人知道,但是我们的请求或响应是可以被篡改拦截的,那么,就需要引入新的方案,加签验签。
加签:用Hash函数把原始报文生成报文摘要,然后用私钥对这个摘要进行加密,就得到这个报文对应的数字签名。一般情况下,客户端会将签名和原始报文一起发给服务端。
客户端加签
//accessKey理解为一个盐值,signPriKey是加密私钥,map是请求参数
public static String sign(String accessKey, String signPriKey, Map map) {
//sort方法主要用于参数排序及过滤,过滤掉key为sign的参数
String paramStr = ParamSort.sort(map);
//生成的摘要
String abstractText = SM3Digest.sm3Encry(paramStr + accessKey);
//非对称加密生成签名
return SMHelper.sm2Sign(signPriKey, abstractText);
}
获取摘要的hash方法
public static String sort(String jsonString){
JSONObject jsonObject = JSON.parseObject(jsonString);
String aa = jsonObject.toJSONString();
List list = new ArrayList();
for(Entry entry : jsonObject.entrySet()){
String key = entry.getKey();
//主要关注这里,排除了sign参数,因为sign签也是要作为参数传递给服务端的,但是客户端加签时还没有sign签
if ("sign".equals(key)){
continue;
}
String value = null;
if (entry.getValue() instanceof JSONObject || entry.getValue() instanceof JSONArray){
value = JSON.toJSONString(entry.getValue());
} else {
value = (String)entry.getValue();
}
String str = key+value;
list.add(str);
}
Collections.sort(list, new Comparator() {
@Override
public int compare(String o1, String o2) {
try {
String s1 = new String(o1.toString().getBytes("UTF-8"), "ISO-8859-1");
String s2 = new String(o2.toString().getBytes("UTF-8"), "ISO-8859-1");
return s1.compareTo(s2);
} catch (Exception e) {
e.printStackTrace();
}
return 0;
}
});
StringBuffer paramStr = new StringBuffer();
for(String param : list){
paramStr.append(param);
}
return paramStr.toString();
}
获取到sign签后,记得传递给服务端的参数加上sign签
map.put("sign",sign);
验签:接收方拿到原始报文和sign签名后,用同一个Hash函数从报文中生成服务端摘要。然后用对方提供的公钥对数字签名进行解密,得到客户端摘要,对比两个摘要是否相同,就可以得知报文有没有被篡改过。
服务端验签
//ca是证书,存储了验签公钥等信息,sign是客户端的签名
private boolean sign(AuthSecCa ca, String sign, Map param) {
//相同的排序hash方法
String paramStr = Sort.sort(param);
//生成服务端摘要
String design = SM3Digest.SM3Encry(paramStr + ca.getAccessKey());
// 验签
boolean b = SMHelper.sm2Verify(ca.getSignPubKey(), design, sign);
return b;
}
上面我们做了数据加密和请求加签验签,可以防止请求被抓包之后篡改请求,但是如果攻击者只是拦截数据包之后恶意请求怎么办呢?答案就是增加时间戳验证。大体思路就是请求参数加上一个请求时间戳dataStamp,服务端获取到这个时间戳后,获取一个当前的时间戳serverStamp,然后这两个时间戳的差值少于多长时间才算有效请求。
private boolean verifyDataStamp(String time) {
boolean flag = false;
long nowTime = System.currentTimeMillis();
if (StringUtils.isNotEmpty(time)) {
long t = Long.parseLong(time);
//时间间隔超过1分钟
int stampInt = stamp;
if (Math.abs(nowTime - t) > stampInt * 1000) {
flag = true;
}
}
return flag;
}
上面虽然做了时间戳验证,但是还是有漏洞的,只要攻击者在对应的时间范围(例如上面的一分钟)内恶意攻击还是能影响到我们的系统的,因此,我们需要给请求加上一个唯一的随机数nonce,每次请求过来把nonce拿到,判断是否已经又过了,来考虑是否放行请求,但是如果存储大量的nonce对我们的系统来说也是巨大的压力,因此配合时间戳一起使用,例如,时间戳是一分钟,我们可以设置nonce的有效期为两分钟(大于一分钟即可,避免极端情况),这样,我们把nonce存到缓存即可。
private boolean verifyNonce(String nonce) {
if (StringUtils.isEmpty(nonce)) {
return true;
}
if (缓存.isExist(nonce)) {
return false;
}
缓存.setWithExpire(nonce, 120);
return true;
}
这里介绍一个常见的限流算法,令牌桶限流。它的思路为:
com.google.guava
guava
31.1-jre
public class test {
//每秒钟生成4个token
private static final RateLimiter rateLimiter = RateLimiter.create(4);
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(()->{
//每次请求消耗一个token
if(rateLimiter.tryAcquire()){
System.out.println("请求成功");
}else{
System.out.println("限流了");
}
}).start();
//每个请求相隔1/5秒
Thread.sleep(200);
}
}
}
可以设想到每五个请求会有一个被限流,实际运行结果也是这样,这里的打印顺序和多线程的打印有关,并不是限流的问题
我们可以在本身的后台管理系统中添加黑名单及白名单的相关配置,对于黑名单发起的请求,直接返回错误码;对于一些特别敏感的操作,例如涉及到转账等,只有在白名单中的请求才可以操作。
前文提到了几种保证接口安全的措施,在实际项目应用过程中,可以将其串联起来使用,例如我们做一个全局的拦截器,拦截全部请求,然后在拦截器里将上述措施串联起来,用来保证接口请求的安全性。