API接口签名验证实战
一、接口签名概述
API签名验证是保护接口安全的重要手段,防止请求被篡改或伪造。
1.1 签名机制原理
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 客户端 │ │ 网络 │ │ 服务端 │
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ 1. 构造请求参数 │────▶│ 传输请求 │────▶│ 1. 获取请求参数 │
│ 2. 生成签名 │ │ │ │ 2. 重新生成签名 │
│ 3. 发送请求 │ │ │ │ 3. 验证签名 │
└─────────────────┘ └─────────────────┘ │ 4. 处理请求 │
└─────────────────┘
1.2 签名要素
| 要素 | 说明 | 示例 |
|---|---|---|
| AppKey | 应用标识 | app001 |
| AppSecret | 应用密钥 | xxxxxx |
| Timestamp | 时间戳 | 1704067200 |
| Nonce | 随机数 | abc123 |
| Signature | 签名结果 | md5(xxx) |
二、签名算法实现
2.1 签名生成流程
java
public class SignatureUtil {
private static final String SIGN_METHOD = "MD5";
private static final String CHARSET = "UTF-8";
public static String generateSignature(Map<String, String> params, String appSecret) {
// 1. 去除sign参数
params.remove("sign");
// 2. 按字典序排序
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
// 3. 拼接参数
StringBuilder sb = new StringBuilder();
for (String key : keys) {
String value = params.get(key);
if (value != null && !value.isEmpty()) {
sb.append(key).append("=").append(value).append("&");
}
}
// 4. 拼接密钥
sb.append("key=").append(appSecret);
// 5. 计算签名
return md5(sb.toString());
}
private static String md5(String input) {
try {
MessageDigest md = MessageDigest.getInstance(SIGN_METHOD);
byte[] digest = md.digest(input.getBytes(CHARSET));
StringBuilder sb = new StringBuilder();
for (byte b : digest) {
sb.append(String.format("%02x", b));
}
return sb.toString().toUpperCase();
} catch (Exception e) {
throw new RuntimeException("MD5 calculation failed", e);
}
}
}
2.2 HMAC-SHA256签名
java
public class HmacSignatureUtil {
private static final String ALGORITHM = "HmacSHA256";
public static String generateHmacSignature(Map<String, String> params, String appSecret) {
params.remove("sign");
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
StringBuilder sb = new StringBuilder();
for (String key : keys) {
String value = params.get(key);
if (value != null && !value.isEmpty()) {
sb.append(key).append("=").append(value).append("&");
}
}
sb.append("key=").append(appSecret);
try {
Mac mac = Mac.getInstance(ALGORITHM);
SecretKeySpec keySpec = new SecretKeySpec(appSecret.getBytes(), ALGORITHM);
mac.init(keySpec);
byte[] digest = mac.doFinal(sb.toString().getBytes());
return Base64.getEncoder().encodeToString(digest);
} catch (Exception e) {
throw new RuntimeException("HMAC calculation failed", e);
}
}
}
三、签名验证实现
3.1 验证过滤器
java
public class SignatureFilter implements Filter {
private static final long TIMESTAMP_TOLERANCE = 5 * 60 * 1000; // 5分钟
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
try {
// 1. 获取参数
Map<String, String> params = getRequestParams(httpRequest);
// 2. 验证时间戳
String timestamp = params.get("timestamp");
validateTimestamp(timestamp);
// 3. 验证随机数
String nonce = params.get("nonce");
validateNonce(nonce);
// 4. 获取AppKey
String appKey = params.get("appKey");
String appSecret = getAppSecret(appKey);
// 5. 验证签名
String sign = params.get("sign");
String expectedSign = SignatureUtil.generateSignature(params, appSecret);
if (!sign.equalsIgnoreCase(expectedSign)) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("Invalid signature");
return;
}
chain.doFilter(request, response);
} catch (Exception e) {
httpResponse.setStatus(HttpServletResponse.SC_BAD_REQUEST);
}
}
private void validateTimestamp(String timestamp) {
long ts = Long.parseLong(timestamp);
long now = System.currentTimeMillis() / 1000;
if (Math.abs(now - ts) > TIMESTAMP_TOLERANCE / 1000) {
throw new RuntimeException("Invalid timestamp");
}
}
private void validateNonce(String nonce) {
if (nonce == null || nonce.length() < 8) {
throw new RuntimeException("Invalid nonce");
}
}
}
3.2 Nonce去重
java
public class NonceManager {
private final StringRedisTemplate redisTemplate;
private static final String PREFIX = "nonce:";
private static final long EXPIRE_SECONDS = 5 * 60; // 5分钟
public void validateNonce(String nonce) {
String key = PREFIX + nonce;
Boolean exists = redisTemplate.hasKey(key);
if (Boolean.TRUE.equals(exists)) {
throw new RuntimeException("Duplicate nonce");
}
redisTemplate.opsForValue().set(key, "true", EXPIRE_SECONDS, TimeUnit.SECONDS);
}
}
四、请求示例
4.1 客户端请求
java
public class ApiClient {
private String appKey = "app001";
private String appSecret = "secret123";
public String sendRequest(String url, Map<String, Object> data) {
Map<String, String> params = new HashMap<>();
params.put("appKey", appKey);
params.put("timestamp", String.valueOf(System.currentTimeMillis() / 1000));
params.put("nonce", UUID.randomUUID().toString().replace("-", "").substring(0, 16));
for (Map.Entry<String, Object> entry : data.entrySet()) {
params.put(entry.getKey(), String.valueOf(entry.getValue()));
}
String sign = SignatureUtil.generateSignature(params, appSecret);
params.put("sign", sign);
// 发送请求...
return doPost(url, params);
}
}
4.2 curl示例
bash
curl -X POST http://api.example.com/endpoint \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "appKey=app001" \
-d "timestamp=1704067200" \
-d "nonce=abc123def456" \
-d "data=test" \
-d "sign=A1B2C3D4E5F6"
五、安全加固
5.1 使用HTTPS
yaml
server:
ssl:
enabled: true
key-store: classpath:keystore.p12
key-store-password: password
key-store-type: PKCS12
key-alias: tomcat
5.2 IP白名单
java
public class IpWhitelistFilter implements Filter {
private Set<String> whitelist = Set.of(
"192.168.1.1",
"10.0.0.0/8"
);
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
String clientIp = getClientIp((HttpServletRequest) request);
if (!isIpWhitelisted(clientIp)) {
((HttpServletResponse) response).setStatus(HttpServletResponse.SC_FORBIDDEN);
return;
}
chain.doFilter(request, response);
}
}
5.3 频率限制
java
public class RateLimitFilter implements Filter {
private final StringRedisTemplate redisTemplate;
private static final int MAX_REQUESTS = 100;
private static final int TIME_WINDOW_SECONDS = 60;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
String clientIp = getClientIp((HttpServletRequest) request);
String key = "rate_limit:" + clientIp;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
redisTemplate.expire(key, TIME_WINDOW_SECONDS, TimeUnit.SECONDS);
}
if (count > MAX_REQUESTS) {
((HttpServletResponse) response).setStatus(429);
return;
}
chain.doFilter(request, response);
}
}
六、签名算法对比
| 算法 | 安全性 | 性能 | 适用场景 |
|---|---|---|---|
| MD5 | 低 | 高 | 内部系统 |
| SHA-1 | 中 | 高 | 一般场景 |
| SHA-256 | 高 | 中 | 重要场景 |
| HMAC-SHA256 | 高 | 中 | 对外API |
七、最佳实践
7.1 签名规范
- 参数排序:按字典序升序排列
- 空值处理:忽略空参数
- 编码格式:统一使用UTF-8
- 签名格式:统一大写或小写
7.2 安全建议
- 密钥管理:使用密钥管理服务存储密钥
- 定期轮换:定期更换AppSecret
- 日志审计:记录签名验证失败日志
- 异常监控:监控异常签名请求
7.3 常见问题
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 签名不一致 | 参数顺序不同 | 统一排序规则 |
| 时间戳过期 | 客户端时间不准 | 增加时间容错 |
| Nonce重复 | 请求重放 | 实现Nonce去重 |
八、总结
API签名验证是保护接口安全的重要手段:
- 选择合适算法:根据安全需求选择HMAC-SHA256
- 实现完整验证:时间戳、Nonce、签名缺一不可
- 配合其他措施:HTTPS、IP白名单、频率限制
- 做好密钥管理:使用安全的密钥存储方案
通过以上措施,可以有效防止接口被篡改和重放攻击。