开放接口签名设计与 Spring Boot 落地:从 AOP 验签到生产级 HMAC-SHA256 对外开放接口时,最怕的不是“别人知道接口地址”,而是“别人拿着接口地址伪造请求、篡改参数、重复提交”。接口签名就是为了解决这类问题:让服务端能够判断一次请求是不是由合法调用方发起、请求内容有没有被中途改过、同一份请求有没有被重复利用。
这篇文章结合两篇常见的 Spring Boot AOP 验签实现思路,再参考 AWS SigV4、腾讯云 TC3-HMAC-SHA256、阿里云 ACS3-HMAC-SHA256、Stripe Webhook 签名等主流实践,整理一套更适合生产环境的开放接口签名方案。
先说结论:教学 Demo 可以用“参数排序 + timestamp + MD5 + AOP”解释原理;生产环境更推荐“HTTPS + appId + timestamp + nonce + body hash + HMAC-SHA256 + Redis 防重放 + 常量时间比较 + 版本化签名协议”。 安全这东西就像门锁,能别用自行车锁守金库,就别硬上。
1. 接口签名到底解决什么问题? 接口签名主要解决三个问题:
1.1 请求来源是否合法 服务端给每个接入方分配一组凭证:
appId:调用方身份标识,可以理解为用户名。
appSecret:调用方密钥,只在客户端和服务端保存,不能通过网络明文传输。
客户端用 appSecret 对请求内容计算签名,服务端根据 appId 找到对应 appSecret,用同样规则重新计算签名。两边结果一致,说明调用方大概率掌握正确密钥。
1.2 请求内容有没有被篡改 如果签名覆盖了请求方法、路径、Query 参数、部分 Header、Body Hash,那么任何一个字段被改动,服务端重算出来的签名都会不同。
例如:
1 2 3 4 5 原请求: amount=100 攻击者篡改: amount=100000
只要 amount 参与签名,篡改后的请求就无法通过验签。
1.3 请求是否被重放 攻击者抓到一条合法请求后,不改内容,直接重复发送。这种情况下签名依然是对的,所以还需要:
timestamp:请求时间,限制请求有效窗口,比如 5 分钟。
nonce:一次性随机串,服务端用 Redis 记录短期内已使用的 nonce。
如果同一个 appId + nonce 再来一次,直接拒绝。
2. 接口签名不解决什么问题? 很多人会把“签名”想成一把万能钥匙,这是危险的。
接口签名不能替代 :
能力
是否由接口签名解决
说明
HTTPS 传输加密
否
签名不加密内容,敏感数据仍然需要 HTTPS
用户登录态
否
签名证明调用方应用合法,不代表某个用户已登录
业务权限
否
appId 合法不代表它能访问任意订单、任意租户数据
参数合法性校验
否
参数格式、金额范围、枚举值仍然要校验
业务幂等
否
防重放不等于支付、下单、回调的业务幂等
防刷/限流
否
合法调用方也可能高频调用,仍需限流
防越权
否
OWASP API Top 10 中的对象级越权仍需逐接口鉴权
一句话:接口签名是开放接口安全体系中的一层,不是整栋楼。
3. 两篇 AOP 验签 Blog 的共同思路与不足 两篇文章的核心思路都比较清晰:
客户端携带 timestamp、sign 等参数。
服务端在 AOP 切面中获取请求参数。
按参数名排序。
拼接密钥和参数字符串。
用 MD5 计算摘要。
服务端重算签名,与客户端传入的签名比较。
对 @RequestBody 读取一次后无法再次读取的问题,通过包装 HttpServletRequest 或过滤器缓存请求体。
这个思路适合入门,但直接上生产还差几块关键拼图。
3.1 MD5 不适合新系统继续作为安全签名算法 很多早期接口签名方案使用:
1 MD5(secret + sortedParams)
问题在于:
MD5 已经不适合新系统作为安全强度依赖。
secret + message 这类手写拼接不是标准 MAC 结构,容易被错误使用。
更推荐使用标准的 HMAC-SHA256。
生产建议:
1 signature = Hex(HMAC-SHA256(appSecret, stringToSign))
3.2 只校验 timestamp 不够,必须加 nonce 只用 timestamp 可以限制请求有效期,但有效期内仍可被重放。
例如窗口是 5 分钟,攻击者抓到请求后,在 5 分钟内重复发送 100 次,签名仍然合法。
更可靠的做法:
1 2 3 Redis Key: api:sign:nonce:{appId}:{nonce} TTL: 5~10 分钟 写入方式: SET key value NX EX 600
如果写入失败,说明 nonce 已经用过,拒绝请求。
3.3 Body 不能“解析后再签”,要签原始 Body 或 Body Hash 常见错误:
客户端签名时 Body 是:
1 { "amount" : 100 , "orderNo" : "A001" }
服务端解析成对象再序列化后变成:
1 { "orderNo" : "A001" , "amount" : 100 }
语义一样,但字节不同,签名就不同。
生产建议二选一:
直接签原始请求体的 SHA-256 Hash ,最简单、最稳定。
如果必须签 JSON 语义内容,使用确定性的 JSON Canonicalization,比如 RFC 8785 JCS。
多数开放接口场景推荐第一种:签 raw body hash,不要在验签时重新序列化 JSON。
3.4 AOP 不是唯一选择,边界层更适合统一验签 AOP 很适合 Demo,也适合通过注解标记部分接口:
1 2 3 4 5 @ApiSignRequired @PostMapping("/callback") public Result callback (@RequestBody CallbackReq req) { ... }
但生产中更推荐在更靠前的位置处理:
API Gateway / Spring Cloud Gateway
Spring Security Filter
Servlet Filter
HandlerInterceptor
原因:
验签属于边界安全,应尽量在进入业务 Controller 前完成。
可以统一拒绝非法请求,减少业务层污染。
对大 Body、流式 Body、异常返回、日志脱敏更好控制。
4. 推荐的签名协议设计 下面给出一套通用、可落地、可版本演进的签名协议。
4.1 请求头设计 客户端调用开放接口时,统一携带以下 Header:
1 2 3 4 5 6 7 8 X-Sign-Version : v1X-App-Id : supplier-10001X-Timestamp : 1719028800X-Nonce : 9f0c0fd9-6a72-49c4-8c0d-8f2f6d7e2e01X-Sign-Method : HMAC-SHA256X-Signed-Headers : content-type;host;x-app-id;x-nonce;x-timestampX-Content-SHA256 : 6f5902ac237024bdd0c176cb93063dc4...X-Signature : 4a69f0d7f3f5c8f7f2a...
字段说明:
Header
必填
说明
X-Sign-Version
是
签名协议版本,例如 v1,以后升级算法不影响老客户端
X-App-Id
是
接入方身份
X-Timestamp
是
Unix 秒级时间戳
X-Nonce
是
一次性随机串,建议 UUID 或 128 bit 随机数
X-Sign-Method
是
固定为 HMAC-SHA256
X-Signed-Headers
是
参与签名的 Header 名称,统一小写,用分号分隔
X-Content-SHA256
建议
请求体 SHA-256 十六进制摘要
X-Signature
是
最终签名
4.2 Canonical Request 借鉴 AWS、腾讯云、阿里云的做法,先把请求规范化为稳定字符串:
1 2 3 4 5 6 7 CanonicalRequest = HTTPMethod + '\n' + CanonicalURI + '\n' + CanonicalQueryString + '\n' + CanonicalHeaders + '\n' + SignedHeaders + '\n' + HashedPayload
示例:
1 2 3 4 5 6 7 8 9 10 11 POST /openapi/v1/orders page=1&size=20 content-type:application/json host:api.example.com x-app-id:supplier-10001 x-nonce:9f0c0fd9-6a72-49c4-8c0d-8f2f6d7e2e01 x-timestamp:1719028800 content-type;host;x-app-id;x-nonce;x-timestamp 6f5902ac237024bdd0c176cb93063dc4...
注意几个规则:
HTTPMethod 统一大写。
CanonicalURI 使用 RFC 3986 规则编码路径。
CanonicalQueryString 对 Query 参数按 key、value 排序,空值保留为空字符串。
Header 名统一小写,value 去除首尾空白。
Body 不直接拼原文,而是拼 SHA-256(rawBody)。
换行符固定使用 \n,不要混用 \r\n。
4.3 String To Sign 再把 CanonicalRequest 摘要后构造待签名字符串:
1 2 3 4 5 StringToSign = SignMethod + '\n' + Timestamp + '\n' + Nonce + '\n' + Hex(SHA256(CanonicalRequest))
示例:
1 2 3 4 HMAC-SHA256 1719028800 9f0c0fd9-6a72-49c4-8c0d-8f2f6d7e2e01 c9b8e8f4f68e0e4c4b6f...
4.4 计算签名 1 Signature = Hex(HMAC-SHA256(appSecret, StringToSign))
最终客户端把 Signature 放入 X-Signature。
5. 客户端签名流程 客户端流程如下:
sequenceDiagram
participant Client as 调用方
participant API as 服务端
participant Redis as Redis
Client->>Client: 生成 timestamp、nonce
Client->>Client: 计算 bodyHash
Client->>Client: 构造 CanonicalRequest
Client->>Client: 构造 StringToSign
Client->>Client: 使用 appSecret 计算 HMAC-SHA256
Client->>API: 携带签名 Header 发起请求
API->>API: 根据 appId 查询 appSecret
API->>API: 校验 timestamp 是否过期
API->>Redis: SET nonce NX EX
Redis-->>API: 成功/失败
API->>API: 重建 CanonicalRequest 并计算签名
API->>API: 常量时间比较签名
API->>Client: 通过则执行业务,否则返回 401/403
6. 服务端验签流程 服务端建议按这个顺序处理:
判断当前接口是否需要验签。
读取并缓存原始请求体。
读取签名 Header。
校验必填字段是否存在。
根据 appId 查询接入方配置。
校验接入方是否启用、是否有访问当前接口的权限。
校验 timestamp 是否在允许窗口内。
使用 Redis 校验 nonce 是否首次出现。
计算 raw body 的 SHA-256,与 X-Content-SHA256 对比。
重建 CanonicalRequest。
重建 StringToSign。
使用 appSecret 计算期望签名。
使用常量时间比较,避免 timing attack。
验签通过后进入业务逻辑。
推荐时间窗口:
1 2 timestampToleranceSeconds = 300 nonceTtlSeconds = 600
nonce TTL 可以比 timestamp 窗口略长,避免边界条件。
7. Spring Boot 落地方案 下面以 Spring Boot 3.x 为例。Spring Boot 2.x 主要区别是 javax.servlet.* 与 jakarta.servlet.* 包名不同。
7.0 Maven 依赖参考 如果项目已经是标准 Spring Boot Web 项目,核心依赖并不多:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 <dependencies > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-web</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-data-redis</artifactId > </dependency > <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-validation</artifactId > </dependency > <dependency > <groupId > org.springframework</groupId > <artifactId > spring-test</artifactId > <scope > test</scope > </dependency > </dependencies >
如果你用的是 Spring Boot 2.x,示例中的 jakarta.servlet.* 需要替换成 javax.servlet.*。
7.1 配置项 1 2 3 4 5 6 7 8 9 api: signature: enabled: true include-path-patterns: - /openapi/** - /callback/** timestamp-tolerance-seconds: 300 nonce-ttl-seconds: 600 max-body-size-bytes: 1048576
7.2 接入方配置表设计 1 2 3 4 5 6 7 8 9 10 11 12 13 14 CREATE TABLE open_api_client ( id BIGSERIAL PRIMARY KEY , app_id VARCHAR (64 ) NOT NULL UNIQUE , app_name VARCHAR (128 ) NOT NULL , app_secret_cipher TEXT NOT NULL , status VARCHAR (32 ) NOT NULL DEFAULT 'ENABLED' , allowed_ips TEXT, allowed_paths TEXT, current_key_version VARCHAR (32 ) DEFAULT 'v1' , previous_secret_cipher TEXT, previous_key_expire_at TIMESTAMP , create_time TIMESTAMP NOT NULL DEFAULT now(), update_time TIMESTAMP NOT NULL DEFAULT now() );
字段说明:
字段
说明
app_id
调用方身份
app_secret_cipher
加密存储的密钥,不建议明文落库
status
是否启用
allowed_ips
可选,IP 白名单
allowed_paths
可选,接口范围
previous_secret_cipher
密钥轮换期间兼容旧密钥
previous_key_expire_at
旧密钥过期时间
7.3 自定义异常 1 2 3 4 5 6 7 8 9 10 11 12 public class ApiSignatureException extends RuntimeException { private final String code; public ApiSignatureException (String code, String message) { super (message); this .code = code; } public String getCode () { return code; } }
7.4 可重复读取 Body 的 Request Wrapper
说明:Spring 自带 ContentCachingRequestWrapper 可以缓存已读取的内容,但它并不会主动读取 Body;如果验签逻辑发生在 Controller 参数绑定之前,使用自定义 wrapper 一次性读取并重新暴露 getInputStream() 往往更直观。Body 大小一定要限制,别让一个 500MB 请求把应用当场送走。
不要在验签里直接读 request.getInputStream() 后就把原 request 继续传下去,否则 Controller 的 @RequestBody 可能读不到内容。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 package com.example.openapi.sign;import jakarta.servlet.ReadListener;import jakarta.servlet.ServletInputStream;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletRequestWrapper;import org.springframework.util.StreamUtils;import java.io.BufferedReader;import java.io.ByteArrayInputStream;import java.io.IOException;import java.io.InputStreamReader;import java.nio.charset.Charset;import java.nio.charset.StandardCharsets;public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { private final byte [] cachedBody; public CachedBodyHttpServletRequest (HttpServletRequest request, int maxBodySizeBytes) throws IOException { super (request); byte [] body = StreamUtils.copyToByteArray(request.getInputStream()); if (body.length > maxBodySizeBytes) { throw new ApiSignatureException ("BODY_TOO_LARGE" , "request body is too large" ); } this .cachedBody = body; } public byte [] getCachedBody() { return cachedBody; } @Override public ServletInputStream getInputStream () { ByteArrayInputStream inputStream = new ByteArrayInputStream (cachedBody); return new ServletInputStream () { @Override public boolean isFinished () { return inputStream.available() == 0 ; } @Override public boolean isReady () { return true ; } @Override public void setReadListener (ReadListener listener) { } @Override public int read () { return inputStream.read(); } }; } @Override public BufferedReader getReader () { Charset charset = StandardCharsets.UTF_8; if (getCharacterEncoding() != null ) { charset = Charset.forName(getCharacterEncoding()); } return new BufferedReader (new InputStreamReader (getInputStream(), charset)); } }
7.5 NonceStore:防重放 1 2 3 public interface NonceStore { boolean tryUse (String appId, String nonce, long ttlSeconds) ; }
Redis 实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 package com.example.openapi.sign;import org.springframework.data.redis.core.StringRedisTemplate;import java.time.Duration;public class RedisNonceStore implements NonceStore { private final StringRedisTemplate redisTemplate; public RedisNonceStore (StringRedisTemplate redisTemplate) { this .redisTemplate = redisTemplate; } @Override public boolean tryUse (String appId, String nonce, long ttlSeconds) { String key = "api:sign:nonce:" + appId + ":" + nonce; Boolean success = redisTemplate.opsForValue() .setIfAbsent(key, "1" , Duration.ofSeconds(ttlSeconds)); return Boolean.TRUE.equals(success); } }
7.6 AppSecret 查询服务 1 2 3 4 5 6 7 8 9 10 11 12 13 14 import java.util.Optional;public interface ApiCredentialService { Optional<ApiCredential> findByAppId (String appId) ; record ApiCredential ( String appId, String appSecret, String previousAppSecret, boolean enabled ) { } }
生产中 appSecret 不建议明文存库,可以:
用 KMS/配置中心加密。
应用启动后解密缓存。
密钥轮换时保留 current/previous 双密钥。
日志里永远不要打印完整密钥。
7.7 签名工具类 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 package com.example.openapi.sign;import jakarta.servlet.http.HttpServletRequest;import javax.crypto.Mac;import javax.crypto.spec.SecretKeySpec;import java.net.URLDecoder;import java.net.URLEncoder;import java.nio.charset.StandardCharsets;import java.security.MessageDigest;import java.time.Instant;import java.util.*;import java.util.stream.Collectors;public final class SignatureUtils { private SignatureUtils () { } public static final String SIGN_METHOD = "HMAC-SHA256" ; public static String sha256Hex (byte [] bytes) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256" ); return hex(digest.digest(bytes)); } catch (Exception e) { throw new IllegalStateException ("SHA-256 not available" , e); } } public static String hmacSha256Hex (String secret, String data) { try { Mac mac = Mac.getInstance("HmacSHA256" ); SecretKeySpec keySpec = new SecretKeySpec ( secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256" ); mac.init(keySpec); return hex(mac.doFinal(data.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException ("HmacSHA256 not available" , e); } } public static boolean constantTimeEqualsHex (String expectedHex, String actualHex) { if (expectedHex == null || actualHex == null ) { return false ; } byte [] expected = hexToBytes(expectedHex); byte [] actual = hexToBytes(actualHex); return MessageDigest.isEqual(expected, actual); } public static String buildCanonicalRequest ( HttpServletRequest request, String signedHeaders, String payloadHash ) { String method = request.getMethod().toUpperCase(Locale.ROOT); String uri = canonicalUri(request.getRequestURI()); String query = canonicalQueryString(request.getQueryString()); String headers = canonicalHeaders(request, signedHeaders); return method + "\n" + uri + "\n" + query + "\n" + headers + "\n" + signedHeaders.toLowerCase(Locale.ROOT) + "\n" + payloadHash; } public static String buildStringToSign (String timestamp, String nonce, String canonicalRequest) { String canonicalRequestHash = sha256Hex(canonicalRequest.getBytes(StandardCharsets.UTF_8)); return SIGN_METHOD + "\n" + timestamp + "\n" + nonce + "\n" + canonicalRequestHash; } private static String canonicalUri (String uri) { if (uri == null || uri.isBlank()) { return "/" ; } return Arrays.stream(uri.split("/" , -1 )) .map(SignatureUtils::rfc3986Encode) .collect(Collectors.joining("/" )); } private static String canonicalQueryString (String rawQuery) { if (rawQuery == null || rawQuery.isBlank()) { return "" ; } List<String[]> pairs = new ArrayList <>(); for (String part : rawQuery.split("&" )) { String[] kv = part.split("=" , 2 ); String key = decode(kv[0 ]); String value = kv.length > 1 ? decode(kv[1 ]) : "" ; pairs.add(new String []{key, value}); } pairs.sort(Comparator .comparing((String[] kv) -> rfc3986Encode(kv[0 ])) .thenComparing(kv -> rfc3986Encode(kv[1 ]))); return pairs.stream() .map(kv -> rfc3986Encode(kv[0 ]) + "=" + rfc3986Encode(kv[1 ])) .collect(Collectors.joining("&" )); } private static String canonicalHeaders (HttpServletRequest request, String signedHeaders) { List<String> names = Arrays.stream(signedHeaders.split(";" )) .map(s -> s.trim().toLowerCase(Locale.ROOT)) .filter(s -> !s.isBlank()) .distinct() .sorted() .toList(); StringBuilder sb = new StringBuilder (); for (String name : names) { String value = request.getHeader(name); if (value == null ) { throw new ApiSignatureException ("MISSING_SIGNED_HEADER" , "missing signed header: " + name); } sb.append(name) .append(":" ) .append(normalizeHeaderValue(value)) .append("\n" ); } return sb.toString(); } private static String normalizeHeaderValue (String value) { return value.trim().replaceAll("\\s+" , " " ); } private static String rfc3986Encode (String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8) .replace("+" , "%20" ) .replace("*" , "%2A" ) .replace("%7E" , "~" ); } private static String decode (String value) { return URLDecoder.decode(value, StandardCharsets.UTF_8); } private static String hex (byte [] bytes) { StringBuilder sb = new StringBuilder (bytes.length * 2 ); for (byte b : bytes) { sb.append(String.format("%02x" , b)); } return sb.toString(); } private static byte [] hexToBytes(String hex) { String normalized = hex.trim().toLowerCase(Locale.ROOT); if (normalized.length() % 2 != 0 ) { return new byte [0 ]; } byte [] result = new byte [normalized.length() / 2 ]; for (int i = 0 ; i < normalized.length(); i += 2 ) { int high = Character.digit(normalized.charAt(i), 16 ); int low = Character.digit(normalized.charAt(i + 1 ), 16 ); if (high < 0 || low < 0 ) { return new byte [0 ]; } result[i / 2 ] = (byte ) ((high << 4 ) + low); } return result; } public static long nowEpochSecond () { return Instant.now().getEpochSecond(); } }
7.8 验签 Filter 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 package com.example.openapi.sign;import jakarta.servlet.FilterChain;import jakarta.servlet.ServletException;import jakarta.servlet.http.HttpServletRequest;import jakarta.servlet.http.HttpServletResponse;import org.springframework.http.MediaType;import org.springframework.util.AntPathMatcher;import org.springframework.web.filter.OncePerRequestFilter;import java.io.IOException;import java.nio.charset.StandardCharsets;import java.util.List;import java.util.Locale;public class ApiSignatureFilter extends OncePerRequestFilter { private final ApiCredentialService credentialService; private final NonceStore nonceStore; private final List<String> includePathPatterns; private final long timestampToleranceSeconds; private final long nonceTtlSeconds; private final int maxBodySizeBytes; private final AntPathMatcher pathMatcher = new AntPathMatcher (); public ApiSignatureFilter ( ApiCredentialService credentialService, NonceStore nonceStore, List<String> includePathPatterns, long timestampToleranceSeconds, long nonceTtlSeconds, int maxBodySizeBytes ) { this .credentialService = credentialService; this .nonceStore = nonceStore; this .includePathPatterns = includePathPatterns; this .timestampToleranceSeconds = timestampToleranceSeconds; this .nonceTtlSeconds = nonceTtlSeconds; this .maxBodySizeBytes = maxBodySizeBytes; } @Override protected boolean shouldNotFilter (HttpServletRequest request) { String path = request.getRequestURI(); return includePathPatterns.stream().noneMatch(pattern -> pathMatcher.match(pattern, path)); } @Override protected void doFilterInternal ( HttpServletRequest request, HttpServletResponse response, FilterChain filterChain ) throws ServletException, IOException { CachedBodyHttpServletRequest wrappedRequest; try { wrappedRequest = new CachedBodyHttpServletRequest (request, maxBodySizeBytes); verify(wrappedRequest); filterChain.doFilter(wrappedRequest, response); } catch (ApiSignatureException e) { writeError(response, HttpServletResponse.SC_UNAUTHORIZED, e.getCode(), e.getMessage()); } catch (Exception e) { writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "SIGNATURE_INVALID" , "signature verification failed" ); } } private void verify (CachedBodyHttpServletRequest request) { String appId = requiredHeader(request, "X-App-Id" ); String timestamp = requiredHeader(request, "X-Timestamp" ); String nonce = requiredHeader(request, "X-Nonce" ); String signMethod = requiredHeader(request, "X-Sign-Method" ); String signedHeaders = requiredHeader(request, "X-Signed-Headers" ); String signature = requiredHeader(request, "X-Signature" ); if (!SignatureUtils.SIGN_METHOD.equalsIgnoreCase(signMethod)) { throw new ApiSignatureException ("SIGN_METHOD_NOT_SUPPORTED" , "unsupported sign method" ); } long requestTime = parseTimestamp(timestamp); long now = SignatureUtils.nowEpochSecond(); if (Math.abs(now - requestTime) > timestampToleranceSeconds) { throw new ApiSignatureException ("SIGNATURE_EXPIRED" , "request timestamp expired" ); } ApiCredentialService.ApiCredential credential = credentialService.findByAppId(appId) .orElseThrow(() -> new ApiSignatureException ("APP_NOT_FOUND" , "appId not found" )); if (!credential.enabled()) { throw new ApiSignatureException ("APP_DISABLED" , "appId disabled" ); } boolean nonceFirstSeen = nonceStore.tryUse(appId, nonce, nonceTtlSeconds); if (!nonceFirstSeen) { throw new ApiSignatureException ("REPLAY_REQUEST" , "nonce already used" ); } String payloadHash = SignatureUtils.sha256Hex(request.getCachedBody()); String headerPayloadHash = request.getHeader("X-Content-SHA256" ); if (headerPayloadHash != null && !headerPayloadHash.isBlank() && !payloadHash.equalsIgnoreCase(headerPayloadHash)) { throw new ApiSignatureException ("PAYLOAD_HASH_MISMATCH" , "payload hash mismatch" ); } String canonicalRequest = SignatureUtils.buildCanonicalRequest(request, signedHeaders, payloadHash); String stringToSign = SignatureUtils.buildStringToSign(timestamp, nonce, canonicalRequest); boolean matched = verifyWithCurrentOrPreviousSecret(credential, stringToSign, signature); if (!matched) { throw new ApiSignatureException ("SIGNATURE_MISMATCH" , "signature mismatch" ); } } private boolean verifyWithCurrentOrPreviousSecret ( ApiCredentialService.ApiCredential credential, String stringToSign, String actualSignature ) { String expected = SignatureUtils.hmacSha256Hex(credential.appSecret(), stringToSign); if (SignatureUtils.constantTimeEqualsHex(expected, actualSignature)) { return true ; } if (credential.previousAppSecret() != null && !credential.previousAppSecret().isBlank()) { String previousExpected = SignatureUtils.hmacSha256Hex(credential.previousAppSecret(), stringToSign); return SignatureUtils.constantTimeEqualsHex(previousExpected, actualSignature); } return false ; } private String requiredHeader (HttpServletRequest request, String name) { String value = request.getHeader(name); if (value == null || value.isBlank()) { throw new ApiSignatureException ("MISSING_HEADER" , "missing header: " + name); } return value.trim(); } private long parseTimestamp (String timestamp) { try { return Long.parseLong(timestamp); } catch (NumberFormatException e) { throw new ApiSignatureException ("INVALID_TIMESTAMP" , "timestamp must be epoch seconds" ); } } private void writeError (HttpServletResponse response, int status, String code, String message) throws IOException { response.setStatus(status); response.setCharacterEncoding(StandardCharsets.UTF_8.name()); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.getWriter().write(""" {"code":"%s","message":"%s"} """ .formatted(escape(code), escape(message))); } private String escape (String value) { return value == null ? "" : value.replace("\"" , "\\\"" ); } }
7.9 注册 Filter 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 @Configuration public class ApiSignatureConfig { @Bean public NonceStore nonceStore (StringRedisTemplate redisTemplate) { return new RedisNonceStore (redisTemplate); } @Bean public FilterRegistrationBean<ApiSignatureFilter> apiSignatureFilter ( ApiCredentialService credentialService, NonceStore nonceStore ) { ApiSignatureFilter filter = new ApiSignatureFilter ( credentialService, nonceStore, List.of("/openapi/**" , "/callback/**" ), 300 , 600 , 1024 * 1024 ); FilterRegistrationBean<ApiSignatureFilter> registration = new FilterRegistrationBean <>(); registration.setFilter(filter); registration.setOrder(10 ); return registration; } }
如果你项目里用了 Spring Security,建议把验签 Filter 放到合适的 Security Filter Chain 中,而不是简单用 FilterRegistrationBean,否则过滤器顺序容易和认证、异常处理、日志链路打架。
8. 客户端 Java 签名示例 下面是一个简化版客户端签名工具:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 public class OpenApiClientSigner { public Map<String, String> sign ( String appId, String appSecret, String method, String path, String rawQuery, Map<String, String> headers, byte [] body ) { String timestamp = String.valueOf(Instant.now().getEpochSecond()); String nonce = UUID.randomUUID().toString(); String bodyHash = SignatureUtils.sha256Hex(body); Map<String, String> signHeaders = new TreeMap <>(String.CASE_INSENSITIVE_ORDER); signHeaders.putAll(headers); signHeaders.put("X-App-Id" , appId); signHeaders.put("X-Timestamp" , timestamp); signHeaders.put("X-Nonce" , nonce); signHeaders.put("X-Sign-Method" , "HMAC-SHA256" ); signHeaders.put("X-Content-SHA256" , bodyHash); String signedHeaders = "content-type;host;x-app-id;x-nonce;x-timestamp" ; signHeaders.put("X-Signed-Headers" , signedHeaders); MockHttpServletRequest request = new MockHttpServletRequest (method, path); request.setQueryString(rawQuery); signHeaders.forEach(request::addHeader); String canonicalRequest = SignatureUtils.buildCanonicalRequest(request, signedHeaders, bodyHash); String stringToSign = SignatureUtils.buildStringToSign(timestamp, nonce, canonicalRequest); String signature = SignatureUtils.hmacSha256Hex(appSecret, stringToSign); signHeaders.put("X-Signature" , signature); signHeaders.put("X-Sign-Version" , "v1" ); return signHeaders; } }
实际生产不建议让每个接入方自己手写签名代码,最好提供:
Java SDK
JavaScript/TypeScript SDK
Python SDK
Go SDK
Postman Pre-request Script
curl 调试样例
签名调试页面
否则你会收获一堆“我本地算的是对的啊”的工单。接口签名最大的敌人不是黑客,是 URL Encode。
9. 错误码设计 错误响应不要把太多内部细节暴露给调用方,但也要能帮助排查。
HTTP 状态码
code
场景
400
INVALID_SIGNATURE_HEADER
Header 格式错误
401
MISSING_HEADER
缺少必要签名字段
401
SIGNATURE_EXPIRED
时间戳过期
401
SIGNATURE_MISMATCH
签名不匹配
401
PAYLOAD_HASH_MISMATCH
Body Hash 不一致
401
REPLAY_REQUEST
nonce 已使用
403
APP_DISABLED
appId 被禁用
403
APP_FORBIDDEN
appId 无权访问当前接口
413
BODY_TOO_LARGE
Body 超过验签缓存上限
429
RATE_LIMITED
调用频率超限
日志里可以记录:
1 2 3 4 5 6 7 8 9 traceId appId path timestamp nonce errorCode clientIp bodyHash canonicalRequestHash
不要记录:
1 2 3 4 appSecret 完整 signature 完整 Authorization 敏感业务字段
10. AOP、Interceptor、Filter、Gateway 怎么选?
方案
优点
缺点
推荐场景
AOP 注解
对业务方法粒度控制细
Body/Request 处理不自然,进入点偏晚
少量接口、Demo、内部系统
HandlerInterceptor
能拿到 HandlerMethod,可判断注解
Body 读取仍要配合 Filter
MVC 项目,需要按 Controller 注解控制
Servlet Filter
进入业务前统一处理,适合安全边界
不易直接知道方法注解
开放接口统一前缀
Spring Security Filter
可融入认证授权体系
配置复杂度更高
已有 Security 体系
API Gateway
统一边界,服务无侵入
需要处理 body 缓存、路由后端信任
多服务、多租户、统一开放平台
我的建议:
单体应用、小规模开放接口:Filter + 路径匹配。
需要按注解细粒度控制:Filter 缓存 Body + HandlerInterceptor 判断注解。
多服务生产开放平台:优先放在 Gateway,后端服务通过内网和 mTLS/网关签发 Header 建立信任。
支付回调、第三方 Webhook:在接收服务本地再验一次,不要只信网关。
11. 防重放与业务幂等的关系 nonce 防重放解决的是:
同一份 HTTP 请求不能被重复使用。
业务幂等解决的是:
同一个业务事件不能被重复处理。
例如支付回调可能因为网络重试发送多次,每次 timestamp、nonce、signature 都不同,但业务事件 ID 一样:
1 2 3 4 5 { "eventId" : "evt_10001" , "orderNo" : "O202606220001" , "status" : "PAID" }
这时签名都合法,但业务仍然只能处理一次。
所以还要做业务幂等:
1 2 3 4 5 6 7 CREATE TABLE webhook_event_log ( event_id VARCHAR (128 ) PRIMARY KEY , event_type VARCHAR (64 ) NOT NULL , status VARCHAR (32 ) NOT NULL , received_at TIMESTAMP NOT NULL DEFAULT now(), processed_at TIMESTAMP );
处理逻辑:
1 2 3 4 1. 验签通过 2. 按 eventId 插入事件表 3. 插入成功才执行业务 4. 插入失败说明已处理或处理中
12. 密钥管理与轮换 接口签名真正的核心资产是 appSecret。
12.1 密钥生成 建议:
1 2 3 长度:至少 256 bit 随机数 编码:Base64 或 Hex 来源:安全随机数生成器
Java 示例:
1 2 3 4 SecureRandom random = new SecureRandom ();byte [] key = new byte [32 ]; random.nextBytes(key);String appSecret = Base64.getUrlEncoder().withoutPadding().encodeToString(key);
12.2 密钥存储 不要:
1 2 3 4 appSecret 明文写配置文件 appSecret 明文进 Git appSecret 明文打日志 appSecret 明文返回前端
建议:
数据库加密存储。
配置中心加密。
使用 KMS。
应用层只在内存短期缓存解密后的密钥。
配合审计日志记录密钥查看、创建、禁用、轮换操作。
12.3 密钥轮换 设计 currentSecret + previousSecret 双密钥窗口:
1 2 3 4 T0: 新建 previousSecret = old,currentSecret = new T1: 通知调用方切换新密钥 T2: 双密钥同时验签 T3: 过渡期结束,删除 previousSecret
也可以加入:
服务端根据 keyId 精确选择密钥,避免每次都用多把密钥试算。
13. 常见踩坑清单 13.1 秒级时间戳和毫秒级时间戳混用 客户端传:
服务端按秒处理,直接过期或差值巨大。
约定清楚:
1 X-Timestamp 使用 Unix 秒级时间戳
13.2 时区问题 如果签名协议里有日期作用域,日期必须约定 UTC。腾讯云文档里也特别提醒:用本地东八区日期计算,凌晨很容易出现“白天正常、凌晨失败”的玄学问题。
13.3 Content-Type 不一致 客户端签名时:
1 content-type:application/json
实际请求库自动发送:
1 content-type:application/json; charset=utf-8
服务端按实际 Header 重算,签名不一致。
解决:
签名时使用最终实际发送的 Header。
SDK 统一设置 Content-Type。
文档明确是否包含 charset。
13.4 JSON 被重新序列化 客户端签名 raw body,服务端解析对象后重新序列化,字段顺序和空格变了。
解决:
签 SHA-256(rawBody)。
服务端验签阶段不要重排 JSON。
Webhook 场景必须拿原始请求体。
13.5 URL Encode 规则不一致 常见差异:
字符
错误处理
推荐处理
空格
+
%20
~
%7E
~
中文
平台默认编码
UTF-8
/
路径整体编码
按 path segment 编码
13.6 Query 多值参数顺序 例如:
要提前约定:
是否保留原顺序?
是否按 value 排序?
是否允许重复 key?
推荐:重复 key 允许,按编码后的 key、value 排序。
13.7 代理修改 Host 或 Path 如果网关转发时改了:
1 2 3 Host X-Forwarded-Host Path Prefix
服务端看到的请求和客户端签名时的请求不同,签名失败。
解决:
在网关层验签。
或约定签名使用外部原始路径,并通过可信 Header 传递。
不要让不可信客户端伪造 X-Forwarded-*。
13.8 Body 太大导致内存问题 验签需要读取 Body,如果没有大小限制,大文件上传会压垮内存。
解决:
开放接口 Body 设置大小上限。
大文件上传使用预签名 URL 或分片上传。
文件内容可签 metadata + content hash,不一定把整个文件读入业务服务。
13.9 签名比较使用 equals 普通字符串 equals 理论上可能暴露比较耗时差异。虽然很多业务系统风险不大,但既然有标准做法,就用:
1 MessageDigest.isEqual(expectedBytes, actualBytes)
13.10 错误日志把密钥打出来 有些人排查签名失败时会打印:
1 2 3 appSecret=xxxx stringToSign=xxxx signature=xxxx
stringToSign 可以谨慎打印摘要,appSecret 绝对不能打印。线上日志不是保险柜,是“未来某天你一定会后悔的公共厕所墙”。
14. Postman 调试脚本思路 可以给调用方一个 Postman Pre-request Script,自动生成签名。
伪代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 const appId = pm.environment .get ("appId" );const appSecret = pm.environment .get ("appSecret" );const timestamp = Math .floor (Date .now () / 1000 ).toString ();const nonce = crypto.randomUUID ();const body = pm.request .body && pm.request .body .raw ? pm.request .body .raw : "" ;const bodyHash = sha256Hex (body); pm.request .headers .upsert ({ key : "X-App-Id" , value : appId }); pm.request .headers .upsert ({ key : "X-Timestamp" , value : timestamp }); pm.request .headers .upsert ({ key : "X-Nonce" , value : nonce }); pm.request .headers .upsert ({ key : "X-Sign-Method" , value : "HMAC-SHA256" }); pm.request .headers .upsert ({ key : "X-Content-SHA256" , value : bodyHash }); pm.request .headers .upsert ({ key : "X-Signed-Headers" , value : "content-type;host;x-app-id;x-nonce;x-timestamp" });
重点不是脚本本身,而是:必须提供一个官方调试工具 ,让接入方能看到:
1 2 3 4 CanonicalRequest CanonicalRequestHash StringToSign Signature
这能把 80% 的联调问题从“猜谜游戏”变成“找不同游戏”。
15. 和 JWT、OAuth2、AK/SK 的区别 15.1 API 签名 vs JWT JWT 常见形式:
1 Authorization : Bearer eyJhbGciOi...
JWT 更适合表达“登录态、用户身份、授权声明”。但普通 Bearer Token 一旦被抓包,在有效期内可以被直接重放。
API 签名是“每个请求单独签名”,签名覆盖请求内容,所以更适合:
服务端到服务端调用。
开放平台 API。
第三方回调。
资金、订单、库存等敏感接口。
15.2 API 签名 vs OAuth2 OAuth2 更适合授权委托,例如“用户授权第三方应用访问自己的资源”。
开放接口签名更像 AK/SK 模式:
1 2 appId = AccessKeyId appSecret = AccessKeySecret
两者可以结合:
OAuth2 解决“用户授权”。
HMAC 签名解决“请求完整性、防篡改、防重放”。
15.3 HMAC vs RSA/ECDSA
方案
优点
缺点
适合场景
HMAC-SHA256
快、简单、实现成本低
双方都持有同一密钥
普通开放 API、内部服务、供应商接口
RSA-SHA256
服务端只需保存公钥,私钥由调用方保存
性能较低、证书/密钥管理复杂
支付、金融、强不可抵赖要求
ECDSA
签名更短,安全强度高
实现和兼容成本更高
云厂商、多区域签名、现代安全体系
大多数企业开放接口,HMAC-SHA256 已经足够。涉及资金或法律不可抵赖场景,可以考虑 RSA/ECDSA。
16. 生产增强建议 16.1 必须启用 HTTPS 签名不是加密。没有 HTTPS,攻击者仍然可以看到请求内容,也可能重放有效请求。REST API 生产环境必须只提供 HTTPS。
16.2 接入方维度限流 建议限流维度:
1 2 3 4 appId appId + apiPath clientIp tenantId
例如:
1 2 supplier-10001 每秒最多 50 次 supplier-10001:/openapi/v1/orders 每分钟最多 1000 次
16.3 IP 白名单 如果是固定供应商系统,可以加 IP 白名单:
1 appId -> allowedIpRanges
但不要只依赖 IP 白名单,因为:
NAT、代理、云出口 IP 可能变化。
IP 可以作为辅助防线,不是唯一凭证。
16.4 权限模型 开放接口要做 appId 级别授权:
1 2 3 4 5 6 7 supplier-10001 可以调用: - POST /openapi/v1/orders - GET /openapi/v1/orders/{orderNo} 不能调用: - POST /openapi/v1/refund - GET /admin/users
16.5 审计日志 对开放接口保留审计日志:
1 2 3 4 5 6 7 8 9 10 11 appId requestId traceId method path status costMs clientIp bodyHash signatureVersion errorCode
敏感字段脱敏,Body 不建议全量落日志。
16.6 灰度启用 老接口改造签名时,不要一刀切。
推荐阶段:
1 2 3 4 5 第一阶段:只记录签名状态,不拦截 第二阶段:对新 appId 强制验签 第三阶段:对指定接口强制验签 第四阶段:全量验签 第五阶段:下线旧 MD5 签名
17. 推荐的最小可用方案 如果你现在要快速落地,不想一上来搞太复杂,可以按这个 MVP 来:
请求头 1 2 3 4 5 X-App-Id X-Timestamp X-Nonce X-Content-SHA256 X-Signature
签名内容 1 2 3 4 5 6 7 StringToSign = method + "\n" + path + "\n" + canonicalQueryString + "\n" + timestamp + "\n" + nonce + "\n" + bodySha256
签名算法 1 signature = hex(hmac_sha256(appSecret, stringToSign))
服务端校验 1 2 3 4 5 1. appId 是否存在 2. timestamp 是否在 5 分钟内 3. nonce 是否首次出现 4. body hash 是否一致 5. signature 是否一致
这套已经比单纯 MD5(secret + params + timestamp) 稳很多。
18. 最后总结 接口签名的关键不是“用什么代码把字符串 MD5 一下”,而是要把协议设计清楚:
谁参与签名:method、path、query、headers、body hash。
怎么规范化:排序、编码、大小写、空值、多值、换行符。
怎么防重放:timestamp + nonce + Redis。
用什么算法:新系统优先 HMAC-SHA256。
怎么比较:常量时间比较。
怎么管理密钥:加密存储、轮换、禁用、审计。
怎么联调:提供 SDK、Postman 脚本、签名调试工具。
怎么上线:灰度、日志观察、错误码、监控告警。
如果只做一个教学 Demo,AOP + timestamp + MD5 可以讲明白原理;如果要上生产,就要把它升级为一套稳定、可演进、可排查的签名协议。
安全不是把门锁焊死,而是让合法的人顺利进来,让非法的人连门牌号都摸不明白。
参考资料