防重放攻击

Python3.8

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

防重放攻击(Replay Attack)是确保API安全的关键措施。以下是一套完整、可落地的防重放攻击方案,从简单到高级逐步深入。

一、核心原理与基础方案

1. 攻击原理

攻击者截获合法请求后,原封不动地重新发送到服务器,让服务器误以为是合法请求。

2. 三大核心防御机制

// 每个请求必须包含的三个要素
{
  "data": {...},          // 业务数据
  "timestamp": 1651234567890,  // 时间戳
  "nonce": "a1b2c3d4e5f6",     // 随机数
  "signature": "..."           // 签名(基于以上所有内容)
}

二、基础实现方案

方案1:时间戳验证(简单有效)

@Component
public class TimestampValidator {
    
    // 时间窗口(单位:毫秒)
    private static final long TIME_WINDOW = 5 * 60 * 1000; // 5分钟
    
    public boolean validate(long clientTimestamp) {
        long currentTime = System.currentTimeMillis();
        long timeDiff = Math.abs(currentTime - clientTimestamp);
        
        // 1. 检查时间戳是否在合理范围内
        if (timeDiff > TIME_WINDOW) {
            return false; // 请求超时或时间戳异常
        }
        
        // 2. 检查时间戳是否未来时间(允许少量误差)
        if (clientTimestamp > currentTime + 10000) { // 未来10秒
            return false;
        }
        
        return true;
    }
}

方案2:Nonce(一次随机数)验证

@Service
public class NonceValidator {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    // Nonce过期时间(略大于时间窗口)
    private static final long NONCE_EXPIRE = 10 * 60; // 10分钟
    
    /**
     * 验证Nonce是否有效
     */
    public boolean validateNonce(String nonce, long timestamp) {
        // 1. Nonce格式校验(可选)
        if (!isValidNonceFormat(nonce)) {
            return false;
        }
        
        String redisKey = "nonce:" + nonce;
        
        // 2. 检查是否已使用过
        Boolean exists = redisTemplate.hasKey(redisKey);
        if (Boolean.TRUE.equals(exists)) {
            return false; // Nonce已使用
        }
        
        // 3. 存储Nonce,设置过期时间
        redisTemplate.opsForValue().set(
            redisKey, 
            String.valueOf(timestamp),
            NONCE_EXPIRE,
            TimeUnit.SECONDS
        );
        
        return true;
    }
    
    /**
     * 批量Nonce验证(针对高并发场景优化)
     */
    public boolean validateNonceBatch(String nonce, long timestamp) {
        String redisKey = "nonce:" + nonce;
        
        // 使用SETNX实现原子性检查
        Boolean success = redisTemplate.opsForValue()
            .setIfAbsent(redisKey, String.valueOf(timestamp), 
                        NONCE_EXPIRE, TimeUnit.SECONDS);
        
        return Boolean.TRUE.equals(success);
    }
    
    private boolean isValidNonceFormat(String nonce) {
        // 基本格式校验,防止恶意过长的key
        return nonce != null && 
               nonce.length() >= 8 && 
               nonce.length() <= 64;
    }
}

三、完整实战方案

3.1 完整拦截器实现

@Slf4j
@Component
public class ReplayAttackInterceptor implements HandlerInterceptor {
    
    @Autowired
    private TimestampValidator timestampValidator;
    
    @Autowired
    private NonceValidator nonceValidator;
    
    @Autowired
    private SignatureValidator signatureValidator;
    
    @Override
    public boolean preHandle(HttpServletRequest request, 
                            HttpServletResponse response, 
                            Object handler) throws Exception {
        
        // 1. 获取请求头中的安全参数
        String timestampStr = request.getHeader("X-Timestamp");
        String nonce = request.getHeader("X-Nonce");
        String signature = request.getHeader("X-Signature");
        String clientId = request.getHeader("X-Client-Id");
        
        // 2. 参数基础校验
        if (StringUtils.isAnyBlank(timestampStr, nonce, signature, clientId)) {
            sendError(response, "Missing security headers");
            return false;
        }
        
        // 3. 解析时间戳
        long timestamp;
        try {
            timestamp = Long.parseLong(timestampStr);
        } catch (NumberFormatException e) {
            sendError(response, "Invalid timestamp format");
            return false;
        }
        
        // 4. 按顺序执行验证(优化性能:先验证时间戳,再验证nonce)
        
        // 4.1 时间戳验证
        if (!timestampValidator.validate(timestamp)) {
            sendError(response, "Invalid or expired timestamp");
            return false;
        }
        
        // 4.2 构造请求唯一标识(clientId + nonce)
        String requestId = clientId + ":" + nonce;
        
        // 4.3 Nonce验证(防重放核心)
        if (!nonceValidator.validateNonce(requestId, timestamp)) {
            sendError(response, "Request already processed or invalid nonce");
            return false;
        }
        
        // 4.4 签名验证(确保数据完整性)
        if (!signatureValidator.validate(request, timestamp, nonce, clientId, signature)) {
            sendError(response, "Invalid signature");
            // 清除已存储的nonce(防止签名验证失败后nonce被占用)
            nonceValidator.removeNonce(requestId);
            return false;
        }
        
        // 5. 将验证信息放入请求上下文,供后续使用
        RequestContextHolder.setRequestId(requestId);
        RequestContextHolder.setTimestamp(timestamp);
        
        return true;
    }
    
    private void sendError(HttpServletResponse response, String message) throws IOException {
        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType("application/json");
        response.getWriter().write(
            String.format("{\"code\":403,\"message\":\"%s\"}", message)
        );
    }
}

// 配置拦截器
@Configuration
public class WebSecurityConfig implements WebMvcConfigurer {
    
    @Autowired
    private ReplayAttackInterceptor replayAttackInterceptor;
    
    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(replayAttackInterceptor)
                .addPathPatterns("/api/**")
                .excludePathPatterns("/api/public/**", "/api/auth/login");
    }
}

3.2 签名验证实现

@Component
public class SignatureValidator {
    
    @Autowired
    private ClientKeyService clientKeyService;
    
    /**
     * 验证请求签名
     */
    public boolean validate(HttpServletRequest request, 
                           long timestamp, 
                           String nonce,
                           String clientId,
                           String clientSignature) {
        
        try {
            // 1. 获取客户端密钥
            String clientSecret = clientKeyService.getClientSecret(clientId);
            if (clientSecret == null) {
                log.warn("Client not found: {}", clientId);
                return false;
            }
            
            // 2. 获取请求体(如果是POST/PUT)
            String requestBody = "";
            if ("POST".equalsIgnoreCase(request.getMethod()) || 
                "PUT".equalsIgnoreCase(request.getMethod()) ||
                "PATCH".equalsIgnoreCase(request.getMethod())) {
                
                requestBody = getRequestBody(request);
                
                // 缓存请求体,供后续使用
                request.setAttribute("cachedRequestBody", requestBody);
            }
            
            // 3. 构造待签名字符串(关键步骤)
            String stringToSign = buildStringToSign(
                request.getMethod(),
                request.getRequestURI(),
                timestamp,
                nonce,
                requestBody,
                getSortedQueryParams(request)
            );
            
            // 4. 计算服务端签名
            String serverSignature = calculateHMAC(stringToSign, clientSecret);
            
            // 5. 安全比较签名(防止时序攻击)
            return MessageDigest.isEqual(
                serverSignature.getBytes(StandardCharsets.UTF_8),
                clientSignature.getBytes(StandardCharsets.UTF_8)
            );
            
        } catch (Exception e) {
            log.error("Signature validation error", e);
            return false;
        }
    }
    
    /**
     * 构造待签名字符串(规范必须一致)
     */
    private String buildStringToSign(String method, 
                                    String uri,
                                    long timestamp,
                                    String nonce,
                                    String body,
                                    String queryParams) {
        
        StringBuilder sb = new StringBuilder();
        
        // 固定顺序,非常重要!
        sb.append(method.toUpperCase()).append("\n")
          .append(uri).append("\n")
          .append(timestamp).append("\n")
          .append(nonce).append("\n");
        
        // 如果有查询参数
        if (StringUtils.isNotBlank(queryParams)) {
            sb.append(queryParams).append("\n");
        }
        
        // 请求体
        if (StringUtils.isNotBlank(body)) {
            // 对body进行规范化(如去除多余空格)
            String normalizedBody = normalizeBody(body);
            sb.append(normalizedBody);
        }
        
        return sb.toString();
    }
    
    /**
     * 计算HMAC签名
     */
    private String calculateHMAC(String data, String key) {
        try {
            Mac hmac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            hmac.init(secretKey);
            byte[] hash = hmac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            
            // 转为十六进制字符串
            return bytesToHex(hash);
        } catch (Exception e) {
            throw new RuntimeException("Failed to calculate HMAC", e);
        }
    }
    
    /**
     * 获取排序后的查询参数(确保顺序一致)
     */
    private String getSortedQueryParams(HttpServletRequest request) {
        Map<String, String[]> params = request.getParameterMap();
        if (params.isEmpty()) {
            return "";
        }
        
        // 按键排序
        List<String> paramPairs = new ArrayList<>();
        for (String key : params.keySet().stream().sorted().collect(Collectors.toList())) {
            String[] values = params.get(key);
            if (values != null && values.length > 0) {
                // 只取第一个值,或多个值排序后拼接
                Arrays.sort(values);
                paramPairs.add(key + "=" + values[0]);
            }
        }
        
        return String.join("&", paramPairs);
    }
    
    /**
     * 获取请求体(可重复读取)
     */
    private String getRequestBody(HttpServletRequest request) throws IOException {
        // 检查是否已经缓存
        String cachedBody = (String) request.getAttribute("cachedRequestBody");
        if (cachedBody != null) {
            return cachedBody;
        }
        
        // 使用ContentCachingRequestWrapper或直接读取
        return IOUtils.toString(request.getReader());
    }
    
    private String normalizeBody(String body) {
        // 移除所有空白字符(根据业务需求调整)
        return body.replaceAll("\\s+", "");
    }
    
    private String bytesToHex(byte[] bytes) {
        StringBuilder hexString = new StringBuilder();
        for (byte b : bytes) {
            String hex = Integer.toHexString(0xff & b);
            if (hex.length() == 1) {
                hexString.append('0');
            }
            hexString.append(hex);
        }
        return hexString.toString();
    }
}

四、高级优化方案

4.1 滑动时间窗口(应对时钟不同步)

@Component
public class SlidingWindowValidator {
    
    // 使用Redis的Sorted Set实现滑动窗口
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    private static final long WINDOW_SIZE = 10 * 60 * 1000; // 10分钟窗口
    private static final String NONCE_WINDOW_KEY = "nonce:window:";
    
    /**
     * 滑动窗口验证(适用于分布式环境)
     */
    public boolean validateWithSlidingWindow(String clientId, 
                                           String nonce, 
                                           long timestamp) {
        
        String windowKey = NONCE_WINDOW_KEY + clientId;
        double score = timestamp;
        
        // 1. 清理窗口外的旧数据
        double minScore = score - WINDOW_SIZE;
        redisTemplate.opsForZSet().removeRangeByScore(windowKey, 0, minScore);
        
        // 2. 检查nonce是否在当前窗口中
        Boolean exists = redisTemplate.opsForZSet().score(windowKey, nonce) != null;
        if (Boolean.TRUE.equals(exists)) {
            return false; // Nonce已存在
        }
        
        // 3. 添加新的nonce到窗口
        redisTemplate.opsForZSet().add(windowKey, nonce, score);
        
        // 4. 设置整个key的过期时间(窗口大小+缓冲)
        redisTemplate.expire(windowKey, WINDOW_SIZE / 1000 + 60, TimeUnit.SECONDS);
        
        return true;
    }
}

4.2 请求序列号方案(针对API调用)

@Service
public class SequenceValidator {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * 序列号验证(确保请求顺序)
     */
    public boolean validateSequence(String clientId, long sequence) {
        String key = "seq:" + clientId;
        
        // 1. 获取上次的序列号
        String lastSeqStr = redisTemplate.opsForValue().get(key);
        long lastSequence = lastSeqStr != null ? Long.parseLong(lastSeqStr) : 0;
        
        // 2. 验证序列号(必须递增)
        if (sequence <= lastSequence) {
            log.warn("Invalid sequence: {} <= {}", sequence, lastSequence);
            return false;
        }
        
        // 3. 验证序列号跳跃(防止过大跳跃)
        if (sequence > lastSequence + 1000) {
            log.warn("Sequence jump too large: {} > {}", sequence, lastSequence + 1000);
            return false;
        }
        
        // 4. 更新序列号(原子操作)
        redisTemplate.opsForValue().set(key, String.valueOf(sequence), 1, TimeUnit.HOURS);
        
        return true;
    }
}

4.3 基于JWT的防重放方案

@Component
public class JwtReplayProtection {
    
    @Autowired
    private RedisTemplate<String, String> redisTemplate;
    
    /**
     * JWT + jti(JWT ID)防重放
     */
    public boolean validateJwtReplay(String jti, long exp) {
        String key = "jwt:" + jti;
        
        // 1. 检查是否已使用
        if (Boolean.TRUE.equals(redisTemplate.hasKey(key))) {
            return false;
        }
        
        // 2. 存储jti,设置过期时间(与JWT过期时间一致)
        long ttl = exp - System.currentTimeMillis() / 1000;
        if (ttl > 0) {
            redisTemplate.opsForValue().set(key, "used", ttl, TimeUnit.SECONDS);
        }
        
        return true;
    }
}

五、前端实现示例

5.1 前端请求封装

class SecureRequest {
    constructor(clientId, clientSecret) {
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.nonceCache = new Set(); // 本地缓存,可选
    }
    
    async request(method, url, data = null) {
        const timestamp = Date.now();
        const nonce = this.generateNonce();
        
        // 构造签名
        const signature = await this.generateSignature(
            method,
            url,
            timestamp,
            nonce,
            data
        );
        
        // 设置请求头
        const headers = {
            'X-Client-Id': this.clientId,
            'X-Timestamp': timestamp.toString(),
            'X-Nonce': nonce,
            'X-Signature': signature,
            'Content-Type': 'application/json'
        };
        
        // 发送请求
        const options = {
            method: method,
            headers: headers
        };
        
        if (data && (method === 'POST' || method === 'PUT' || method === 'PATCH')) {
            options.body = JSON.stringify(data);
        }
        
        const response = await fetch(url, options);
        
        // 处理重放错误(自动重试)
        if (response.status === 403) {
            const error = await response.json();
            if (error.message.includes('Request already processed')) {
                // 使用新的nonce重试
                return this.request(method, url, data);
            }
        }
        
        return response;
    }
    
    generateNonce() {
        // 生成足够随机的nonce
        return crypto.randomUUID() || 
               Date.now().toString(36) + Math.random().toString(36).substr(2);
    }
    
    async generateSignature(method, url, timestamp, nonce, data) {
        // 构造待签名字符串(必须与后端一致)
        const stringToSign = [
            method.toUpperCase(),
            new URL(url).pathname,
            timestamp,
            nonce,
            data ? JSON.stringify(data).replace(/\s+/g, '') : ''
        ].join('\n');
        
        // 使用HMAC SHA256计算签名
        const encoder = new TextEncoder();
        const keyData = encoder.encode(this.clientSecret);
        const msgData = encoder.encode(stringToSign);
        
        const cryptoKey = await window.crypto.subtle.importKey(
            'raw',
            keyData,
            { name: 'HMAC', hash: 'SHA-256' },
            false,
            ['sign']
        );
        
        const signature = await window.crypto.subtle.sign(
            'HMAC',
            cryptoKey,
            msgData
        );
        
        // 转为hex
        return Array.from(new Uint8Array(signature))
            .map(b => b.toString(16).padStart(2, '0'))
            .join('');
    }
}

// 使用示例
const secureClient = new SecureRequest('your-client-id', 'your-secret');
secureClient.request('POST', '/api/user/update', { phone: '13812345678' })
    .then(response => response.json())
    .then(data => console.log(data));

5.2 Axios拦截器实现

import axios from 'axios';

// 创建axios实例
const secureAxios = axios.create();

// 请求拦截器
secureAxios.interceptors.request.use(async (config) => {
    const timestamp = Date.now();
    const nonce = generateNonce();
    
    // 获取待签名数据
    const data = config.data || '';
    const url = new URL(config.url, window.location.origin);
    
    // 构造签名字符串
    const stringToSign = buildStringToSign(
        config.method.toUpperCase(),
        url.pathname,
        timestamp,
        nonce,
        data,
        config.params
    );
    
    // 计算签名
    const signature = await calculateSignature(stringToSign);
    
    // 添加安全头
    config.headers['X-Timestamp'] = timestamp;
    config.headers['X-Nonce'] = nonce;
    config.headers['X-Signature'] = signature;
    config.headers['X-Client-Id'] = CLIENT_ID;
    
    return config;
}, (error) => {
    return Promise.reject(error);
});

// 响应拦截器(处理重放错误)
secureAxios.interceptors.response.use(
    (response) => response,
    async (error) => {
        if (error.response && error.response.status === 403) {
            const message = error.response.data?.message || '';
            
            // 如果是重放攻击错误,重新生成nonce重试
            if (message.includes('already processed') || 
                message.includes('invalid nonce')) {
                
                // 清除已使用的nonce
                clearUsedNonce();
                
                // 重试原请求(最多3次)
                const config = error.config;
                config.__retryCount = config.__retryCount || 0;
                
                if (config.__retryCount < 3) {
                    config.__retryCount += 1;
                    
                    // 延迟重试(指数退避)
                    const delay = Math.pow(2, config.__retryCount) * 100;
                    await new Promise(resolve => setTimeout(resolve, delay));
                    
                    return secureAxios(config);
                }
            }
        }
        return Promise.reject(error);
    }
);

六、测试与监控

6.1 测试用例

@SpringBootTest
class ReplayAttackTest {
    
    @Autowired
    private ReplayAttackInterceptor interceptor;
    
    @Test
    void testReplayAttack() {
        // 第一次请求
        MockHttpServletRequest request1 = createRequest("nonce123", 1000);
        assertTrue(interceptor.preHandle(request1, response, handler));
        
        // 完全相同的第二次请求(应该被拒绝)
        MockHttpServletRequest request2 = createRequest("nonce123", 1000);
        assertFalse(interceptor.preHandle(request2, response, handler));
    }
    
    @Test
    void testExpiredTimestamp() {
        // 使用过时的时间戳
        long expiredTimestamp = System.currentTimeMillis() - 10 * 60 * 1000;
        MockHttpServletRequest request = createRequest("nonce456", expiredTimestamp);
        
        assertFalse(interceptor.preHandle(request, response, handler));
    }
}

6.2 监控与告警

@Component
@Slf4j
public class ReplayAttackMonitor {
    
    @Autowired
    private MeterRegistry meterRegistry;
    
    private Counter replayAttemptCounter;
    private Timer requestValidationTimer;
    
    @PostConstruct
    public void init() {
        replayAttemptCounter = Counter.builder("api.security.replay_attempts")
            .description("Number of replay attack attempts")
            .register(meterRegistry);
            
        requestValidationTimer = Timer.builder("api.security.validation_time")
            .description("Time taken for security validation")
            .register(meterRegistry);
    }
    
    public void recordReplayAttempt(String clientId, String ip) {
        replayAttemptCounter.increment();
        
        log.warn("Replay attack detected - Client: {}, IP: {}", clientId, ip);
        
        // 发送告警(如Slack、邮件)
        if (replayAttemptCounter.count() > 100) { // 阈值
            sendAlert("High volume of replay attacks detected");
        }
    }
}

七、最佳实践总结

  1. 多层防御:时间戳 + Nonce + 签名三重验证
  2. 时间窗口:5-10分钟为宜,平衡安全性与时钟偏差容忍度
  3. Nonce存储:使用Redis,设置合理过期时间
  4. 签名规范:严格按照固定顺序构造签名字符串
  5. 时钟同步:确保服务器时间准确(使用NTP)
  6. 错误处理:不泄露过多信息(如不提示是时间戳还是nonce问题)
  7. 性能优化
    • 先验证时间戳(开销最小)
    • 使用Redis原子操作
    • 缓存客户端密钥
  8. 监控告警:记录重放攻击尝试,设置阈值告警

这个方案可以有效地防止99%以上的重放攻击,同时保持了良好的性能和可用性。根据业务的具体安全要求,可以适当调整时间窗口大小和验证严格程度。

是的,前端签名是整个防重放攻击方案中最关键的环节!

如果签名可以被伪造或绕过,那么整个安全体系就失效了。让我详细解释前端如何正确实现签名:

一、前端签名的重要性

攻击者可以轻易获取:
1. ✅ 时间戳(从请求头)
2. ✅ Nonce(从请求头)  
3. ✅ 请求URL和参数(从网络抓包)
4. ✅ 请求体数据(从网络抓包)

攻击者唯一无法获取/伪造的是:
❌ 客户端密钥(client_secret)
❌ 基于密钥计算出的签名

如果没有签名验证,攻击者可以:

  1. 修改请求数据后重新发送
  2. 使用有效的时间戳和nonce构造新请求
  3. 执行未授权的操作

二、前端签名完整实现方案

2.1 基础签名方案(HMAC-SHA256)

class SignatureGenerator {
    constructor(clientId, clientSecret) {
        this.clientId = clientId;
        this.clientSecret = clientSecret;
        this.algorithm = 'SHA-256';
    }
    
    /**
     * 生成请求签名
     */
    async generateSignature(requestData) {
        // 1. 构造待签名字符串(顺序必须与后端一致!)
        const stringToSign = this.buildStringToSign(requestData);
        
        // 2. 计算HMAC签名
        const signature = await this.calculateHMAC(stringToSign);
        
        return signature;
    }
    
    /**
     * 构造待签名字符串(规范格式)
     */
    buildStringToSign({
        method,
        path,
        queryParams,
        timestamp,
        nonce,
        requestBody
    }) {
        const parts = [];
        
        // 固定顺序,非常重要!
        parts.push(method.toUpperCase());           // GET/POST等
        parts.push(this.normalizePath(path));       // URL路径
        parts.push(timestamp.toString());           // 时间戳
        parts.push(nonce);                          // 随机数
        
        // 如果有查询参数(必须排序)
        if (queryParams && Object.keys(queryParams).length > 0) {
            const sortedParams = this.sortAndStringify(queryParams);
            parts.push(sortedParams);
        }
        
        // 请求体(POST/PUT/PATCH)
        if (requestBody && method !== 'GET' && method !== 'DELETE') {
            const normalizedBody = this.normalizeBody(requestBody);
            parts.push(normalizedBody);
        }
        
        // 用换行符连接(与后端保持一致)
        return parts.join('\n');
    }
    
    /**
     * 计算HMAC-SHA256签名
     */
    async calculateHMAC(stringToSign) {
        try {
            // 方法1:使用Web Crypto API(现代浏览器)
            return await this.calculateHMACWebCrypto(stringToSign);
            
            // 方法2:使用第三方库(如crypto-js,兼容性好)
            // return this.calculateHMACCryptoJS(stringToSign);
        } catch (error) {
            console.error('签名计算失败:', error);
            throw error;
        }
    }
    
    /**
     * 使用Web Crypto API计算HMAC
     */
    async calculateHMACWebCrypto(stringToSign) {
        // 编码密钥和消息
        const encoder = new TextEncoder();
        const keyData = encoder.encode(this.clientSecret);
        const msgData = encoder.encode(stringToSign);
        
        // 导入密钥
        const cryptoKey = await crypto.subtle.importKey(
            'raw',
            keyData,
            { 
                name: 'HMAC', 
                hash: { name: 'SHA-256' }
            },
            false,  // 是否可导出
            ['sign', 'verify']
        );
        
        // 计算签名
        const signatureBuffer = await crypto.subtle.sign(
            'HMAC',
            cryptoKey,
            msgData
        );
        
        // 转为十六进制字符串
        return this.arrayBufferToHex(signatureBuffer);
    }
    
    /**
     * 使用crypto-js库(兼容性更好)
     */
    calculateHMACCryptoJS(stringToSign) {
        // 需要先引入crypto-js库
        const CryptoJS = window.CryptoJS;
        
        // 计算HMAC-SHA256
        const hash = CryptoJS.HmacSHA256(stringToSign, this.clientSecret);
        
        // 转为十六进制字符串
        return hash.toString(CryptoJS.enc.Hex);
    }
    
    /**
     * 工具函数:ArrayBuffer转Hex
     */
    arrayBufferToHex(buffer) {
        return Array.from(new Uint8Array(buffer))
            .map(b => b.toString(16).padStart(2, '0'))
            .join('');
    }
    
    /**
     * 规范化URL路径
     */
    normalizePath(path) {
        // 移除协议和域名(如果包含)
        const url = new URL(path, window.location.origin);
        return url.pathname;
    }
    
    /**
     * 规范化请求体
     */
    normalizeBody(body) {
        if (typeof body === 'string') {
            try {
                // 如果是JSON字符串,先解析再重新序列化
                const parsed = JSON.parse(body);
                return JSON.stringify(parsed, null, 0)  // 移除空格和换行
                       .replace(/\s+/g, '');
            } catch {
                // 如果不是JSON,直接返回
                return body;
            }
        } else if (typeof body === 'object') {
            // 对象转为JSON字符串
            return JSON.stringify(body, null, 0).replace(/\s+/g, '');
        }
        return String(body || '');
    }
    
    /**
     * 排序并序列化查询参数
     */
    sortAndStringify(params) {
        const sortedKeys = Object.keys(params).sort();
        const pairs = sortedKeys.map(key => {
            let value = params[key];
            
            // 处理数组参数
            if (Array.isArray(value)) {
                value = value.sort().join(',');
            }
            
            return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
        });
        
        return pairs.join('&');
    }
}

2.2 完整的前端请求示例

// 安全请求客户端
class SecureHttpClient {
    constructor(baseURL, clientId, clientSecret) {
        this.baseURL = baseURL;
        this.generator = new SignatureGenerator(clientId, clientSecret);
        this.nonceGenerator = new NonceGenerator();
    }
    
    /**
     * 发送安全请求
     */
    async request(config) {
        const {
            method = 'GET',
            path,
            params = {},
            data = null,
            headers = {}
        } = config;
        
        // 1. 生成安全参数
        const timestamp = Date.now();
        const nonce = this.nonceGenerator.generate();
        
        // 2. 构造完整URL
        const fullPath = this.buildFullPath(path, params);
        const url = new URL(fullPath, this.baseURL);
        
        // 3. 序列化请求体(用于签名)
        let requestBody = null;
        if (data && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method.toUpperCase())) {
            requestBody = typeof data === 'string' ? data : JSON.stringify(data);
        }
        
        // 4. 生成签名
        const signature = await this.generator.generateSignature({
            method: method.toUpperCase(),
            path: url.pathname,
            queryParams: params,
            timestamp,
            nonce,
            requestBody
        });
        
        // 5. 准备请求头
        const secureHeaders = {
            'X-Client-ID': this.generator.clientId,
            'X-Timestamp': timestamp.toString(),
            'X-Nonce': nonce,
            'X-Signature': signature,
            'Content-Type': 'application/json',
            ...headers
        };
        
        // 6. 发送请求
        const requestConfig = {
            method: method.toUpperCase(),
            headers: secureHeaders,
            credentials: 'include' // 如果需要cookie
        };
        
        if (requestBody) {
            requestConfig.body = requestBody;
        }
        
        try {
            const response = await fetch(url.toString(), requestConfig);
            
            // 处理重放错误(自动重试)
            if (response.status === 409) {
                return await this.retryRequest(config, response);
            }
            
            return response;
        } catch (error) {
            console.error('安全请求失败:', error);
            throw error;
        }
    }
    
    /**
     * 构建完整路径(带查询参数)
     */
    buildFullPath(path, params) {
        if (!params || Object.keys(params).length === 0) {
            return path;
        }
        
        const queryString = Object.keys(params)
            .sort()
            .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(params[key])}`)
            .join('&');
        
        return `${path}?${queryString}`;
    }
    
    /**
     * 重试机制(处理Nonce冲突)
     */
    async retryRequest(originalConfig, originalResponse) {
        const maxRetries = 3;
        let retryCount = 0;
        
        while (retryCount < maxRetries) {
            retryCount++;
            
            try {
                console.log(`请求冲突,第${retryCount}次重试...`);
                
                // 延迟重试(指数退避)
                const delay = Math.pow(2, retryCount) * 100; // 200ms, 400ms, 800ms
                await new Promise(resolve => setTimeout(resolve, delay));
                
                // 使用新的Nonce重新发送请求
                return await this.request(originalConfig);
            } catch (error) {
                if (retryCount === maxRetries) {
                    console.error('重试失败,返回原始错误');
                    return originalResponse;
                }
            }
        }
    }
}

// Nonce生成器
class NonceGenerator {
    constructor() {
        this.counter = 0;
        this.usedNonces = new Set();
    }
    
    generate() {
        // 方法1:UUID(推荐)
        if (typeof crypto !== 'undefined' && crypto.randomUUID) {
            return crypto.randomUUID();
        }
        
        // 方法2:时间戳 + 随机数 + 计数器
        const timestamp = Date.now();
        const random = Math.random().toString(36).substr(2, 9);
        const nonce = `${timestamp}_${random}_${this.counter++}`;
        
        // 确保唯一性(本地检查)
        if (this.usedNonces.has(nonce)) {
            return this.generate(); // 递归生成
        }
        
        this.usedNonces.add(nonce);
        
        // 清理旧的nonce(避免内存泄漏)
        if (this.usedNonces.size > 1000) {
            this.cleanupOldNonces();
        }
        
        return nonce;
    }
    
    cleanupOldNonces() {
        const now = Date.now();
        for (const nonce of this.usedNonces) {
            const timestamp = parseInt(nonce.split('_')[0]);
            if (now - timestamp > 300000) { // 5分钟前
                this.usedNonces.delete(nonce);
            }
        }
    }
}

2.3 使用示例

// 初始化客户端
const client = new SecureHttpClient(
    'https://api.yourdomain.com',
    'your-client-id',
    'your-client-secret' // 注意:前端存储密钥有风险!
);

// 示例1:GET请求
async function getUserProfile(userId) {
    const response = await client.request({
        method: 'GET',
        path: '/api/users/profile',
        params: { userId }
    });
    
    return response.json();
}

// 示例2:POST请求(带敏感数据)
async function updatePhoneNumber(newPhone) {
    const response = await client.request({
        method: 'POST',
        path: '/api/users/update-phone',
        data: {
            phone: newPhone,
            verificationCode: '123456'
        }
    });
    
    return response.json();
}

// 示例3:文件上传(特殊处理)
async function uploadFile(file) {
    // 对于FormData,需要特殊处理签名
    const formData = new FormData();
    formData.append('file', file);
    formData.append('description', '测试文件');
    
    // 注意:FormData不能直接序列化,需要特殊处理
    const timestamp = Date.now();
    const nonce = generateNonce();
    
    // 对于文件上传,可以只对元数据签名
    const metadata = {
        filename: file.name,
        size: file.size,
        type: file.type
    };
    
    // 或者使用另一种方案:先获取预签名URL
}

三、密钥管理安全问题

问题:前端无法安全存储密钥!

// ❌ 危险:硬编码在代码中
const CLIENT_SECRET = 'my-super-secret-key-123'; // 可以被反编译获取

// ❌ 危险:存储在localStorage
localStorage.setItem('client_secret', 'secret-key'); // XSS攻击可获取

// ❌ 危险:存储在cookie中
document.cookie = 'client_secret=secret-key'; // 同样不安全

解决方案

方案1:动态密钥交换(推荐)
// 步骤1:前端生成临时密钥对
async function generateKeyPair() {
    const keyPair = await crypto.subtle.generateKey(
        {
            name: 'ECDH',
            namedCurve: 'P-256'
        },
        true, // 是否可导出
        ['deriveKey']
    );
    
    return keyPair;
}

// 步骤2:与服务器交换公钥,协商出共享密钥
async function establishSecureChannel() {
    // 生成临时密钥对
    const keyPair = await generateKeyPair();
    
    // 导出公钥发送给服务器
    const publicKey = await crypto.subtle.exportKey('raw', keyPair.publicKey);
    
    // 获取服务器的公钥
    const serverPublicKey = await fetchServerPublicKey();
    
    // 协商出共享密钥
    const sharedSecret = await deriveSharedKey(
        keyPair.privateKey,
        serverPublicKey
    );
    
    return sharedSecret;
}
方案2:短期令牌(Token)
// 后端返回短期有效的签名令牌
async function getSigningToken() {
    const response = await fetch('/api/auth/signing-token', {
        method: 'POST',
        credentials: 'include' // 使用session或http-only cookie
    });
    
    const { token, expiresIn } = await response.json();
    
    // 令牌只用于签名,短期有效(如5分钟)
    return {
        token,
        expiresAt: Date.now() + expiresIn * 1000
    };
}

// 使用令牌签名
async function signWithToken(data, token) {
    // 令牌作为签名密钥
    return calculateHMAC(data, token);
}
方案3:后端签名代理(最安全)
// 前端不直接签名,而是调用后端签名接口
class BackendSignedClient {
    async request(config) {
        // 1. 准备所有请求数据
        const requestData = this.prepareRequestData(config);
        
        // 2. 调用后端签名接口
        const signature = await this.getSignatureFromBackend(requestData);
        
        // 3. 发送已签名的请求
        return this.sendSignedRequest(requestData, signature);
    }
    
    async getSignatureFromBackend(requestData) {
        // 通过安全的会话(http-only cookie)调用签名接口
        const response = await fetch('/api/security/sign', {
            method: 'POST',
            credentials: 'include',
            headers: {
                'Content-Type': 'application/json'
            },
            body: JSON.stringify(requestData)
        });
        
        const { signature, timestamp, nonce } = await response.json();
        
        return { signature, timestamp, nonce };
    }
}

四、签名的常见陷阱和解决方案

陷阱1:签名数据不一致

// ❌ 错误:前端和后端构造的签名字符串不同
// 前端:method + url + timestamp + nonce + body
// 后端:timestamp + nonce + method + url + body

// ✅ 正确:定义统一的规范
const SIGNATURE_SPEC = {
    order: ['METHOD', 'PATH', 'TIMESTAMP', 'NONCE', 'QUERY', 'BODY'],
    separator: '\n',
    normalize: {
        method: 'uppercase',
        path: 'remove_domain',
        body: 'remove_whitespace'
    }
};

陷阱2:时间不同步

// 解决方案:获取服务器时间校准
async function syncServerTime() {
    const before = Date.now();
    const response = await fetch('/api/server-time');
    const after = Date.now();
    
    const { timestamp } = await response.json();
    const rtt = after - before;
    
    // 计算时间偏移(假设网络延迟对称)
    this.timeOffset = timestamp - before - (rtt / 2);
    
    // 定期校准(每5分钟)
    setInterval(() => this.syncServerTime(), 5 * 60 * 1000);
}

// 使用校准后的时间戳
getAdjustedTimestamp() {
    return Date.now() + this.timeOffset;
}

陷阱3:请求重放

// 即使有签名,也要防止请求被原样重放
// 解决方案:Nonce + 时间戳双重防护

// 额外的防护:请求指纹
async function addRequestFingerprint(config) {
    // 添加浏览器指纹
    const fingerprint = await getBrowserFingerprint();
    
    // 添加用户行为特征
    const userAction = getUserActionContext();
    
    // 将这些信息也加入签名
    return {
        ...config,
        fingerprint,
        userAction
    };
}

五、测试签名实现

// 签名测试工具
class SignatureTester {
    static async test() {
        const testCases = [
            {
                name: 'GET请求测试',
                method: 'GET',
                path: '/api/users',
                params: { page: 1, limit: 20 },
                body: null,
                expected: '计算出的签名'
            },
            {
                name: 'POST请求测试',
                method: 'POST',
                path: '/api/login',
                params: {},
                body: { username: 'test', password: '123' },
                expected: '计算出的签名'
            }
        ];
        
        const generator = new SignatureGenerator('test-client', 'test-secret');
        
        for (const testCase of testCases) {
            const timestamp = 1651234567890;
            const nonce = 'test-nonce-123';
            
            const stringToSign = generator.buildStringToSign({
                method: testCase.method,
                path: testCase.path,
                queryParams: testCase.params,
                timestamp,
                nonce,
                requestBody: testCase.body
            });
            
            console.log(`测试: ${testCase.name}`);
            console.log('待签名字符串:', stringToSign);
            console.log('---');
            
            const signature = await generator.calculateHMAC(stringToSign);
            console.log('生成的签名:', signature);
            console.log('期望的签名:', testCase.expected);
            console.log('结果:', signature === testCase.expected ? '✅ 通过' : '❌ 失败');
            console.log('\n');
        }
    }
}

// 运行测试
SignatureTester.test();

总结

前端签名确实是整个防重放攻击方案的核心,因为:

  1. 签名确保数据完整性:防止请求在传输中被篡改
  2. 签名确保身份认证:只有持有正确密钥的客户端才能生成有效签名
  3. 签名与Nonce和时间戳结合:提供了完整的防重放保护

关键实施要点:

  • 前后端必须使用完全相同的签名算法和参数顺序
  • 密钥不能硬编码在前端代码中
  • 考虑使用动态密钥交换或后端签名代理
  • 实现时间同步机制
  • 添加完善的错误处理和重试逻辑

在实际项目中,建议采用方案3(后端签名代理),这是最安全的做法,即使前端代码完全暴露,攻击者也无法伪造有效的签名。

您可能感兴趣的与本文相关的镜像

Python3.8

Python3.8

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值