Java进阶之路66问 | 对接口签名是怎么理解的?如何防止接口重放攻击?

接口签名

为什么需要接口签名?

现在越来越多的公司以 API 的形式对外提供服务,这些 API 接口大多暴露在公网上,所以安全性就变的很重要了。最直接的风险如下:

  • 非法使用 API 服务。(收费接口非法调用)
  • 恶意攻击和破坏。(数据篡改、DOS)

因此需要设计一些接口安全保护的方式来增强接口安全,目前主流的一种方式是 API 签名。

知乎上有个提问:使用了 https 后,还有必要对数据进行签名来确保数据没有被篡改吗?答案是有必要的。

HTTPS 保证通过中间人攻击抓到的报文是密文,无法或者说很难破解。但仍然可以将报文重发,形成 DDOS。同时,如果不签名,只用 HTTP 简单认证,通过抓包,直接可以获取到 Authorization,就可以随意发起请求了。因此最安全的方法就是结合 HTTPS 和 API 签名。

总结一下就是:

  • API 签名保证的是应用的数据安全和防篡改,并且可以作为业务的参数校验和处理重放攻击。
  • HTTPS 保证的是运输层的加密传输,但是无法防御重放攻击。

简单的签名算法

一个服务调用另外一个服务,肯定是有参数需要传递的,我们可以将参数按照以下步骤进行签名:

  1. 对所有非空参数值的的参数进行拼接
key1value1key2value2...
  • 参数名ASCII码从小到大排序(字典序);
    • 如果参数的值为空不参与签名;
    • 参数名区分大小写;
    • 传送的sign参数不参与签名;
  1. 在stringA最后拼接上secret密钥得到stringSignTemp字符串
  2. 对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空间会越来越大。

  • 26
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值