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

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

时间:2024-07-14 10:30:17浏览次数:15  
标签:nonce Java 进阶 timestamp 对接口 sign params 签名 String

接口签名

为什么需要接口签名?

现在越来越多的公司以 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空间会越来越大。

标签:nonce,Java,进阶,timestamp,对接口,sign,params,签名,String
From: https://blog.csdn.net/weixin_42627385/article/details/140413062

相关文章

  • Java进阶之路66问 | 什么是幂等性?如何保证接口的冥等性?
    API设计中的幂等性什么是幂等性幂等性是指无论一个操作执行多少次,最终的结果都是一样的。也就是说,重复执行同一个操作不会改变系统的状态或产生不同的结果。想象你在一栋大楼里等电梯。你按下电梯按钮的5楼按钮键,电梯开始向5楼的位置移动。后面即使你再按几次5楼按钮键,......
  • Java优雅使用线程池连接SFTP进行文件上传下载 解决请求量大问题
    Java优雅使用线程池连接SFTP进行文件上传下载解决请求量大问题使用FTP连接池降低资源消耗,提高响应速率为什么要使用线程池连接SFTP呢?在Java中使用线程池来连接SFTP(SecureFileTransferProtocol)工具的原因主要与性能、资源管理和效率有关。以下是一些关键原因:资源管......
  • 花几千上万学习Java,真没必要!(四)
    1、关系运算符:packagetest.com;publicclassRelationalArithmetic{ /*关系运算符用于比较两个值之间的关系,关系运算符的结果是一个布尔值,即true或false。 Java提供了6种关系运算符: >:大于 <:小于 >=:大于等于 <=:小于等于 ==:等于 !=:不等于*/publicstaticvoi......
  • Java计算机毕业设计个人健康管理系统的设计与实现(开题报告+源码+论文)
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容研究背景随着生活节奏的加快和健康意识的增强,个人健康管理成为了现代社会的重要议题。传统医疗模式下,人们往往只在出现症状时才寻求医生的帮助,这种“被动医疗......
  • Java计算机毕业设计的高校疫情防控系统(开题报告+源码+论文)
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容研究背景在全球新冠疫情持续蔓延的背景下,高校作为人群密集、流动性大的场所,其疫情防控工作面临着前所未有的挑战。传统的疫情防控手段难以有效应对疫情传播的......
  • Java计算机毕业设计校园二手物品交易平台(开题+源码+论文)
    本系统(程序+源码)带文档lw万字以上 文末可获取一份本项目的java源码和数据库参考。系统程序文件列表开题报告内容研究背景:随着高等教育的普及与校园生活的日益丰富,学生群体对各类学习资料、生活用品及电子产品的需求日益增长。同时,由于更新换代迅速及经济因素的考量,大量二......
  • java基础篇(java面向对象基础)
            面向对象编程(OOP)是Java编程语言的核心特性之一。以下是Java面向对象编程的一些基础概念和示例:类(Class) 类是对象的蓝图或模板,定义了对象的属性和行为。publicclassPerson{//属性Stringname;intage;//构造方法publicP......
  • Java基础教程秘籍-2章_基本语法上
    ......
  • 基于Javaweb电动车在线租赁系统设计与实现
      博主介绍:黄菊华老师《Vue.js入门与商城开发实战》《微信小程序商城开发》图书作者,CSDN博客专家,在线教育专家,CSDN钻石讲师;专注大学生毕业设计教育和辅导。所有项目都配有从入门到精通的基础知识视频课程,学习后应对毕业设计答辩。项目配有对应开发文档、开题报告、任务书......
  • java基础
    一:接口和抽象类①接口的定义:声明方式:接口使用interface关键字来声明,后跟接口的名称和接口体(包含常量和方法声明的代码块)publicinterfaceObjectServiceextendsIService<ObjectDO>{/***创建扶优对象信息**@paramcreateReqVO创建信息*@......