防重放攻击(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");
}
}
}
七、最佳实践总结
- 多层防御:时间戳 + Nonce + 签名三重验证
- 时间窗口:5-10分钟为宜,平衡安全性与时钟偏差容忍度
- Nonce存储:使用Redis,设置合理过期时间
- 签名规范:严格按照固定顺序构造签名字符串
- 时钟同步:确保服务器时间准确(使用NTP)
- 错误处理:不泄露过多信息(如不提示是时间戳还是nonce问题)
- 性能优化:
- 先验证时间戳(开销最小)
- 使用Redis原子操作
- 缓存客户端密钥
- 监控告警:记录重放攻击尝试,设置阈值告警
这个方案可以有效地防止99%以上的重放攻击,同时保持了良好的性能和可用性。根据业务的具体安全要求,可以适当调整时间窗口大小和验证严格程度。
是的,前端签名是整个防重放攻击方案中最关键的环节!
如果签名可以被伪造或绕过,那么整个安全体系就失效了。让我详细解释前端如何正确实现签名:
一、前端签名的重要性
攻击者可以轻易获取:
1. ✅ 时间戳(从请求头)
2. ✅ Nonce(从请求头)
3. ✅ 请求URL和参数(从网络抓包)
4. ✅ 请求体数据(从网络抓包)
攻击者唯一无法获取/伪造的是:
❌ 客户端密钥(client_secret)
❌ 基于密钥计算出的签名
如果没有签名验证,攻击者可以:
- 修改请求数据后重新发送
- 使用有效的时间戳和nonce构造新请求
- 执行未授权的操作
二、前端签名完整实现方案
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();
总结
前端签名确实是整个防重放攻击方案的核心,因为:
- 签名确保数据完整性:防止请求在传输中被篡改
- 签名确保身份认证:只有持有正确密钥的客户端才能生成有效签名
- 签名与Nonce和时间戳结合:提供了完整的防重放保护
关键实施要点:
- 前后端必须使用完全相同的签名算法和参数顺序
- 密钥不能硬编码在前端代码中
- 考虑使用动态密钥交换或后端签名代理
- 实现时间同步机制
- 添加完善的错误处理和重试逻辑
在实际项目中,建议采用方案3(后端签名代理),这是最安全的做法,即使前端代码完全暴露,攻击者也无法伪造有效的签名。

5228

被折叠的 条评论
为什么被折叠?



