Slash Admin: # 一个基于React和TypeScript的现代化管理后台框架
为什么要做签名?
想象一个场景:一位许久不见的朋友,突然在微信里面跟你说“朋友,借200应个急”,你会怎么反应?
我想大部分人马上的反应就是:是不是被盗号了?他是本人吗?
实际上这是我们日常生活中常见的通讯行为,系统间调用API和传输数据的过程无异于你和朋友间的微信沟通,所有处于开放环境的数据传输都是可以被截取,甚至被篡改的。因而数据传输存在着极大的危险,所以必须签名加密
。
签名核心解决两个问题:
-
请求是否合法:是否是我规定的那个人
-
请求是否被篡改:是否被第三方劫持并篡改参数
-
防止重复请求(防重放):是否重复请求
如何进行签名
签名算法逻辑
第一步, 设所有发送或者接收到的数据为集合M,将集合M内非空参数值的参数按照参数名ASCII码从小到大
排序(字典序),使用以下格式拼接成字符串stringA
key1value1key2value2...
特别注意以下重要规则:
- 参数名ASCII码从小到大排序(字典序);
- 如果参数的值为空不参与签名;
- 参数名区分大小写;
- 传送的sign参数不参与签名;
第二步,在stringA最后拼接上secret密钥得到stringSignTemp字符串
第三步,对stringSignTemp进行MD5加密得到signValue
防重放攻击
以上措施依然不是最严谨的,虽然仿冒者无法轻易模仿签名规则再生成一模一样的签名,可事实上,如果仿冒者监听并截取到了请求片段,然后把签名单独截取出来模仿正式请求方欺骗服务器进行重复请求,这也会造成安全问题,这攻击方式就叫重放攻击(replay 攻击)。
我们可以通过加入 timestamp
+ nonce
两个参数来控制请求有效性,防止重放攻击。
timestamp
请求端:timestamp
由请求方生成,代表请求被发送的时间(需双方共用一套时间计数系统)随请求参数一并发出,并将 timestamp
作为一个参数加入 sign
加密计算。
服务端:平台服务器接到请求后对比当前时间戳,设定不超过60s 即认为该请求正常,否则认为超时并不反馈结果(由于实际传输时间差的存在所以不可能无限缩小超时时间)。
但是这样仍然是仅仅不够的,仿冒者仍然有60秒的时间来模仿请求进行重放攻击。所以更进一步地,可以为sign
加上一个随机码(称之为盐值)这里我们定义为 nonce
。
nonce
请求端:nonce
是由请求方生成的随机数(在规定的时间内保证有充足的随机数产生,即在60s 内产生的随机数重复的概率为0)也作为参数之一加入 sign
签名。
服务端:服务器接受到请求先判定 nonce
是否被请求过(一般会放到redis
中),如果发现 nonce
参数在规定时间是全新的则正常返回结果,反之,则判定是重放攻击。而由于以上2个参数也写入了签名当中,攻击方刻意增加或伪造 timestamp
和 nonce
企图逃过重放判定都会导致签名不通过而失败。
前端生成签名
一般在axios发送请求处统一拦截
// npm install crypto-js
import MD5 from 'crypto-js/md5';
// 获取指定位数的随机数
function getRandom(num) {
return Math.floor((Math.random() + Math.floor(Math.random() * 9 + 1)) * Math.pow(10, num - 1))
}
function genSign(params) {
// 密钥
const secret = 'xxxxxxxxxxxxxxxxxxxxxxx'
// 1O位时间戳
const timestampStr = parseInt(new Date().getTime() / 1000).toString()
// 20位随机数
const nonce = getRandom(20).toString()
params.timestampStr = timestampStr
params.nonce = nonce
// 取 key
const sortedKeys = []
for (const key in params) {
// 注意这里,要剔除掉 sign 参数本身
if (key !== 'sign') {
sortedKeys.push(key)
}
}
// 参数名 ASCII 码从小到大排序(字典序)
sortedKeys.sort()
// 1 拼接参数
let str = ''
sortedKeys.forEach(key => {
str += key + params[key]
})
// 2 拼接密钥
str += secret
// 3 MD5加密
params.sign = MD5(str).toString().toUpperCase()
}
export default genSign
如何解决时间差问题
如果客户端时间与服务器时间不一致时(客户端时间比服务端快2分钟
), 如果验签时规定 一分钟内的请求有效,则该签名永远无法通过。
- 第一次打开应用获取本地时间,然后请求接口获取服务器时间。
// 获取服务器时间 function ajax(option){ var xhr = null; if(window.XMLHttpRequest){ xhr = new window.XMLHttpRequest(); }else{ // ie xhr = new ActiveObject("Microsoft") } // 通过get的方式请求当前文件 xhr.open("get","/"); xhr.send(null); // 监听请求状态变化 xhr.onreadystatechange = function(){ var time = null, curDate = null; if(xhr.readyState===2){ // 获取响应头里的时间戳 time = xhr.getResponseHeader("Date"); console.log(xhr.getAllResponseHeaders()) curDate = new Date(time); console.log(curDate) } } }
- 把时间差保存到本地存储
- 请求接口的时候把本地时间和时间差相加。
后端生成签名
import org.apache.commons.codec.binary.Hex;
import java.security.MessageDigest;
@Slf4j
public class SignUtil {
// 密钥
private static final String SECRET = "xxxxxxxxxxxxxxxxxxxxxxxxxx";
private static final String SIGN = "sign";
private static final String NONCE = "nonce";
private static final String TIMESTAMP = "timestamp";
private static final String SIGN_KEY = "apisign_";
/**
* 生成
* @param params
* @return
*/
public static String genSign(TreeMap<String, Object> params) {
params.remove(SIGN);
StringBuilder str = new StringBuilder();
for (String key : params.keySet()) {
Object val = params.get(key);
if (ObjectUtil.isNotNull(val)) {
// 1 拼接参数
str.append(key).append(val);
}
}
// 2 拼接秘钥
str.append(SECRET);
// 3 MD5加密
return md5(str.toString());
}
public static String md5(String source) {
String md5Result = null;
try {
byte[] hash = org.apache.commons.codec.binary.StringUtils.getBytesUtf8(source);
MessageDigest messageDigest = MessageDigest.getInstance("MD5");
messageDigest.update(hash);
hash = messageDigest.digest();
md5Result = Hex.encodeHexString(hash);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
return md5Result;
}
}
验证签名
public static void validateSign(Map<String, Object> params) {
// redis
SingleRedisCacheClient cacheClient = ServiceBean.getSpringContext().getBean(SingleRedisCacheClient.class);
String sign = (String) params.get(SIGN);
if (StringUtils.isNotBlank(sign)) {
if (StringUtils.isBlank(sign)) {
throw new RuntimeException("签名不能为空");
}
String nonce = (String) params.get(NONCE);
if (StringUtils.isBlank(nonce)) {
throw new RuntimeException("随机字符串不能为空");
}
String timestampStr = (String) params.get(TIMESTAMP);
if (StringUtils.isBlank(timestampStr)) {
throw new RuntimeException("时间戳不能为空");
}
long timestamp = 0;
try {
timestamp = Long.parseLong(timestampStr);
} catch (Exception e) {
log.error("发生异常",e);
}
// 请求传过来的时间戳与服务器当前时间戳差值大于120,则当前请求的timestamp无效
if (Math.abs(timestamp - System.currentTimeMillis() / 1000) > 120) {
throw new RuntimeException("签名已过期");
}
// 请求传过来的随机数如果在redis中存在,则当前请求的nonce无效
boolean nonceExists = cacheClient.hasKey(SIGN_KEY + timestampStr + nonce);
if (nonceExists) {
throw new RuntimeException("随机字符串已存在");
}
// 根据请求传过来的参数构造签名,如果和接口的签名不一致,则请求参数被篡改
TreeMap<String, Object> signTreeMap = new TreeMap<>();
signTreeMap.putAll(params);
String currentSign = genSign(signTreeMap);
if (!sign.equalsIgnoreCase(currentSign)) {
throw new RuntimeException("签名不匹配");
}
// 存入redis
cacheClient.setCacheWithExpire(SIGN_KEY + timestampStr+ nonce, nonce, 120L);
}
}
🚀🚀🚀🚀个人开源了一款基于React、TypeScript、Zustand、Ant Design开发的高颜值后台管理系统Slash Admin, 马上就有1000star了,感兴趣的可以了解下🚀🚀🚀🚀