接口签名
为什么需要接口签名?
现在越来越多的公司以 API 的形式对外提供服务,这些 API 接口大多暴露在公网上,所以安全性就变的很重要了。最直接的风险如下:
- 非法使用 API 服务。(收费接口非法调用)
- 恶意攻击和破坏。(数据篡改、DOS)
因此需要设计一些接口安全保护的方式来增强接口安全,目前主流的一种方式是 API 签名。
知乎上有个提问:使用了 https 后,还有必要对数据进行签名来确保数据没有被篡改吗?答案是有必要的。
HTTPS 保证通过中间人攻击抓到的报文是密文,无法或者说很难破解。但仍然可以将报文重发,形成 DDOS。同时,如果不签名,只用 HTTP 简单认证,通过抓包,直接可以获取到 Authorization,就可以随意发起请求了。因此最安全的方法就是结合 HTTPS 和 API 签名。
总结一下就是:
- API 签名保证的是应用的数据安全和防篡改,并且可以作为业务的参数校验和处理重放攻击。
- HTTPS 保证的是运输层的加密传输,但是无法防御重放攻击。
简单的签名算法
一个服务调用另外一个服务,肯定是有参数需要传递的,我们可以将参数按照以下步骤进行签名:
- 对所有非空参数值的的参数进行拼接
key1value1key2value2...
- 参数名ASCII码从小到大排序(字典序);
- 如果参数的值为空不参与签名;
- 参数名区分大小写;
- 传送的sign参数不参与签名;
- 在stringA最后拼接上secret密钥得到stringSignTemp字符串
- 对stringSignTemp进行MD5加密得到signValue
假设服务A的接口f1调用服务2的接口f2,在调用时,按照以上算法生成一个签名sign,调用接口的时候将签名sign连同参数一起传给服务2的接口f2,f2解析完参数和sign后,拿到参数根据同样的签名算法和secret再生成一个新sign,对比新sign和传过来的sign是否相同,如果相同,说明参数没有被修改过,如果不同,则说明参数已经被修改了,拒绝服务,
public class AntiReplayAttackV1Example {
//如果是对称加密,则是调用方和被调用方都知道的私钥;如果是
//非对称加密,调用方这里是被调用方生成的公钥
private static final String SECRET_KEY = "your_secret_key";
// 模拟支付请求类
static class PaymentRequest {
private String transactionId;
private double amount;
private String nonce;
public PaymentRequest(String transactionId, double amount, String nonce) {
this.transactionId = transactionId;
this.amount = amount;
this.nonce = nonce;
}
public String getTransactionId() {
return transactionId;
}
public double getAmount() {
return amount;
}
public String getNonce() {
return nonce;
}
}
public static String generateSign(Map<String, String> params, String secret) throws NoSuchAlgorithmException {
// 将参数按ASCII码从小到大排序
Map<String, String> sortedParams = new TreeMap<>(params);
StringBuilder stringA = new StringBuilder();
// 拼接成字符串stringA
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
if (entry.getValue() != null && !entry.getValue().isEmpty()) {
stringA.append(entry.getKey()).append(entry.getValue());
}
}
// 在stringA最后拼接上secret密钥得到stringSignTemp字符串
String stringSignTemp = stringA.toString() + secret;
// 对stringSignTemp进行MD5加密得到signValue
return md5(stringSignTemp);
}
private static String md5(String input) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] messageDigest = md.digest(input.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : messageDigest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
public static void main(String[] args) {
try {
// 模拟一个支付请求
PaymentRequest request = new PaymentRequest("txn-001", 100.0, "nonce-123");
// 模拟请求参数集合
Map<String, String> params = new TreeMap<>();
params.put("transactionId", request.getTransactionId());
params.put("amount", String.valueOf(request.getAmount()));
params.put("nonce", request.getNonce());
// 生成签名
String sign = generateSign(params, SECRET_KEY);
System.out.println("Generated Sign: " + sign);
// 模拟服务器端验证签名
boolean isValid = verifySign(params, sign, SECRET_KEY);
System.out.println("Is Sign Valid: " + isValid);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
}
public static boolean verifySign(Map<String, String> params, String providedSign, String secret) throws NoSuchAlgorithmException {
// 生成签名
String generatedSign = generateSign(params, secret);
// 比较生成的签名和提供的签名
return generatedSign.equals(providedSign);
}
}
//输出:
Generated Sign: 1f7cc39bcb0eb7e293c29d49906d69d6
Is Sign Valid: true
但是这种签名只能验证数据有没有被修改,但是防不了重放攻击!
重放攻击
什么是重放攻击?
API重放攻击(Replay Attacks)又称为重播攻击。就是把你的请求原封不动地再发送一次,两次…n次,一般正常的请求都会通过验证进入到正常逻辑中,如果这个正常逻辑是插入数据库操作,那么一旦插入数据库的语句写的不好,就有可能出现多条重复的数据。一旦是比较慢的查询操作,就可能导致数据库堵住等情况,如果是付款接口,或者购买接口就会造成损失。
因此需要采用防重放的机制来做请求验证,下面介绍如何对接口做防重放攻击。
带时间戳的签名算法
请求端:timestamp由请求方生成,代表请求被发送的时间(需双方共用一套时间计数系统)随请求参数一并发出,并将 timestamp作为一个参数加入 sign 加密计算。
服务端:平台服务器接到请求后对比当前时间戳,设定不超过30s 即认为该请求正常,否则认为超时拒绝服务。
public class AntiReplayAttackWithTimestamp {
private static final String SECRET_KEY = "your_secret_key"; // 替换为你的secret密钥
private static final long TIME_WINDOW = 30000; // 时间窗口(例如30秒)
// 模拟支付请求类
static class PaymentRequest {
private String transactionId;
private double amount;
private long timestamp;
private String signature;
public PaymentRequest(String transactionId, double amount, long timestamp) {
this.transactionId = transactionId;
this.amount = amount;
this.timestamp = timestamp;
}
public String getTransactionId() {
return transactionId;
}
public double getAmount() {
return amount;
}
public long getTimestamp() {
return timestamp;
}
public void setSignature(String signature) {
this.signature = signature;
}
public String getSignature() {
return signature;
}
}
public static String generateSign(Map<String, String> params, String secret) throws NoSuchAlgorithmException {
// 将参数按ASCII码从小到大排序
Map<String, String> sortedParams = new TreeMap<>(params);
StringBuilder stringA = new StringBuilder();
// 拼接成字符串stringA
for (Map.Entry<String, String> entry : sortedParams.entrySet()) {
if (entry.getValue() != null && !entry.getValue().isEmpty()) {
stringA.append(entry.getKey()).append(entry.getValue());
}
}
// 在stringA最后拼接上secret密钥得到stringSignTemp字符串
String stringSignTemp = stringA.toString() + secret;
// 对stringSignTemp进行MD5加密得到signValue
return md5(stringSignTemp);
}
private static String md5(String input) throws NoSuchAlgorithmException {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] messageDigest = md.digest(input.getBytes());
StringBuilder sb = new StringBuilder();
for (byte b : messageDigest) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
public static void main(String[] args) {
try {
long timestamp = System.currentTimeMillis();
// 模拟一个支付请求
PaymentRequest request = new PaymentRequest("txn-001", 100.0, timestamp);
// 模拟请求参数集合
Map<String, String> params = new TreeMap<>();
params.put("transactionId", request.getTransactionId());
params.put("amount", String.valueOf(request.getAmount()));
params.put("timestamp", String.valueOf(request.getTimestamp()));
// 生成签名
String sign = generateSign(params, SECRET_KEY);
request.setSignature(sign);
System.out.println("Generated Sign: " + sign);
Thread.sleep(15000);
//30s之内的验证;
boolean isValid1 = verifySign(params, sign, SECRET_KEY);
System.out.println("15s, Is Sign Valid: " + isValid1);
// 模拟服务器端验证签名2,时间过了30s了
Thread.sleep(16000);
boolean isValid2 = verifySign(params, sign, SECRET_KEY);
System.out.println("after 30 s, Is Sign Valid: " + isValid2);
} catch (NoSuchAlgorithmException | InterruptedException e) {
e.printStackTrace();
}
}
public static boolean verifySign(Map<String, String> params, String providedSign, String secret) throws NoSuchAlgorithmException {
// 检查时间戳是否在有效时间窗口内
long timestamp = Long.parseLong(params.get("timestamp"));
long currentTime = System.currentTimeMillis();
if (Math.abs(currentTime - timestamp) > TIME_WINDOW) {
return false; // 时间戳不在有效时间窗口内
}
// 生成签名
String generatedSign = generateSign(params, secret);
// 比较生成的签名和提供的签名
return generatedSign.equals(providedSign);
}
}
//输出:
Generated Sign: f245ea6b0812e261fee1effc3bb9ace6
15s, Is Sign Valid: true
after 30 s, Is Sign Valid: false
但是这样还是有缺陷的,以上面的代码为例。攻击者如果在30s之内进行重放攻击那就没办法了,因为30s之内的请求都认为是合法请求,那将这30s设置的小一些,那多小算小了?太小的话,如果网络拥挤,会将正常请求也拒绝掉的 ! 因此将时间改小这不是一个解决问题的根本办法。
所以更进一步地,可以为sign 加上一个随机码(称之为盐值)这里我们定义为 nonce。
带nonce的签名算法
请求方:nonce 是由请求方生成的随机数(在规定的时间内保证有充足的随机数产生,即在60s 内产生的随机数重复的概率为0)也作为参数之一加入 sign 签名。
服务端:服务器接受到请求先判定 nonce 是否被请求过(一般会放到redis中),如果发现 nonce 参数在规定时间是全新的则正常返回结果,反之,则判定是重放攻击拒绝服务。
下面是带nonce的签名算法的实现,主要是验证签名这个函数逻辑发生了变化,因此这里只贴出了验签函数,如下:
public static boolean verifyRequest(Map<String, String> params, String providedSign) throws NoSuchAlgorithmException {
// 验证签名
if (!verifySign(params, providedSign, SECRET_KEY)) {
return false; // 签名不匹配,验证失败
}
// 验证时间戳
long timestamp = Long.parseLong(params.get("timestamp"));
long currentTime = System.currentTimeMillis();
if (Math.abs(currentTime - timestamp) > TIME_WINDOW) {
return false; // 时间戳不在有效时间窗口内
}
// 验证nonce
String nonce = params.get("nonce");
try (Jedis jedis = new Jedis(REDIS_HOST, REDIS_PORT)) {
if (jedis.exists(nonce)) {
return false; // nonce已存在,验证失败
}
jedis.setex(nonce, (int) (TIME_WINDOW / 1000), "1"); // 将nonce存入Redis并设置过期时间
}
return true;
}
//注意,请求的时候也有些变化,需要生成一个随机的nonce加入到参数中作为签名
// 生成唯一标识符和时间戳
String nonce = UUID.randomUUID().toString();
long timestamp = System.currentTimeMillis();
// 模拟一个支付请求
Request request = new Request("txn-001", 100.0, nonce, timestamp);
// 模拟请求参数集合
Map<String, String> params = new TreeMap<>();
params.put("transactionId", request.getTransactionId());
params.put("amount", String.valueOf(request.getAmount()));
params.put("nonce", request.getNonce());
params.put("timestamp", String.valueOf(request.getTimestamp()));
// 生成签名
String sign = generateSign(params, SECRET_KEY);
这里注意对于处理过的请求,将其nance存放到redis的时候设置过期时间为我们自定义的timestamp时间,这里即30s,因为timestamp参数对于超过30s的请求,都认为是非法请求,所以我们只需要存储30s的nonce参数集合即可。否则占用Redis空间会越来越大。
标签:nonce,Java,进阶,timestamp,对接口,sign,params,签名,String From: https://blog.csdn.net/weixin_42627385/article/details/140413062