U+平台的开放接口都可以使用 HTTP Authorization
标头携带签名信息来进行身份认证,格式为——
Authorization: UPIv2 AccessKey:Nonce:Signature
其中 UPIv2
为身份认证服务版本,当前版本 UPIv2
使用 HmacSHA256 算法进行签名。
AccessKey
为密钥中的 Access Key。
Nonce 是一个唯一的随机字符串,每次请求应使用不同的 Nonce 值,最大长度为 32。
Signature 为本次请求的签名。
Authorization = "UPIv2 " + AccessKey + ":" + RandomNonce + ":" + Signature
RandomNonce = 随机字符串、UUID、时间戳等均可
Signature = base64(HmacSHA256(AccessSecret,
AccessKey + "\n"
+ Date + "\n"
+ RandomNonce + "\n"
+ Verb + "\n"
+ CanonicalPathAndParameters + "\n"
+ Content-Type + "\n"
+ Content-MD5
))
生成请求的时间,该时间为 RFC1123 格式,如 Mon, 10 Jul 2023 13:07:29 GMT
,需要同时在请求标头 Date 中携带该时间。
指接口操作的方法,对于 REST 接口而言即为 HTTP 请求方法,如 GET、POST、PUT、DELETE 等,需大写。
该字段包含请求 Path、Query 和 Form 中的所有参数,组织形式如下——
Path + "?" + Key1 + "=" + Value1 + "&" + Key2 + "=" + Value2 + ... "&" + KeyN + "=" + ValueN
参数拼接需要以字典顺序排序。
Path、Key、Value 均需要进行百分号编码(URL编码)后再拼接。
编码遵循 RFC3986 规范——
A~Z a~z 0~9 - _ . ~
%XY
格式(其中 XY 为字符的 ASCII 码的十六进制)%XY%ZA...
格式%20
而非 +
号*
号编码为 %2A
拼接过程需遵循——
?
;请求中 Content-Type
标头的值,可以为空,为空时使用空字符串。
注:当因客户端一些底层逻辑(如微信小程序)导致 Content-Type 异常时,可以增加一个
X-Ca-Signed-Content-Type: application/json
自定义头,使用该值进行签名。
按照 RFC1864 规范计算请求体的 MD5 摘要,然后经过 Base64 编码后得到的字符串,需要同时在请求标头 Content-MD5 中携带该字符串,可以为空,为空时使用空字符串。
只有存在请求体,且请求体不是 Form 形式时才进行 Content-MD5 的计算。
计算方式参考——
ContentMD5 = Base64(MD5(content.getBytes(UTF-8)))
当签名校验失败时,会将服务端的签名字符串(StringToSign)放到响应头 X-Ca-Error-Message
中返回给客户端,用户可以将其和本地的签名字符串进行比较查找问题。
由于 HTTP 标头中无法表示换行,因此 StringToSign 中的换行符将替换为 #
,例如——
Invalid Signature, Server StringToSign: `MDLhiMQPw0wlNHWorLIiyXiGzHylrcMS#Mon, 10 Jul 2023 13:07:29 GMT#4abb2e885aaf4b0e9db446dac23a3819#GET#/app/v1/courses?name=TEST##`
说明服务端的签名字符串为——
MDLhiMQPw0wlNHWorLIiyXiGzHylrcMS # AccessKey
Mon, 10 Jul 2023 13:07:29 GMT # Date
4abb2e885aaf4b0e9db446dac23a3819 # Nonce
GET # Verb
/app/v1/courses?name=TEST # CanonicalPathAndParameters
# Content-Type,该请求没有请求体,所以Content-Type使用空串
# Content-MD5,该请求没有请求体,所以Content-MD5为空串
假设——
UhH3QfuFW0O0JAkmi2IFU5m95VI0Kziv
69589UwjICw7k9gjuyIY6IgajTHxEHR5MaYFawS8YlLEwaQpzN2HBYRtx0fyakvI
api.example.com
POST
/api/v1/courses
public static void main(String[] args) throws NoSuchAlgorithmException, InvalidKeyException {
String accessKey = "UhH3QfuFW0O0JAkmi2IFU5m95VI0Kziv";
String accessSecret = "69589UwjICw7k9gjuyIY6IgajTHxEHR5MaYFawS8YlLEwaQpzN2HBYRtx0fyakvI";
// 请求时间:Date
DateTimeFormatter dtf = DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US).withZone(ZoneId.of("GMT"));
String date = dtf.format(Instant.now());
// 随机字符串:Nonce
String nonce = UUID.randomUUID().toString().replace("-", "");
// Verb
String method = "POST";
// CanonicalPathAndParameters
String canonicalPath = percentEncode("/api/v1/courses");
TreeMap<String, String> queries = new TreeMap<>(String::compareTo);
queries.put(percentEncode("region"), percentEncode("Prov.11"));
queries.put(percentEncode("nature"), percentEncode("Senior"));
queries.put(percentEncode("tags"), percentEncode("Java,Spring,MySQL"));
queries.put(percentEncode("feature"), percentEncode(""));
StringJoiner canonicalParams = new StringJoiner("&");
for (Map.Entry<String, String> entry : queries.entrySet()) {
canonicalParams.add(entry.getKey() + "=" + entry.getValue());
}
String canonicalPathAndParameters = canonicalPath + "?" + canonicalParams;
// Content-Type
String contentType = MediaType.APPLICATION_JSON_VALUE;
// Content-MD5
// 构造原始请求体
Map<String, Object> course = new HashMap<>();
course.put("name", "Spring增删改查");
course.put("code", "ABC");
course.put("author", "Tom");
Map<String, Object> metadata = new HashMap<>();
metadata.put("version", "1.0");
metadata.put("grade", "2023");
course.put("metadata", metadata);
String content = new Gson().toJson(course);
// 生成请求体 MD5 摘要,并进行 Base64 编码
MessageDigest md = MessageDigest.getInstance("MD5");
md.update(content.getBytes(StandardCharsets.UTF_8));
String contentMD5 = Base64.getEncoder().encodeToString(md.digest());
// 组装签名字符串
String stringToSign = accessKey + "\n"
+ date + "\n"
+ nonce + "\n"
+ method + "\n"
+ canonicalPathAndParameters + "\n"
+ contentType + "\n"
+ contentMD5;
System.out.printf("### string to sign:\n%s\n\n", stringToSign);
// 生成签名
SecretKey keySpec = new SecretKeySpec(accessSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(keySpec);
String signature = Base64.getEncoder().encodeToString(mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8)));
System.out.printf("### signature:\n%s\n\n", signature);
// 拼装 Authorization 请求头的值
String authorization = "UPIv2 " + accessKey + ":" + nonce + ":" + signature;
System.out.printf("### authorization:\n%s\n\n", authorization);
}
/** 辅助方法:用于对请求路径和参数进行 URL 编码 */
public static String percentEncode(String value) {
if (value == null) {
return null;
}
/*
URLEncoder.encode 并非 RFC3986 规范,编码后需要额外处理——
空格会被编码为 + 号,替换为 %20
~ 会被编码为 %7E,替换回 ~
* 不会被编码,需要替换为 %2A
*/
return URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("+", "%20")
.replace("*", "%2A")
.replace("%7E", "~");
}