前阵子对接一个开放平台,对方给了一堆接口文档,权限校验用的是类似AWS SigV4的签名规则。我一开始觉得麻烦,心想直接用Token或者Basic Auth不就完了,但在用JDK17的HttpClient手写实现了一遍之后,才理解这套设计确实有它的道理。今天就把整个签名生成和集成逻辑梳理清楚,重点讲三件事:签名里每个字节是怎么算出来的、怎么用JDK17 HttpClient把签名后的请求安全地发出去、实际对接中哪些坑最容易踩。
这篇文章适合正在对接云厂商API、开放平台网关,或者想给自己内部服务设计一套请求签名体系的同学。如果你还在用RestTemplate或者OkHttp,看完也可以把思路平移过去,签名逻辑本身是通用的,跟具体HTTP客户端没有强绑定。
1. 请求签名到底要解决什么问题
1.1 签名不是加密,而是防篡改、防伪装、防重放
很多人第一次接触“请求签名”这个概念时,会下意识认为它是为了加密数据。这个理解偏差很要命。签名算法做的是完整性校验和身份认证,而不是内容保密。你POST给服务端的JSON仍然是明文,抓包一样能看见请求体,但这个请求体一旦被中间人改了哪怕一个字节,服务端验签时就能立刻发现。
打个比方,签名就像你在合同每一页上按的手印。合同内容人人都能看,但谁要是偷偷改了某个数字,手印就对不上了。而且这个手印只能由持有密钥的人按出来,别人伪造不了。
在实际的业务场景里,请求签名要解决的具体问题有三个:
- 身份认证:服务端收到请求后,需要通过签名确认“你是谁”。密钥即身份,客户端持有下发的AccessKey和SecretKey,签名能证明请求是由合法客户端发出的。
- 完整性校验:请求参数、请求体、请求头在传输过程中都不能被改动。为了做到这一点,签名时会把这些内容一起参与计算,任何一个字段的变化都会导致签名结果完全不同。
- 防重放:抓包拿到一个完整的签名请求后,能不能直接重放一万次?不能。SigV4风格的签名会把请求时间戳作为签名因子,服务端一般建议把时间窗口控制在15分钟以内,超时直接拒绝。
所以你去看AWS的官方文档,里面有一句话我记得很清楚,签名是用来提供请求完整性的,而不是用来加密请求内容。想清楚这个定位,后面写代码时就不会搞混。
1.2 为什么大家都要抄SigV4的作业
这几年我接触过的云厂商、开放平台网关,至少有五六家采用了和SigV4非常相似的签名规则。不是说大家懒得创新,而是这套设计确实经过了大规模生产环境的验证,具备几个其他方案没有的优点。
第一,签名覆盖范围广。SigV4不只是把请求体和参数拿来算一遍,它把HTTP方法、URI路径、查询字符串、选定请求头、请求体哈希全部纳入规范请求(Canonical Request),相当于把HTTP请求的关键指纹都锁死了。无论是谁篡改了参数名、参数值还是Header,验签都过不了。
第二,签名密钥是“派生”的。它不是直接用你的SecretKey去签,而是通过时间戳、地域、服务名做了一层HMAC派生,得到临时签名密钥,再用这个临时密钥去签请求。这样即使某个请求的签名被截获,也无法逆向推导出原始SecretKey,减少了长期密钥泄露的风险。
第三,规范化规则清晰。所有参与签名的字符串都有精确的拼接方式和编码规则,比如URI每个路径段要单独URL编码、请求头名称要转小写并按字典序排序。这些规则虽然写起来繁琐,但好处是各种语言、各种平台的实现可以做到完全一致,服务端验签时不会因为编码细节不统一而误判。
我自己的体会是,这套签名算法第一次看会觉得步骤太多,但当你把它拆成“规范化请求、待签字符串、派生签名密钥”三个模块后,每一块都很好理解,代码量其实也控制在两三百行以内。
2. SigV4签名算法核心流程拆解
2.1 签名过程的整体逻辑
在写代码之前,先把SigV4风格的签名算法在脑子里形成一个完整链条。整个过程可以分成两大阶段,第一阶段在客户端完成,第二阶段在服务端完成。
客户端签名流程如下:
- 将请求的原始内容整理成“规范请求”(CanonicalRequest),包括HTTP方法、规范化URI、规范化查询字符串、规范化请求头、请求体哈希。
- 用规范请求拼接生成“待签字符串”(StringToSign),待签字符串里还包含签名算法标识、请求时间戳、凭证作用域。
- 用你的SecretKey结合时间戳、地域、服务名,派生出签名密钥。
- 用派生签名密钥对待签字符串做HMAC-SHA256计算,得到二进制摘要,再转成十六进制小写字符串。
- 组装Authorization请求头,内容包括签名算法、AccessKey、凭证作用域、参与签名的Header列表和最终签名值。
服务端验签时做的事情基本就是反向操作:同一个请求,自己按同样的规则重新算一遍签名,如果结果和客户端传过来的签名一致,就认为请求合法。所以这个算法能成立的前提是,两端对规范化规则的理解完全一致,哪怕一个多余的换行符都会导致验签失败。
2.2 规范请求长什么样
规范请求是整个签名链路的起点,它把HTTP请求的核心信息变成一个定长字符串。以GET请求为例,它的格式如下:
HTTPMethod + '\n' + CanonicalURI + '\n' + CanonicalQueryString + '\n' + CanonicalHeaders + '\n' + SignedHeaders + '\n' + HashedPayload逐行拆开看:
- HTTPMethod:就是GET、POST这类大写方法名,原样放上去,不需要加任何编码。
- CanonicalURI:URI路径的规范化版本。规则是每个路径段单独做URL编码,但保留路径分隔符/,比如
/v1/users/detail不能把斜杠也编码成%2F。 - CanonicalQueryString:查询字符串的规范化版本。需要把参数名和参数值都做URL编码,然后按参数名ASCII码升序排列,多个参数用&连接。
- CanonicalHeaders:需要参与签名的请求头列表,格式是“请求头小写名称:请求头值”,每个请求头占一行,以换行符结尾。这些请求头还要按名称的ASCII码排序。
- SignedHeaders:参与签名的请求头名称列表,用分号分隔,且所有名称转小写。
- HashedPayload:请求体的SHA-256哈希值,十六进制小写。对于GET请求,请求体为空字符串,那就对空字符串做SHA-256哈希。
看着很抽象,我举个实际例子。如果请求是:
GET https://api.example.com/v1/users?pageSize=20&page=1请求头包含host: api.example.com和x-ca-date: 20250601T120000Z,请求体为空。那么这个请求的规范请求就是:
GET /v1/users page=1&pageSize=20 host:api.example.com x-ca-date:20250601T120000Z host;x-ca-date e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855注意这里CanonicalHeaders后面有个空行,那个空行是用来分隔请求头列表和请求体哈希的。很多人第一次写这个算法时就在这里翻车,少了一个换行符,签名怎么都对不上。我把这个细节写出来,希望你能避免踩同样的坑。
2.3 待签字符串与派生签名密钥
规范请求算完之后,还要再做一次哈希,然后拼接到待签字符串里。待签字符串的格式如下:
HMAC_SHA256_ALGORITHM + '\n' + RequestTimestamp + '\n' + CredentialScope + '\n' + HashedCanonicalRequest其中HMAC_SHA256_ALGORITHM在SigV4里是固定字符串AWS4-HMAC-SHA256,RequestTimestamp是形如20250601T120000Z的UTC时间,CredentialScope是YYYYMMDD/region/service/aws4_request格式的凭证作用域,最后一项就是把上一步得到的规范请求再做一次SHA-256哈希,转为十六进制小写。
到这里,签名还没开始计算。真正的密钥从你的SecretKey派生而来,派生链条如下:
kDate = HMAC_SHA256("AWS4" + SecretKey, YYYYMMDD) kRegion = HMAC_SHA256(kDate, Region) kService = HMAC_SHA256(kRegion, Service) kSigning = HMAC_SHA256(kService, "aws4_request")看到没有,每一级HMAC的密钥都是上一级的输出,最初的是AWS4拼接你的SecretKey。最终拿到的kSigning就是当前请求的签名密钥。这个设计的巧妙之处在于,你的SecretKey不会直接参与请求签名计算,即使服务端日志里记录了签名值,也无法反推出密钥本身。
最后一步,用kSigning对待签字符串做HMAC-SHA256,得到32字节的二进制摘要,转成十六进制小写,就是最终签名值。
3. JDK17环境准备与基础工具代码
3.1 为什么选JDK17的HttpClient
很多同学还在用Java 8,每次提到JDK11以后的原生HttpClient都有点犹豫。以我个人的使用体验来说,从JDK11引入HttpClient开始,它就已经覆盖了绝大多数生产环境的需求。为什么非要强调JDK17,主要是两个原因。
一是JDK17是长期支持版本,国内大部分互联网公司已经逐步把生产环境从8迁移到17,你写的代码如果不能在新版本上跑,迟早是要还债的。二是我下面示例里用到了一个很舒服的API,java.util.HexFormat,这是Java 17才有的工具类,用来做字节数组和十六进制字符串的互转,比之前手写String.format或者BigInteger.toString(16)舒服太多。
另外,原生HttpClient支持HTTP/2、响应式Body处理、异步发送、超时控制,都是开箱即用,不需要额外引入依赖。Spring的RestTemplate虽然生态好,但在新项目里我越来越倾向于直接用原生客户端,少一层封装就少一层幺蛾子。
3.2 十六进制转换和HMAC工具先备好
签名过程中大量涉及SHA-256哈希和HMAC计算,所以先把这两个最基础的能力封装好。Java标准库提供了java.security.MessageDigest和javax.crypto.Mac,不需要引第三方包。
package com.example.sigv4; import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.HexFormat; public final class CryptoUtils { private CryptoUtils() { } public static String sha256Hex(byte[] data) { try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hash = digest.digest(data); return HexFormat.of().formatHex(hash); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException("SHA-256 algorithm not available", e); } } public static String sha256Hex(String data) { return sha256Hex(data.getBytes(StandardCharsets.UTF_8)); } public static byte[] hmacSha256(byte[] key, byte[] data) { try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(key, "HmacSHA256")); return mac.doFinal(data); } catch (Exception e) { throw new IllegalStateException("Failed to calculate HMAC-SHA256", e); } } public static byte[] hmacSha256(byte[] key, String data) { return hmacSha256(key, data.getBytes(StandardCharsets.UTF_8)); } }这段代码里有个细节值得说一下。Mac初始化时传入的SecretKeySpec使用的是HmacSHA256算法名,它要求密钥类型是SecretKey,直接把byte数组包成SecretKeySpec是标准做法。另外,Mac实例不是线程安全的,每次计算时都new一个新的,避免在并发场景下出现数据错乱。我在封装工具类时没有把Mac做成单例缓存,正是基于这个考虑。
4. 手写SigV4签名完整实现
4.1 构建规范请求
签名核心的第一步就是构建规范请求。这里我把URI和查询字符串的规范化逻辑单独抽取出来,因为实际项目中这两个地方最容易出幺蛾子。
package com.example.sigv4; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public final class SignRequestBuilder { private static final char HEX_DIGITS[] = "0123456789abcdef".toCharArray(); private SignRequestBuilder() { } public static String canonicalUri(String rawPath) { if (rawPath == null || rawPath.isEmpty()) { return "/"; } String raw = rawPath.startsWith("/") ? rawPath : "/" + rawPath; String[] segments = raw.split("/", -1); StringBuilder sb = new StringBuilder(); for (int i = 0; i < segments.length; i++) { if (i > 0) { sb.append('/'); } sb.append(encodePathSegment(segments[i])); } return sb.length() == 0 ? "/" : sb.toString(); } private static String encodePathSegment(String segment) { if (segment.isEmpty()) { return segment; } StringBuilder result = new StringBuilder(); byte[] bytes = segment.getBytes(StandardCharsets.UTF_8); for (byte b : bytes) { char ch = (char) (b & 0xFF); if ((ch >= 'A' && ch <= 'Z') || (ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9') || ch == '-' || ch == '_' || ch == '.' || ch == '~') { result.append(ch); } else { result.append('%'); result.append(HEX_DIGITS[(b >> 4) & 0xF]); result.append(HEX_DIGITS[b & 0xF]); } } return result.toString(); } public static String canonicalQueryString(Map<String, String> queryParams) { if (queryParams == null || queryParams.isEmpty()) { return ""; } List<String> encodedPairs = new ArrayList<>(); for (Map.Entry<String, String> entry : queryParams.entrySet()) { String key = urlEncode(entry.getKey()); String value = urlEncode(entry.getValue() == null ? "" : entry.getValue()); encodedPairs.add(key + "=" + value); } Collections.sort(encodedPairs); return String.join("&", encodedPairs); } private static String urlEncode(String value) { return URLEncoder.encode(value, StandardCharsets.UTF_8) .replace("+", "%20") .replace("*", "%2A") .replace("%7E", "~"); } }这个类里有一个必须重点强调的规则:RFC 3986对URL编码的规定和URLEncoder默认行为不一致。URLEncoder会把空格编码成+,但签名算法要求空格必须编码成%20;星号*应该编码成%2A;波浪号~在URL里属于非保留字符,不应该被编码。所以我写了三个replace调用把URLEncoder的默认行为纠正过来。
URI路径编码也容易出错。很多人直接对整个路径调用URLEncoder.encode,结果斜杠/也被转义成了%2F,导致服务端解析路径时直接404。正确做法是把路径按斜杠拆开,对每一段单独编码,再把斜杠拼回去。这一点我在代码里用split("/", -1)配合循环处理了。
4.2 计算签名并生成Authorization头
有了规范请求就能计算签名了。下面这个类负责拼接待签字符串、派生签名密钥、生成最终的Authorization请求头。
package com.example.sigv4; import java.time.ZoneOffset; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.Locale; import java.util.Map; import java.util.TreeMap; public final class SigV4Util { public static final String ALGORITHM = "AWS4-HMAC-SHA256"; public static final String TERMINATOR = "aws4_request"; private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd", Locale.ROOT).withZone(ZoneOffset.UTC); private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'", Locale.ROOT).withZone(ZoneOffset.UTC); private static final ZoneOffset UTC = ZoneOffset.UTC; private final String accessKey; private final String secretKey; private final String region; private final String service; public SigV4Util(String accessKey, String secretKey, String region, String service) { this.accessKey = accessKey; this.secretKey = secretKey; this.region = region; this.service = service; } public String buildAuthorizationHeader(String method, String uri, Map<String, String> queryParams, Map<String, String> headers, byte[] payload) { ZonedDateTime now = ZonedDateTime.now(UTC); String amzDate = TIME_FORMATTER.format(now); String dateStamp = DATE_FORMATTER.format(now); String payloadHash = CryptoUtils.sha256Hex(payload != null ? payload : new byte[0]); TreeMap<String, String> sortedHeaders = new TreeMap<>(headers); sortedHeaders.put("host", headers.getOrDefault("host", "")); sortedHeaders.put("x-ca-date", amzDate); sortedHeaders.put("x-ca-content-sha256", payloadHash); String canonicalRequest = buildCanonicalRequest(method, uri, queryParams, sortedHeaders, payloadHash); String credentialScope = dateStamp + "/" + region + "/" + service + "/" + TERMINATOR; String stringToSign = ALGORITHM + "\n" + amzDate + "\n" + credentialScope + "\n" + CryptoUtils.sha256Hex(canonicalRequest); byte[] signingKey = deriveSigningKey(dateStamp); byte[] signatureBytes = CryptoUtils.hmacSha256(signingKey, stringToSign); String signature = java.util.HexFormat.of().formatHex(signatureBytes); return ALGORITHM + " Credential=" + accessKey + "/" + credentialScope + ", SignedHeaders=" + signedHeaders(sortedHeaders) + ", Signature=" + signature; } private String buildCanonicalRequest(String method, String uri, Map<String, String> queryParams, Map<String, String> sortedHeaders, String payloadHash) { String canonicalQuery = SignRequestBuilder.canonicalQueryString(queryParams); String canonicalHeaders = buildCanonicalHeaders(sortedHeaders); String signedHeaders = signedHeaders(sortedHeaders); return method + "\n" + SignRequestBuilder.canonicalUri(uri) + "\n" + canonicalQuery + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + payloadHash; } private String buildCanonicalHeaders(TreeMap<String, String> sortedHeaders) { StringBuilder sb = new StringBuilder(); for (Map.Entry<String, String> entry : sortedHeaders.entrySet()) { String name = entry.getKey().toLowerCase(Locale.ROOT); String value = entry.getValue() == null ? "" : entry.getValue().trim(); sb.append(name).append(':').append(value).append('\n'); } return sb.toString(); } private String signedHeaders(TreeMap<String, String> sortedHeaders) { return sortedHeaders.keySet().stream() .map(name -> name.toLowerCase(Locale.ROOT)) .collect(java.util.stream.Collectors.joining(";")); } private byte[] deriveSigningKey(String dateStamp) { byte[] kDate = CryptoUtils.hmacSha256( ("AWS4" + secretKey).getBytes(java.nio.charset.StandardCharsets.UTF_8), dateStamp); byte[] kRegion = CryptoUtils.hmacSha256(kDate, region); byte[] kService = CryptoUtils.hmacSha256(kRegion, service); return CryptoUtils.hmacSha256(kService, TERMINATOR); } }这段代码里我把参与签名的请求头放进了TreeMap,它默认按Key的字典序排序,省得自己写比较器。然后主动把host、x-ca-date、x-ca-content-sha256三个头合并进去,确保签名覆盖了目标Host、请求时间和请求体哈希。host这里是从入参headers里取的,如果在实际使用中没传,那就得从URI里提取。这一点我在下一节集成到HttpClient时再做演示。
这里我还想解释一下x-ca-date和x-ca-content-sha256这两个自定义请求头。在真正的SigV4设计里,它们叫x-amz-date和x-amz-content-sha256,我这里改成了x-ca-前缀,表示这是一套模仿SigV4思路的自定义签名方案。如果你对接的是真正的AWS服务,需要把前缀改回去,同时日期格式也要严格对齐AWS的规范。
4.3 把工具类串起来跑通一个Demo
工具类写完了,先别急着往HttpClient上套,我习惯先写一个main方法把签名结果打印出来,肉眼检查一下格式是否正确。
public class SigV4Demo { public static void main(String[] args) { SigV4Util signer = new SigV4Util( "test-access-key", "test-secret-key", "cn-north-1", "custom-api"); byte[] emptyBody = new byte[0]; Map<String, String> query = Map.of( "page", "1", "pageSize", "20" ); Map<String, String> headers = new java.util.HashMap<>(); headers.put("host", "api.example.com"); String authHeader = signer.buildAuthorizationHeader( "GET", "/v1/users", query, headers, emptyBody); System.out.println(authHeader); } }输出大概长这样:
AWS4-HMAC-SHA256 Credential=test-access-key/20250601/cn-north-1/custom-api/aws4_request, SignedHeaders=host;x-ca-content-sha256;x-ca-date, Signature=4f6d7b...看到这个输出,心里就有底了。如果服务端签名一直验不过,先用这份格式化输出和服务端提供的测试用例比对,很快能定位是时间戳问题、Header排序问题还是签名值计算问题。
5. 集成到JDK17 HttpClient的完整流程
5.1 封装签名请求构建器
签名工具类的输出是一个Authorization头,接下来的事情就简单了:把它塞进JDK17的HttpClient请求里。
我先封装一个SignedRequestFactory,负责接收业务参数、自动计算签名、返回一个可以直接发送的HttpRequest。
package com.example.sigv4; import java.net.URI; import java.net.http.HttpRequest; import java.net.http.HttpRequest.BodyPublishers; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.HashMap; import java.util.Map; public class SignedRequestFactory { private final String baseUrl; private final SigV4Util sigV4Util; public SignedRequestFactory(String baseUrl, SigV4Util sigV4Util) { this.baseUrl = baseUrl; this.sigV4Util = sigV4Util; } public HttpRequest get(String path, Map<String, String> queryParams) { byte[] payload = new byte[0]; Map<String, String> headers = new HashMap<>(); headers.put("host", URI.create(baseUrl).getHost()); String fullPath = buildPath(path, queryParams); String authorization = sigV4Util.buildAuthorizationHeader( "GET", fullPath, queryParams, headers, payload); return HttpRequest.newBuilder() .uri(URI.create(baseUrl + fullPath)) .timeout(Duration.ofSeconds(10)) .header("host", URI.create(baseUrl).getHost()) .header("x-ca-date", headers.get("x-ca-date")) .header("x-ca-content-sha256", headers.get("x-ca-content-sha256")) .header("Authorization", authorization) .GET() .build(); } public HttpRequest post(String path, String jsonBody) { byte[] payload = jsonBody.getBytes(StandardCharsets.UTF_8); Map<String, String> headers = new HashMap<>(); headers.put("host", URI.create(baseUrl).getHost()); headers.put("content-type", "application/json"); String authorization = sigV4Util.buildAuthorizationHeader( "POST", path, Map.of(), headers, payload); return HttpRequest.newBuilder() .uri(URI.create(baseUrl + path)) .timeout(Duration.ofSeconds(10)) .header("host", URI.create(baseUrl).getHost()) .header("content-type", "application/json") .header("x-ca-date", headers.get("x-ca-date")) .header("x-ca-content-sha256", headers.get("x-ca-content-sha256")) .header("Authorization", authorization) .POST(BodyPublishers.ofByteArray(payload)) .build(); } private String buildPath(String path, Map<String, String> queryParams) { if (queryParams == null || queryParams.isEmpty()) { return path; } StringBuilder sb = new StringBuilder(path).append('?'); String canonicalQuery = SignRequestBuilder.canonicalQueryString(queryParams); return sb.append(canonicalQuery).toString(); } }有几个点要提醒一下。第一,我在GET请求构建签名时传入的fullPath是包含查询字符串的完整路径,这样签名就能覆盖查询参数。但传给HttpRequest.newBuilder().uri()的URI中,如果查询参数已经拼进路径,就不要再额外传查询参数了,否则会被HttpClient重复编码,导致签名计算时用的QueryString和实际发出的QueryString不一致。
第二,buildAuthorizationHeader方法内部会自动往sortedHeaders里塞x-ca-date和x-ca-content-sha256,所以我在外部创建headers时不需要手动传这两个值。但发送请求时,这两个头必须显式添加到HttpRequest上,否则签名计算的Header集合和实际发送的Header集合不一致,服务端验签时排序得到的SignedHeaders对不上。
第三,host头这个细节要单拎出来说。JDK的HttpClient在发送时会自动加上Host头,但这个自动添加发生在请求构建阶段之后。我在签名计算时用URI.create(baseUrl).getHost()手动取了Host,并在请求构建时也显式设了Host头,这样能确保签名和实际发送保持一致。
5.2 用JDK17 HttpClient发送签名请求
Factory写完之后,实际发送就只剩几行代码了。
package com.example.sigv4; import java.net.http.HttpClient; import java.net.http.HttpResponse; import java.util.Map; public class ApiClient { private final HttpClient httpClient = HttpClient.newBuilder() .version(HttpClient.Version.HTTP_1_1) .connectTimeout(java.time.Duration.ofSeconds(5)) .build(); private final SignedRequestFactory requestFactory; public ApiClient(SignedRequestFactory requestFactory) { this.requestFactory = requestFactory; } public String queryUsers(int page, int pageSize) throws Exception { var request = requestFactory.get("/v1/users", Map.of( "page", String.valueOf(page), "pageSize", String.valueOf(pageSize) )); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); return response.body(); } public String createUser(String jsonBody) throws Exception { var request = requestFactory.post("/v1/users", jsonBody); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); return response.body(); } }这个ApiClient已经把签名、发送、响应解析都串起来了。业务方法里不再出现任何签名相关的代码,调用方只需要关心业务参数就行。
这里我特意把HttpClient的版本设置为HTTP_1_1,可能有人会问为什么不直接用默认的HTTP/2。原因很简单,HTTP/2的请求头是二进制的,签名计算时没法简单地拼成字符串。虽然真正做起来可以用:scheme等伪头字段来拼,但大多数开放平台网关对HTTP/2的支持都还没有那么完善。为了稳定,我建议对接外部API时先强制HTTP/1.1,等确认服务端完全支持HTTP/2再升级。
5.3 异步场景下的签名注意点
JDK17的原生HttpClient有很好的异步支持,sendAsync返回一个CompletableFuture,可以配合thenApply做链式处理。但在异步场景下,签名计算逻辑本身和同步没有区别,密钥、时间戳、请求体哈希都是请求发送前就已经确定的。
要注意的是时间戳。SigV4Util.buildAuthorizationHeader内部获取的是当前系统时间,如果你多次调用这个方法,时间戳会自然递增。但在异步请求的场景下,如果程序在短时间内发送大量请求,时间戳秒级精度不会成为问题。真正需要担心的是,把签名时间和准星对齐,多台机器部署时需要配置NTP时间同步。
另外,如果你用同一个SigV4Util实例在多线程下并发调用,TreeMap和HexFormat都是线程安全的,不会出问题。我的代码里没有维护任何可变状态,所有临时对象都在方法内部创建,这一点对生产环境很重要。
6. 常见问题与排查技巧实录
6.1 请求重定向导致认证信息丢失
使用JDK HttpClient时,有个默认行为容易让人栽跟头:HttpClient默认会跟随重定向,但你可以通过HttpClient.Builder.followRedirects()来设置策略。如果你不显式设置,JDK11到JDK17的默认值是NEVER。也就是说,除非你主动配置,HttpClient不会自动跟随重定向。
但你要是配置了NORMAL或ALWAYS,问题就来了。签名绑定的是原始请求的Host、路径和Header,一旦服务端返回302,HttpClient自动重定向到另一个地址,Host变了、路径变了、签名自然就失效了。因为签名头还在原来的Authorization里,但服务端重新验签时发现目标Host和签名目标Host不一致,直接拒绝。
我自己遇到过类似的场景是,API网关在鉴权前会先重定向一次到某个统一入口。解决思路有两种:
- 如果重定向是增加路径前缀,可以在签名前手动把最终的目标URI算好,直接请求最终地址,绕开重定向。
- 如果确实没法预判重定向目标,就必须关闭自动重定向,拿到302响应后,从
Location头解析新地址,重新执行签名流程,再发起一次新请求。
这本质上不是签名算法的问题,而是HTTP客户端状态机和签名状态不一致的问题。每次重定向,本质上是发起一个全新的请求,必须重新签名。
6.2 请求体读取与签名顺序
这个坑非常隐蔽。很多HTTP客户端框架允许你通过拦截器(Interceptor)统一加签名,但你如果想在拦截器里读取请求体来计算哈希,就可能会发现请求体被读完一次之后,真正发送时变成了空内容。
JDK的HttpRequest在设计上是不允许重复读取Body的。BodyPublisher是一次性的流,不能被消费两次。所以在拦截器里先签名再发送,如果你的代码在签名时把请求体完全读出来做了哈希,那么发送时请求体就已经没了。
我的解决方案是始终在构建请求前就准备好请求体的byte[],把哈希计算放在请求体还不属于HttpClient的情况下。SignedRequestFactory里先拿到byte[] payload,用它计算哈希,再通过BodyPublishers.ofByteArray(payload)创建Publisher,这样同一个byte数组可以被哈希和使用,互不影响。如果请求体是文件流,那就先把流读成byte[],再做后续操作,绝不能把流直接传给BodyPublishers后再去读取流来做哈希。
6.3 时钟不同步、URL编码、Header大小写
我在排错过程中遇到的绝大多数签名失败,都集中在下面三个细节上。
一是系统时间不同步。签名里带着时间戳,如果服务器本地时间和标准UTC时间差超过服务端允许的窗口(一般是5到15分钟),签名直接无效。我遇到过最诡异的情况是,本地开发环境时间正常,但测试服务器因为没做NTP同步,时间慢了三分钟,导致线上一直验签失败。排查这个问题的办法很简单,在签名工具类里临时打印时间戳,手动差一下和标准时间的差值。
二是URL编码规则不一致。这个问题我在4.1小节里说过,再强调一次:Java的URLEncoder不是为签名场景设计的,你必须手动把+替换成%20,把*替换成%2A,同时保留~。更麻烦的是,不同的服务端实现可能对编码的要求还有细微差别,有的要求对!、'、(、)也做编码,这取决于服务端用的规范化规则。如果你的服务端文档里没有明确细节,先用最简单的ASCII字符测试,再逐步增加特殊字符。
三是Header名称大小写。HTTP Header名称本来不区分大小写,但签名算法要求必须把参与签名的Header名称统一转成小写。如果你构造请求时用了Host,签名时却是host,服务端按小写解析后会认为你签名的Header和实际发送的Header不一致。解决办法是buildCanonicalHeaders和signedHeaders两个方法里都对Header名称做toLowerCase,我代码里已经处理了,但你自己如果扩展新的Header要注意这一条。
我再整理一个排查速查表,方便你逐个排除:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 返回403 Forbidden | 签名值错误 | 先打印签名值,比对服务端文档的测试用例 |
| 返回400 InvalidSignature | 时间戳超范围 | 检查系统时间和UTC时间差,检查时区设置 |
| 返回URI未授权 | URI编码不一致 | 对比CanonicalURI和实际路径,检查斜杠是否被转义 |
| 服务端只报缺少签名头 | Authorization头未发送 | 检查HttpClient请求构建时是否漏掉自定义Header |
| 重定向后签名失效 | 开启了自动重定向 | 关闭followRedirects,手动重新签名并发送 |
| 异步发送大量请求部分失败 | 多线程复用Mac实例 | 确保Mac每次new,不能缓存共享 |
6.4 请求签名的性能开销
有同学可能会担心HMAC-SHA256的计算量会不会拖慢接口响应。我可以负责任地说,这个担心是多余的。我做过简单压测,本机环境下HMAC-SHA256计算一次签名大约在几微秒到几十微秒级别,相对一次网络请求动辄几十毫秒的耗时,完全可以忽略不计。
真正影响性能的是请求体的SHA-256哈希计算。如果请求体有几十MB,哈希计算确实会占用一些CPU时间。但这种场景下,Hash过程是流式的,可以边读边算,不需要把整个请求体先读入内存。不过JDK自带的HttpClient对请求体并没有提供流式签名的钩子,所以我建议还是先把请求体转成byte[],如果内存压力大,可以考虑用分片上传接口替代。
安全方面还有一个额外建议:签名计算用的SecretKey不要硬编码在代码里,也不要放在配置文件后提交到Git仓库。推荐的做法是通过环境变量或密钥管理服务注入,在运行时读取。我在示例代码里图省事直接传了字符串,但在生产环境,至少要用System.getenv("API_SECRET_KEY")来获取。
7. 结束语
说了这么多,最后分享一点我自己的实践体会。第一次接触签名算法时,我对那套复杂规则是有点抵触的,觉得不如直接上OAuth2或者简单的Token。但用完之后,我反而喜欢上了这种签名方式的直白——它不依赖全局的会话状态,不需要专门的服务端存储,两边只要共享一套密钥和一套规则,就能完成双向验证。
如果你现在也在做类似的事情,我的建议是小步快跑。先把算法跑通,再逐步加上自定义Header、异常重试、多线程发送这些进阶功能。签名这个东西,最怕的就是一步到位写得花里胡哨,结果出现问题后根本不知道是哪一环算错了。把日志打清楚,每一步的输入输出都记录下来,调试起来会轻松很多。