一、什么是重放攻击?

攻击者通过抓包、网络监听等手段,非法截取客户端发送给服务端的完整合法请求报文,不修改报文内容、参数、签名等任何数据,在任意时间多次、重复发送至服务端,利用服务端对重复请求无拦截校验的漏洞,触发业务重复执行的恶意攻击方式。该攻击核心特点是复用合法有效请求,伪装成正常业务请求,服务端无法天然区分是用户正常请求还是攻击者重放请求。

核心攻击原理:正常接口请求无时效性、唯一性校验,合法报文一旦被窃取,即可无限复用,突破前端防抖、按钮置灰等简易防护手段。

典型业务危害:触发重复下单、重复扣款、重复积分发放、重复注册、订单状态错乱、数据冗余脏数据、接口刷量压测、资金损失等核心业务风险。

典型攻击场景:接口回调请求反复提交、请求劫持、重复提交、参数篡改、过期请求刷量。

二、防重放核心四大方案(面试必问)

1. 时间戳 Timestamp(最基础通用、面试核心高频)

一、完整定义(面试背诵版)

时间戳防重放是接口安全最基础、必备的第一道防护。核心思想是:所有接口请求具备时效性,拒绝永久有效报文。客户端每次请求必须携带当前毫秒级时间戳,服务端通过时间窗口校验,判定请求是否过期,彻底拦截长期抓包重放攻击。

二、核心原理

请求头携带客户端毫秒时间戳 X-Timestamp,服务端固定校验两条规则:

  1. 校验客户端时间戳与服务器当前时间差值不超过固定有效时间窗口(生产通用 5 分钟)

  2. 超出时间窗口直接 403 拦截,杜绝老旧报文被无限重放

三、优缺点(面试满分作答)
  • 优点:逻辑极简、零存储成本、高性能无损耗、无需依赖Redis/数据库、兼容所有请求方式,是所有接口防重放的基础标配。

  • 核心缺点无法防止时间窗口期内的重复重放;客户端与服务端时钟不同步、客户端时间异常超前/滞后,会导致合法请求误拦截。

四、生产核心避坑点(面试加分)
  • 必须使用毫秒时间戳:禁止使用秒级时间戳,秒级精度太低,极易被爆破重放。

  • 时间窗口不能过大/过小:过小(1分钟)移动端网络卡顿容易请求超时误拦截;过大(10分钟)会放大重放攻击窗口期风险。

  • 兼容时钟偏移:不做绝对时间相等校验,只做时间绝对值差值校验,适配客户端本地时间不准的场景。

  • 禁止服务端生成时间戳:服务端生成时间戳无法校验请求时效性,失去防重放意义,必须由客户端每次实时生成。

五、时钟偏移解决方案(企业实战)

针对客户端和服务端时间不同步导致的误拦截问题,主流解决方案:

  1. 服务端采用正负时间窗口兼容:允许客户端时间小幅超前/滞后服务端时间。

  2. 前端初始化时同步服务端时间,以服务端时间偏移量修正本地时间戳。

  3. 核心业务不单独依赖时间戳,必须搭配 Nonce随机串 + 签名 组合防护。

六、技术优化方案

单独时间戳只能防过期重放,无法防窗口期内的重复重放,

生产环境必须组合优化:时间戳 + Nonce一次性随机数 + 签名校验,构成基础安全三角防护体系。

七、企业生产级增强代码(完整版、可直接上线)

针对简易版代码存在的异常裸露、返回格式不统一、无法适配集群、容错性差等问题,提供生产可用、规范严谨、面试可深度讲解的增强版本。

1. 全局统一返回结果类(生产标准)
/**
 * 全局统一返回实体
 */
public class Result<T> {
    private Integer code;
    private String msg;
    private T data;

    public static <T> Result<T> fail(Integer code, String msg) {
        Result<T> result = new Result<>();
        result.setCode(code);
        result.setMsg(msg);
        return result;
    }

    public static <T> Result<T> success(T data) {
        Result<T> result = new Result<>();
        result.setCode(200);
        result.setMsg("操作成功");
        result.setData(data);
        return result;
    }

    public static <T> Result<T> success() {
        return success(null);
    }

    // getter/setter
    public Integer getCode() { return code; }
    public void setCode(Integer code) { this.code = code; }
    public String getMsg() { return msg; }
    public void setMsg(String msg) { this.msg = msg; }
    public T getData() { return data; }
    public void setData(T data) { this.data = data; }
}

2. 增强版时间戳工具类(容错+严谨校验)
/**
 * 生产级时间戳防重放工具类
 * 修复:时钟漂移、负数时间戳、空值、格式异常等各类线上问题
 */
public class TimestampCheckUtil {

    // 有效时间窗口 5分钟(毫秒)
    private static final long VALID_WINDOW = 5 * 60 * 1000;

    /**
     * 校验时间戳是否合法有效
     * @param clientTs 客户端毫秒时间戳
     * @return true=合法请求 false=过期/非法重放请求
     */
    public static boolean isValid(Long clientTs) {
        // 1. 空值、负数、0 直接拦截
        if (clientTs == null || clientTs <= 0) {
            return false;
        }
        long now = System.currentTimeMillis();
        // 2. 兼容客户端时间小幅超前/滞后,取绝对值
        long offset = Math.abs(now - clientTs);
        // 3. 超出窗口判定为过期重放
        return offset <= VALID_WINDOW;
    }
}

3. 生产级拦截器(统一JSON返回、异常全覆盖)
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 生产级时间戳防重放拦截器
 * 特性:统一返回格式、全局异常捕获、规范响应头、兼容线上各类异常
 */
@Component
public class AntiReplayTimestampInterceptor implements HandlerInterceptor {

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
    private static final String TS_HEADER = "X-Timestamp";

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 设置响应格式
        response.setContentType("application/json;charset=UTF-8");
        response.setCharacterEncoding("UTF-8");

        // 获取请求头时间戳
        String tsValue = request.getHeader(TS_HEADER);

        // 1. 时间戳为空
        if (tsValue == null || tsValue.trim().isEmpty()) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "请求非法,缺少时间戳")));
            return false;
        }

        Long clientTs;
        try {
            // 2. 时间戳格式校验,防止非数字参数
            clientTs = Long.parseLong(tsValue.trim());
        } catch (NumberFormatException e) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "时间戳格式非法")));
            return false;
        }

        // 3. 时间戳过期/重放校验
        if (!TimestampCheckUtil.isValid(clientTs)) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "请求已过期,禁止重放攻击")));
            return false;
        }

        // 全部校验通过,放行请求
        return true;
    }
}

4. 拦截器配置类(精准路径拦截、放行白名单)
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebSecurityConfig implements WebMvcConfigurer {

    // 注入时间戳防重放拦截器
    private final AntiReplayTimestampInterceptor antiReplayTimestampInterceptor;

    public WebSecurityConfig(AntiReplayTimestampInterceptor antiReplayTimestampInterceptor) {
        this.antiReplayTimestampInterceptor = antiReplayTimestampInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(antiReplayTimestampInterceptor)
                // 拦截所有业务接口
                .addPathPatterns("/api/**")
                // 放行无需安全校验的接口
                .excludePathPatterns("/api/login", "/api/register", "/api/captcha/get");
    }
}

代码实战 & 面试总结(必背)
  • 核心逻辑:通过客户端毫秒时间戳与服务端当前时间做差值,限定5分钟有效窗口,过期请求直接拦截,杜绝永久报文重放。

  • 代码优势:无Redis依赖、无复杂加密、性能极高、适配小型项目快速落地、零业务侵入。

  • 代码缺陷:无法拦截窗口期内的重复请求,单独使用不安全,生产核心业务必须搭配Nonce随机串使用。

  • 前端适配:每次请求请求头携带 X-Timestamp: Date.now() 即可,每次请求时间戳唯一。

八、生产级关键优化点(面试高分核心)
  • 全覆盖异常捕获:针对空时间戳、空字符串、非数字格式、负数时间戳等所有非法场景统一拦截,避免程序报错堆栈泄露。

  • 标准化JSON返回:摒弃原生拼接JSON字符串,使用Jackson序列化,格式统一、无语法错误,适配前端统一异常处理。

  • 解决时钟漂移问题:采用双向时间差值校验,兼容客户端时间快/慢服务端的场景,大幅降低合法请求误拦截率。

  • 精准路径管控:支持自定义白名单,登录、验证码等公共接口放行,不影响基础业务使用。

  • 零内存泄漏:静态全局ObjectMapper,避免频繁创建对象导致JVM内存浪费,适配高并发场景。

九、面试追问标准答案

Q:生产环境只用时间戳防重放可以吗?

A:不可以,仅能作为第一层基础防护。时间戳只能拦截超过5分钟的过期重放报文,无法拦截窗口期内的重复抓包重放。生产环境必须采用 时间戳 + Nonce随机串 + HMAC签名 三重组合防护,时间戳解决过期重放,Nonce解决窗口期重复请求,签名解决参数篡改问题。

Q:为什么要用毫秒级时间戳,不用秒级?

A:秒级时间戳精度过低,1秒内可发送大量重复请求,攻击者可快速爆破重放;毫秒级精度可保证单次请求时间唯一,极大提升攻击门槛。

2. 随机串 Nonce / 一次性随机数(主流搭配时间戳、面试核心高频)

一、完整定义(面试背诵版)

Nonce(Number Used Once,一次性随机数)是单请求唯一、一次性失效的随机字符串,是时间戳方案的核心互补防护手段。

核心思想:解决时间窗口内的重复重放漏洞。在5分钟有效时间窗口内,每一次合法接口请求的Nonce必须全局唯一,一旦被使用立即作废,杜绝攻击者抓包后在窗口期内重复提交请求。

二、核心原理

客户端每次发起接口请求,随机生成一串32位UUID随机串作为 X-Nonce 请求头,配合时间戳组合校验,完整校验逻辑:

  1. 客户端每次请求生成全新、永不重复的Nonce,禁止复用、禁止固定写死;

  2. 服务端校验时间戳合法(未过期)后,立即校验当前Nonce是否存在于Redis缓存;

  3. 不存在则判定为合法请求,写入Redis缓存,设置和时间戳一致的5分钟过期时间;

  4. 已存在则直接拦截,判定为窗口期重放攻击/重复请求。

核心组合流程时效性(Timestamp) + 唯一性(Nonce) = 彻底杜绝所有重放攻击

三、存储设计(单机/分布式全覆盖)
  • 单机场景:可使用本地Caffeine缓存/内存缓存,高性能无中间件依赖,适合单体小型项目。

  • 分布式/微服务场景(生产主流):统一使用Redis全局存储,所有服务节点共享Nonce记录,彻底解决集群多节点校验不一致问题。

  • 缓存设计规范:Key=自定义前缀+Nonce值,过期时间严格对齐5分钟时间窗口,做到过期自动清理。

四、优缺点(面试满分作答)
  • 核心优点:完美弥补时间戳短板,杜绝窗口期内重复重放;单次请求永久失效,安全性极高;搭配时间戳使用无死角防护;适配所有业务接口。

  • 核心缺点:需要依赖缓存存储,有少量Redis开销;需要客户端配合生成随机串;单独使用无法解决请求过期问题,必须和时间戳绑定使用。

五、生产核心避坑点(面试加分)
  • Nonce长度规范:生产统一使用32位无横线UUID,禁止过短随机串,防止随机碰撞导致误拦截。

  • 禁止客户端复用Nonce:每次请求必须重新生成,前端缓存、重复调用接口必须刷新Nonce和Timestamp。

  • 必须原子写入Redis:高并发场景必须使用 SET NX EX 原子命令,防止并发同一请求同时放行,击穿防重机制。

  • 过期时间严格对齐:Nonce缓存过期时间必须和时间戳窗口一致,避免缓存残留、内存堆积或提前失效。

  • 禁止本地缓存做集群校验:微服务集群绝对不能用本地内存,会出现节点校验不一致、重放绕过漏洞。

六、技术适配方案

时间戳负责拦截过期老旧报文,Nonce负责拦截窗口期内重复报文,二者强绑定、缺一不可,构成接口防重放基础双防线,再叠加签名防篡改,形成企业标准安全三角体系。

✅ 企业生产级实战代码(Nonce + Timestamp 组合防重放)

基于前文时间戳代码扩展,实现生产可用、原子防重、异常全覆盖、集群适配的完整组合防重放拦截器,可直接上线。

1. 防重放统一常量
/**
 * 防重放全局常量
 * 统一时间窗口、缓存前缀、请求头字段
 */
public class AntiReplayConst {
    // 有效时间窗口 5分钟
    public static final long VALID_TIME_WINDOW = 5 * 60 * 1000;
    // Redis Nonce缓存前缀
    public static final String REPLAY_NONCE_PREFIX = "replay:nonce:";
    // 请求头常量
    public static final String HEADER_TIMESTAMP = "X-Timestamp";
    public static final String HEADER_NONCE = "X-Nonce";
}

2. Nonce防重Redis工具类(原子操作、生产级)
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import static com.demo.consts.AntiReplayConst.*;

@Component
public class NonceReplayUtil {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 校验Nonce是否重复(原子防重)
     * @param nonce 一次性随机串
     * @return true=重复请求/重放攻击  false=合法首次请求
     */
    public boolean isRepeatNonce(String nonce) {
        String key = REPLAY_NONCE_PREFIX + nonce;
        // NX: 不存在才写入 EX: 设置过期时间
        // 原子操作,杜绝并发重复请求击穿
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(key, "1", VALID_TIME_WINDOW / 1000, TimeUnit.SECONDS);
        // 写入失败 = key已存在 = 重复请求
        return Boolean.FALSE.equals(success);
    }
}

3. 组合防重拦截器(Timestamp + Nonce 双校验)
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import static com.demo.consts.AntiReplayConst.*;

@Component
public class AntiReplayInterceptor implements HandlerInterceptor {

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    @Resource
    private NonceReplayUtil nonceReplayUtil;

    /**
     * 双校验:时间戳过期校验 + Nonce唯一校验
     */
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        response.setContentType("application/json;charset=UTF-8");
        response.setCharacterEncoding("UTF-8");

        // 1. 获取请求头
        String tsStr = request.getHeader(HEADER_TIMESTAMP);
        String nonce = request.getHeader(HEADER_NONCE);

        // 基础非空校验
        if (tsStr == null || tsStr.trim().isEmpty() || nonce == null || nonce.trim().isEmpty()) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "请求缺少安全校验参数")));
            return false;
        }

        // 2. 时间戳合法性校验
        Long clientTs;
        try {
            clientTs = Long.parseLong(tsStr.trim());
        } catch (NumberFormatException e) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "时间戳格式非法")));
            return false;
        }
        long now = System.currentTimeMillis();
        if (Math.abs(now - clientTs) > VALID_TIME_WINDOW) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "请求已过期,禁止重放")));
            return false;
        }

        // 3. Nonce唯一防重校验(核心:拦截窗口期内重放)
        if (nonceReplayUtil.isRepeatNonce(nonce)) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "请勿重复请求,疑似重放攻击")));
            return false;
        }

        return true;
    }
}

4. 刷新拦截器配置
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;

@Configuration
public class WebSecurityConfig implements WebMvcConfigurer {

    @Resource
    private AntiReplayInterceptor antiReplayInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(antiReplayInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/login", "/api/register", "/api/captcha/get");
    }
}

七、代码实战 & 面试必背总结
  • 防护闭环:Timestamp防「过期重放」,Nonce防「窗口期内重复重放」,彻底解决纯时间戳的安全漏洞。

  • 原子安全:基于Redis NX原子命令,解决高并发下同一请求同时通过校验的击穿问题。

  • 集群兼容:全局Redis存储,微服务多节点共享校验数据,无集群漏洞。

  • 自动清理:Nonce缓存随时间窗口自动过期,无需手动清理,无内存堆积压力。

八、高频面试追问

Q:为什么Nonce必须配合时间戳,不能单独使用?

A:如果仅有Nonce无时间戳,攻击者可抓取多年前的旧报文,只要Nonce缓存过期,就能再次重放请求,无法拦截长期过期报文攻击,存在严重安全漏洞。

Q:Nonce缓存为什么要设置过期时间?永久存储不行吗?

A:绝对不行。永久存储会导致Redis数据无限堆积,内存溢出;且5分钟时间窗口外的旧Nonce已经无攻击价值,过期自动清理是生产最优方案。

3. 全局唯一流水号 ID(业务层防重、最终兜底方案)

一、完整定义(面试背诵版)

全局唯一流水号防重,是业务层面最终兜底的防重、幂等方案。区别于 Timestamp、Nonce 的「请求层防重」,流水号属于业务级防重。核心思想:每一笔业务操作对应一个全局唯一、永不重复的业务流水号,服务端以流水号为唯一维度判断业务是否已处理,彻底杜绝重复业务执行。

该方案不依赖请求时效性、不依赖随机串,永久防重、永久幂等,是支付、订单、充值、退款、转账、优惠券发放等资金级核心业务的强制落地方案。

二、核心原理

客户端/上游系统生成全局唯一业务流水号(BizNo),随业务请求一同传入服务端,服务端执行固定校验流程:

  1. 接口接收业务流水号 BizNo;

  2. 优先查询 Redis/数据库,判断当前流水号是否已完成业务处理;

  3. 已存在:直接拦截,返回「请勿重复提交」;

  4. 不存在:执行业务逻辑,成功后将流水号持久化记录,永久标记已处理。

三、流水号生成规范(生产标准)

禁止使用简单UUID(无序、不可追溯),生产主流两种方案:

  • 雪花算法(Snowflake):分布式唯一ID、有序、可溯源、高性能,订单/支付首选;

  • 时间戳+机器码+随机数:自定义业务流水号,可读性强、便于日志排查。

四、适用场景(面试必答)
  • 资金交易类:支付、退款、转账、充值、提现;

  • 权益发放类:优惠券、积分、会员资格、红包发放;

  • 状态变更类:订单确认、发货、完结、回调通知;

  • 所有不允许重复执行、对数据一致性要求极高的核心业务。

五、优缺点(面试满分作答)
  • 核心优点: 1. 无时间窗口限制,永久防重、永久幂等; 2. 业务层面兜底,即使请求层防重(ts+nonce)被绕过,也不会产生脏数据; 3. 便于问题溯源、对账排查、日志追踪; 4. 天然适配分布式、微服务、回调重试场景。

  • 核心缺点: 1. 需要额外存储(数据库/Redis)记录流水号; 2. 属于业务层实现,无法拦截单纯的接口重放攻击,只能兜底数据重复问题; 3. 需要上游配合传递唯一流水号。

六、生产核心避坑点(面试加分)
  • 流水号必须客户端/上游生成:服务端生成无法保证幂等性,重试请求会生成新ID,导致失效。

  • 必须做幂等前置校验:先查再执行业务,禁止先执行再记录,防止并发重复执行。

  • 数据库务必加唯一索引:Redis校验存在极小并发击穿,数据库唯一索引做最终兜底。

  • 幂等记录持久化:核心业务禁止只存Redis,必须落库,防止缓存丢失导致幂等失效。

  • 失败业务允许重试:仅拦截成功状态的重复请求,未完成/失败请求支持正常重试。

七、和 ts/nonce 防重的核心区别(高频面试反问)
  • Timestamp/Nonce:属于接口安全层,防抓包、防重放、防恶意攻击,拦截非法请求;

  • 业务流水号:属于业务数据层,防重复业务执行、保证数据幂等、兜底容错;

生产标准组合:接口层(ts+nonce+sign)防攻击 + 业务层(流水号幂等)防脏数据,双层防护万无一失。

✅ 企业生产级实战代码(全局流水号幂等防重)

提供 Redis快速幂等 + 数据库最终兜底 双方案,金融级落地,可直接上线。

1. 业务幂等常量
/**
 * 业务幂等防重常量
 */
public class IdempotentConst {
    // 业务流水号幂等Key前缀
    public static final String BIZ_IDEM_PREFIX = "idem:bizno:";
    // 幂等过期时间(7天,根据业务对账周期调整)
    public static final long BIZ_IDEM_EXPIRE_SECONDS = 7 * 24 * 60 * 60L;
}

2. 幂等工具类(Redis原子校验)
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
import static com.demo.consts.IdempotentConst.*;

@Component
public class BizIdempotentUtil {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 校验业务流水号是否重复
     * @param bizNo 全局唯一业务流水号
     * @return true=首次请求可执行  false=重复请求/已处理
     */
    public boolean checkBizNoUnique(String bizNo) {
        if (bizNo == null || bizNo.trim().isEmpty()) {
            return false;
        }
        String key = BIZ_IDEM_PREFIX + bizNo;
        // 原子NX防击穿:不存在则写入,标记已处理
        Boolean success = stringRedisTemplate.opsForValue()
                .setIfAbsent(key, "processed", BIZ_IDEM_EXPIRE_SECONDS, TimeUnit.SECONDS);
        return Boolean.TRUE.equals(success);
    }

    /**
     * 删除幂等Key,异常场景支持重试
     * @param bizNo 业务流水号
     */
    public void removeBizKey(String bizNo) {
        if (bizNo == null || bizNo.trim().isEmpty()) {
            return;
        }
        String key = BIZ_IDEM_PREFIX + bizNo;
        stringRedisTemplate.delete(key);
    }
}

3. 数据库兜底SQL(核心必备)
-- 业务订单表、支付表新增流水号唯一索引
-- 杜绝Redis缓存失效、并发击穿导致的重复数据
ALTER TABLE t_order 
ADD UNIQUE INDEX uk_order_biz_no (biz_no);

ALTER TABLE t_pay_record 
ADD UNIQUE INDEX uk_pay_biz_no (biz_no);

4. Controller实战落地(业务层防重完整示例)
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;

@RestController
@RequestMapping("/api/pay")
public class PayController {

    @Resource
    private BizIdempotentUtil bizIdempotentUtil;

    /**
     * 支付提交接口 - 业务幂等防重落地
     */
    @PostMapping("/submit")
    public Result<String> submitPay(String bizNo, Long amount) {
        // 1. 业务流水号幂等校验(前置拦截)
        boolean unique = bizIdempotentUtil.checkBizNoUnique(bizNo);
        if (!unique) {
            return Result.fail(400, "业务已处理,请勿重复提交");
        }

        try {
            // 2. 执行业务逻辑:创建订单、扣余额、生成支付记录
            // ...... 业务代码 ......

            // 3. 数据库落库(唯一索引兜底,重复直接抛异常)
            return Result.success("支付提交成功");
        } catch (Exception e) {
            // 异常删除幂等Key,支持失败重试
            bizIdempotentUtil.removeBizKey(bizNo);
            return Result.fail(500, "业务处理失败,请重试");
        }
    }
}

八、代码实战 & 面试必背总结
  • 双层兜底机制:Redis原子校验拦截大部分重复请求,数据库唯一索引兜底极端并发场景,零重复数据。

  • 支持失败重试:业务异常自动删除幂等标记,避免临时故障导致用户无法重试。

  • 永久业务幂等:区别于5分钟短期防重,适配订单对账、回调重试、长期重试场景。

  • 攻击无感防护:即使攻击者抓取请求长期重放,也无法重复执行业务。

九、高频面试追问

Q:为什么有了ts+nonce还要做业务流水号幂等?

A:ts+nonce 是接口安全防护,只防恶意重放攻击;而网络重试、网关重试、用户刷新页面、回调重试等合法重复请求依然会触发业务重复,必须依靠业务流水号幂等兜底,保证数据绝对一致。

Q:Redis幂等为什么还要加数据库唯一索引?

A:Redis存在缓存过期、节点宕机、主从同步延迟等极端情况,存在极小概率击穿风险,数据库唯一索引是最终数据兜底,保证生产绝对不会产生重复脏数据。

4. 签名 Signature(防篡改 + 防重放双保障、企业级核心安全屏障)

一、完整定义(面试背诵版)

签名机制是接口安全的最终校验屏障,区别于时间戳、Nonce 仅能防重放的单一能力,Signature 同时实现防重放 + 防参数篡改双重防护

。核心思想:基于请求全部明文参数+安全密钥,通过不可逆哈希算法生成唯一指纹,请求一旦参数改动、报文伪造、密钥冒用,签名立即失效,彻底解决接口参数篡改、伪造请求、恶意重放漏洞。

Timestamp、Nonce 只能保证「请求不重复、不过期」,无法防止参数被篡改;签名是唯一能拦截篡改攻击的核心方案,生产开放API、支付、金融、第三方对接接口必须强制开启。

二、核心原理

客户端基于本次请求所有信息,通过固定加密规则生成签名,服务端复刻相同规则校验一致性,完整核心逻辑:

  1. 客户端收集本次请求所有业务参数、时间戳、一次性随机串、应用标识;

  2. 按照固定字典序排序拼接,剔除空参数、无效参数,避免签名不一致;

  3. 拼接私密密钥(AppSecret),通过 HMAC-SHA256 生成不可逆签名指纹;

  4. 请求头携带公开参数与签名,密钥仅服务端留存,客户端永不暴露;

  5. 服务端复刻相同排序、拼接、加密规则,重新计算签名与客户端签名比对,一致则合法,不一致直接拦截。

三、标准企业级签名规则(面试必背)

生产统一标准拼接公式(杜绝乱序导致验签失败):

参数字典升序拼接 + X-Timestamp + X-Nonce + AppSecret → HMAC-SHA256 → 小写32位签名

合法请求头完整携带参数:X-AppId、X-Timestamp、X-Nonce、X-Sign

四、核心作用(满分面试答案)
  • 防参数篡改:攻击者修改URL参数、Body参数、分页参数、金额参数,任意字段改动都会导致签名完全不一致,直接拦截恶意篡改请求。

  • 防伪造请求:密钥仅服务端持有,无密钥无法生成合法签名,杜绝攻击者伪造虚假接口请求。

  • 辅助防重放:绑定单次请求的 ts+nonce,签名与请求强绑定,无法复用旧签名搭配新参数重放。

  • 校验请求合法性:区分合法客户端与非法爬虫、脚本、恶意工具请求。

五、主流加密算法对比(面试高频)
  • MD5:过时、可碰撞、安全性低,仅老旧项目使用,新项目禁止采用。

  • SHA256:单向哈希、无密钥加持易被爆破,适合公开无高安全要求接口。

  • HMAC-SHA256(生产首选):密钥内置哈希运算、抗碰撞、防伪造、安全性最高,金融/支付/开放API强制使用。

六、优缺点分析
  • 优点:双重防护(防重放+防篡改)、加密强度高、抗伪造能力强、适配分布式集群、不依赖大量存储、适配所有接口场景。

  • 缺点:客户端对接成本高、需要前后端/第三方严格对齐加密规则、增加少量接口计算开销、规则不规范易出现误拦截。

七、生产核心避坑点(面试加分项)
  • 必须字典序排序:参数顺序不一致会导致正常请求验签失败,必须固定升序规则。

  • 空参数统一过滤:空值、null、空字符串禁止参与拼接,避免两端拼接不一致。

  • Secret 严禁前端硬编码:密钥泄露可被伪造所有签名,前端需通过加密下发、后端动态配置。

  • Body参数必须参与签名:POST/JSON请求必须序列化Body摘要参与签名,仅校验URL参数存在篡改漏洞。

  • 禁止固定签名:每次请求必须基于全新ts+nonce生成新签名,禁止复用签名。

✅ 企业生产级实战代码(HMAC-SHA256 完整签名+验签)

适配前文 ts+nonce 防重放逻辑,完整打通时间戳+随机串+签名三层接口安全防护,可直接上线替换原有拦截器。

1. 补充全局安全常量
/**
 * 防重放&签名全局常量
 */
public class AntiReplayConst {
    // 有效时间窗口 5分钟
    public static final long VALID_TIME_WINDOW = 5 * 60 * 1000;
    // Redis Nonce缓存前缀
    public static final String REPLAY_NONCE_PREFIX = "replay:nonce:";

    // 签名配置
    public static final String APP_ID = "business_api_001";
    public static final String APP_SECRET = "Jf9s2kL8mP0qR3tX7yZ1bN4vC6dG8hK";

    // 请求头常量
    public static final String HEADER_TIMESTAMP = "X-Timestamp";
    public static final String HEADER_NONCE = "X-Nonce";
    public static final String HEADER_APP_ID = "X-AppId";
    public static final String HEADER_SIGN = "X-Sign";
}

2. 生产级HMAC-SHA256签名工具类
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.digest.HMac;
import cn.hutool.crypto.digest.HmacAlgorithm;
import java.util.Map;
import java.util.TreeMap;

/**
 * 生产级签名工具类
 * 规则:参数字典升序 + 时间戳 + nonce + 密钥 HMAC-SHA256加密
 */
public class SignUtil {

    /**
     * 生成签名
     * @param params 请求参数
     * @param timestamp 时间戳
     * @param nonce 一次性随机串
     * @param secret 密钥
     * @return 小写签名字符串
     */
    public static String generateSign(Map<String, String> params, String timestamp, String nonce, String secret) {
        // 1. 参数按字典升序排序,保证顺序绝对统一
        TreeMap<String, String> sortMap = new TreeMap<>(params);
        StringBuilder sb = new StringBuilder();

        // 2. 拼接非空参数,过滤空值防止两端不一致
        for (Map.Entry<String, String> entry : sortMap.entrySet()) {
            if (StrUtil.isNotBlank(entry.getValue())) {
                sb.append(entry.getKey()).append(entry.getValue());
            }
        }

        // 3. 拼接时间戳、随机串、密钥
        sb.append(timestamp).append(nonce).append(secret);

        // 4. HMAC-SHA256加密,转小写统一格式
        HMac hMac = new HMac(HmacAlgorithm.HmacSHA256, secret.getBytes());
        return hMac.digestHex(sb.toString()).toLowerCase();
    }

    /**
     * 校验签名
     * @param params 请求参数
     * @param timestamp 时间戳
     * @param nonce 一次性随机串
     * @param secret 密钥
     * @param clientSign 客户端签名
     * @return true=签名合法 false=篡改/伪造
     */
    public static boolean verifySign(Map<String, String> params, String timestamp, String nonce, String secret, String clientSign) {
        if (StrUtil.isBlank(clientSign)) {
            return false;
        }
        String serverSign = generateSign(params, timestamp, nonce, secret);
        // 服务端计算签名与客户端签名强一致比对
        return serverSign.equals(clientSign);
    }
}

3. 终极三层防护拦截器(ts + nonce + sign)
import cn.hutool.core.util.StrUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.HashMap;
import java.util.Map;
import static com.demo.consts.AntiReplayConst.*;

@Component
public class FullSecurityInterceptor implements HandlerInterceptor {

    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    @Resource
    private NonceReplayUtil nonceReplayUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        response.setContentType("application/json;charset=UTF-8");
        response.setCharacterEncoding("UTF-8");

        // 1. 获取全部安全请求头
        String appId = request.getHeader(HEADER_APP_ID);
        String tsStr = request.getHeader(HEADER_TIMESTAMP);
        String nonce = request.getHeader(HEADER_NONCE);
        String clientSign = request.getHeader(HEADER_SIGN);

        // 基础非空校验
        if (StrUtil.hasBlank(appId, tsStr, nonce, clientSign)) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "缺少安全校验请求头,请求非法")));
            return false;
        }

        // 2. 校验应用合法性
        if (!APP_ID.equals(appId)) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "非法客户端")));
            return false;
        }

        // 3. 时间戳过期校验
        Long clientTs;
        try {
            clientTs = Long.parseLong(tsStr.trim());
        } catch (NumberFormatException e) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "时间戳格式非法")));
            return false;
        }
        long now = System.currentTimeMillis();
        if (Math.abs(now - clientTs) > VALID_TIME_WINDOW) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "请求已过期,禁止重放")));
            return false;
        }

        // 4. Nonce防重放校验(拦截窗口期重复请求)
        if (nonceReplayUtil.isRepeatNonce(nonce)) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "请勿重复请求,疑似重放攻击")));
            return false;
        }

        // 5. 组装请求参数,验签防篡改(核心)
        Map<String, String> paramMap = new HashMap<>();
        request.getParameterMap().forEach((k, v) -> paramMap.put(k, v[0]));
        boolean signPass = SignUtil.verifySign(paramMap, tsStr, nonce, APP_SECRET, clientSign);
        if (!signPass) {
            response.getWriter().write(OBJECT_MAPPER.writeValueAsString(Result.fail(403, "签名非法,参数已被篡改或请求伪造")));
            return false;
        }

        // 三层校验全部通过,放行请求
        return true;
    }
}

4. 刷新拦截器配置
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import javax.annotation.Resource;

@Configuration
public class WebSecurityConfig implements WebMvcConfigurer {

    @Resource
    private FullSecurityInterceptor fullSecurityInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(fullSecurityInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/login", "/api/register", "/api/captcha/get");
    }
}

八、代码实战 & 生产落地核心总结(面试必背)
  • 全链路安全闭环:本次终极拦截器整合时间戳时效校验、Nonce唯一防重、HMAC-SHA256签名验签三层接口防护,彻底解决接口过期重放、窗口期重复请求、参数篡改、请求伪造四大核心安全漏洞,是互联网金融、支付交易、开放API接口的标准落地方案。

  • 杜绝各类线上漏洞:相较于单一防重方案,组合防护可拦截所有恶意攻击场景,包括抓包长期重放、短时批量重放、参数篡改薅羊毛、伪造非法请求等,同时规避时钟偏移、参数乱序、空值拼接导致的合法请求误拦截问题,兼容性与安全性拉满。

  • 高并发集群适配:基于Redis NX EX原子命令实现分布式防重,规避微服务多节点并发击穿问题;统一字典序参数签名规则,解决集群环境、前后端参数拼接不一致导致的验签失败,完全适配线上集群高并发场景。

  • 生产极致容错设计:全覆盖空值、格式异常、非法应用、签名不匹配等异常场景,统一标准化JSON错误返回,无原生异常堆栈泄露,兼顾安全性与用户体验;同时区分非法攻击拦截与正常业务异常,不影响合法请求重试机制。

  • 轻量化高性能落地:整体方案无复杂依赖、加密运算性能损耗极低,Redis缓存自动过期清理,无内存堆积、无数据冗余,适配大流量接口场景,兼顾安全与接口响应性能。

九、高频面试深度追问(满分答疑)

Q:为什么签名必须结合Timestamp+Nonce,不能单独使用签名防重放?

A:单独签名只能防篡改、防伪造,无法防重放攻击。攻击者可抓取一份合法带签名的报文,在任意时间无限重放请求,签名不会失效。绑定Timestamp实现请求时效性、绑定Nonce实现单次请求唯一性,三者结合才能同时实现防篡改、防伪造、防过期重放、防短时重复重放的全维度防护。

Q:POST JSON请求只校验URL参数签名有什么漏洞?如何解决?

A:存在严重参数篡改漏洞,攻击者可随意修改请求Body内的核心业务参数(金额、订单号、权益数量等),URL参数不变则签名校验通过,成功绕过防护。生产解决方案:对POST请求的JSON Body进行序列化摘要,将Body摘要一并纳入签名拼接规则,实现全参数校验。

Q:HMAC-SHA256相比普通SHA256安全优势是什么?

A:普通SHA256是无密钥单向哈希,参数泄露后可被暴力碰撞、伪造签名;HMAC-SHA256基于密钥加密哈希,密钥仅服务端私密留存,即使请求参数完全泄露,攻击者也无法生成合法签名,彻底杜绝签名伪造,安全性适配金融级业务场景。

Q:线上出现偶尔验签失败是什么原因?如何排查解决?

A:常见原因包含:前后端参数排序不一致、空值参数参与拼接、Body序列化格式不统一、客户端时间偏移过大、Nonce重复使用。解决方案:统一字典升序拼接规则、过滤所有空参数、固定JSON序列化格式、前端同步服务端时间、每次请求刷新全新Timestamp和Nonce。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐