一般情况下,我们针对一些敏感的参数,例如密码、身份证号等,给它加密,防止报文明文传输,加密可以分为大体的两类,对称加密和非对称加密,下面,简单介绍下这两种方式。
对称加密:加密使用的密钥和解密使用的密钥是同一个,例如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() {@Overridepublic 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个tokenprivate static final RateLimiter rateLimiter = RateLimiter.create(4);public static void main(String[] args) throws InterruptedException {for (int i = 0; i < 10; i++) {new Thread(()->{//每次请求消耗一个tokenif(rateLimiter.tryAcquire()){System.out.println("请求成功");}else{System.out.println("限流了");}}).start();//每个请求相隔1/5秒Thread.sleep(200);}}
}
可以设想到每五个请求会有一个被限流,实际运行结果也是这样,这里的打印顺序和多线程的打印有关,并不是限流的问题
我们可以在本身的后台管理系统中添加黑名单及白名单的相关配置,对于黑名单发起的请求,直接返回错误码;对于一些特别敏感的操作,例如涉及到转账等,只有在白名单中的请求才可以操作。
前文提到了几种保证接口安全的措施,在实际项目应用过程中,可以将其串联起来使用,例如我们做一个全局的拦截器,拦截全部请求,然后在拦截器里将上述措施串联起来,用来保证接口请求的安全性。