青云OSS签名避坑指南基于真实企业案例的深度解析
前几天帮一家做视频存储的创业公司排查问题,折腾到凌晨两点,就是青云OSS上传的时候总是返回签名错误。他们之前用AWS S3,以为青云OSS也差不多,结果踩了一堆坑。这篇文章就是把所有踩过的坑、见过的坑,都揉碎了讲给你听。
青云OSS签名机制长什么样
青云QingCloud Object Storage Service(简称QOSS)的签名算法,跟AWS Signature V4很像,但不是一回事。简单说,它用HMAC-SHA256对请求进行签名,签名密钥是你Access Key Secret对请求信息做哈希,然后把签名拼到请求头里发出去。
核心思路就这四步:
1. 构造规范请求字符串(Canonical Request)
2. 构造用于签名的字符串(String to Sign)
3. 计算签名密钥
4. 生成最终签名并拼入请求头
看着简单,坑都在细节里。下面一个个说。
坑一:Content-Type没传,签名直接失效
这件事发生在某教育平台的文件上传模块。他们的Java代码长这样:
// 出问题的代码
String contentType = request.getHeader("Content-Type");
// 前端传过来的Content-Type是"application/octet-stream; charset=utf-8"
// 但Java代码里直接用这个值构造签名
String canonicalRequest = "PUT\n"
+ "/bucket/file.txt\n"
+ "\n" // 空参数
+ "Content-Type:" + contentType + "\n"
+ "\n"
+ contentType;
String stringToSign = "QH3-HMAC-SHA256\n"
+ timestamp + "\n"
+ credentialScope + "\n"
+ sha256(canonicalRequest);
String signature = hmacSha256(signingKey, stringToSign);
问题在哪? 实际请求时,服务器拿到的Content-Type和构造签名时用的Content-Type对不上。浏览器有时候会标准化Content-Type,把application/octet-stream; charset=utf-8变成application/octet-stream,签名就对不上了。
正确做法: 签名的Content-Type必须是实际发出的请求头里的值,一个字都不能差。建议统一用小写、去掉多余参数:
// 修复后的代码
String contentType = "application/octet-stream"; // 统一规范
String canonicalRequest = "PUT\n"
+ "/bucket/file.txt\n"
+ "\n"
+ "content-type:" + contentType + "\n"
+ "\n"
+ contentType;
有个经验法则是:什么时候构造签名,什么时候用当时的header值,不要提前硬编码。
坑二:日期格式写错,整个认证体系崩塌
这个坑踩的人最多。青云OSS的签名字符串里有一个字段叫x-qcs-date,格式必须是YYYYMMDD'T'HHMMSS'Z',也就是UTC时间,而且必须带T和Z。
某物流公司的Python代码里是这么写的:
# 错误的日期格式
from datetime import datetime
# 方式一:返回了本地时间,还带了毫秒
date_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 结果: "2024-01-15 14:32:10" — 完全不对
# 方式二:用了UTC但格式错
date_str = datetime.utcnow().strftime("%Y/%m/%d %H:%M:%S")
# 结果: "2024/01/15 14:32:10" — 还是不对
# 正确的写法
import pytz
from datetime import datetime
utc_now = datetime.now(pytz.UTC)
date_str = utc_now.strftime("%Y%m%dT%H%M%SZ")
# 结果: "20240115T143210Z" ✓
验证技巧: 签名错误的时候,第一反应先去检查日期格式。把打印出来的date_str复制出来,对照YYYYMMDDTHHMMSSZ这个模板,一个字符一个字符对。
坑三:HTTP方法大小写敏感
青云OSS要求HTTP方法必须全大写。GET、POST、PUT、DELETE,一个字都不能小写。
有个前端团队用JavaScript的fetch写上传:
// 出问题的代码
fetch(url, {
method: 'put', // 小写的put,签名会错
headers: {
'Content-Type': 'application/octet-stream',
'Authorization': authHeader,
}
})
// 修复后
fetch(url, {
method: 'PUT', // 必须大写
headers: {
'Content-Type': 'application/octet-stream',
'Authorization': authHeader,
}
})
这个问题很容易被忽略,因为很多其他API对大小写不敏感,但青云OSS的签名验证是严格匹配的。
坑四:请求URL里的路径编码问题
这个坑特别隐蔽。假设你要上传一个文件名是我的文件.txt,在构造规范请求的时候,路径应该是原始的还是URL编码后的?
答案是:原始路径,不要编码。
// 错误写法 — 把路径编码了
String path = URLEncoder.encode("/bucket/我的文件.txt", "UTF-8");
// 结果: "/bucket/%E6%88%91%E7%9A%84%E6%96%87%E4%BB%B6.txt"
// 这个路径会被当成签名的一部分,导致签名错误
// 正确写法 — 保持原始路径
String path = "/bucket/我的文件.txt";
// 但要注意:实际HTTP请求里,服务器会自己编码,我们签名时用原始路径
判断方法: 在青云控制台里用POSTMAN或者curl发请求,看看实际发出的路径是什么样的,签名的路径就应该和这个一致。
坑五:Header名称大小写问题
青云OSS签名验证header的时候,header名称是小写比较,但header值要保持原样。
# 错误:header名称大小写不一致
headers = {
"Content-Type": "application/octet-stream", # 首字母大写
"x-QCS-Date": "20240115T143210Z", # 大小写混用
}
# 正确:签名时统一用小写header名
headers = {
"content-type": "application/octet-stream",
"x-qcs-date": "20240115T143210Z",
}
构造规范请求的时候,header名称必须全小写,并且要按字母顺序排列:
// 按字母顺序排列header
Map<String, String> sortedHeaders = new TreeMap<>(caseInsensitiveMap);
// TreeMap会按key的字典序排列,保证顺序一致
坑六:Credential Scope写错
Credential Scope是签名的一个重要组成部分,格式是:
{Date}/{region}/{service}/qcs3_request
比如:
20240115/cn-bj-05/qcs3_request
很多团队会犯的错误:
# 错误1:region写错
credential_scope = f"20240115/us-east-1/qcs3_request" # 青云的region是cn-bj-xx格式
# 错误2:service写错
credential_scope = f"20240115/cn-bj-05/s3_request" # 青云用的是qcs3,不是s3
# 错误3:Date没截断成日期部分
date_with_time = "20240115T143210Z"
credential_scope = f"{date_with_time}/cn-bj-05/qcs3_request" # 这里应该只用日期部分
# 正确:credential_scope里用的是日期部分,不是完整的时间戳
credential_scope = f"20240115/cn-bj-05/qcs3_request"
坑七:Secret Key的换行符问题
这个坑特别隐蔽。很多团队的Secret Key是从配置文件读取的,但配置文件末尾可能有多余的换行符。
// 错误:没有trim
String secretKey = config.getProperty("qcs.secret.key");
// 实际值是 "abc123\n",多了一个换行符
// 正确:必须trim
String secretKey = config.getProperty("qcs.secret.key").trim();
排查技巧: 把secret key转成hexdump看看,确认没有隐藏字符:
secret = "abc123"
print(secret.encode().hex()) # 打印出来看,确认长度对不上就说明有隐藏字符
坑八:多个Header同名的问题
青云OSS签名时,如果同一个header出现多次,需要把值合并,用逗号分隔。
Accept: application/json
Accept: text/html
应该合并成:
Accept: application/json, text/html
但构造规范请求的时候,如果代码里只是简单拼接,可能会漏掉这个逻辑。
# 错误:重复header没有合并
headers = [
("Accept", "application/json"),
("Accept", "text/html"),
]
canonical_headers = "\n".join(f"{k}:{v}" for k, v in headers)
# 结果: "Accept:application/json\nAccept:text/html"
# 这是错的
# 正确:同名header合并
from collections import defaultdict
header_map = defaultdict(list)
for k, v in headers:
header_map[k.lower()].append(v)
canonical_headers = "\n".join(
f"{k}:{','.join(v)}" for k, v in sorted(header_map.items())
)
# 结果: "accept:application/json,text/html"
完整可用的签名工具类
下面给一个相对完善的Java实现,可以作为参考:
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.TreeMap;
public class QingCloudOSSSigner {
private static final String ALGORITHM = "QH3-HMAC-SHA256";
private static final String HMAC_SHA256 = "HmacSHA256";
/**
* 生成青云OSS签名
*/
public static String sign(
String method,
String path,
String accessKeyId,
String secretKey,
String region,
TreeMap<String, String> headers,
long timestamp
) throws Exception {
// 1. 构造日期部分
String date = String.format("%tY%<tm%<td", timestamp);
String time = String.format("%tH%<tM%<tS", timestamp);
String dateTime = date + "T" + time + "Z";
// 2. 构造Credential Scope
String credentialScope = date + "/" + region + "/qcs3_request";
// 3. 构造规范请求
String canonicalRequest = buildCanonicalRequest(method, path, headers);
// 4. 构造待签名字符串
String stringToSign = ALGORITHM + "\n"
+ dateTime + "\n"
+ credentialScope + "\n"
+ sha256Hex(canonicalRequest);
// 5. 计算签名密钥
byte[] secretKeyBytes = secretKey.getBytes(StandardCharsets.UTF_8);
byte[] dateKey = hmacSha256(secretKeyBytes, date);
byte[] dateRegionKey = hmacSha256(dateKey, region);
byte[] dateRegionServiceKey = hmacSha256(dateRegionKey, "qcs3_request");
byte[] signingKey = hmacSha256(dateRegionServiceKey, "qh3");
// 6. 计算最终签名
String signature = hexEncode(hmacSha256(signingKey, stringToSign));
// 7. 构造Authorization头
String authorization = ALGORITHM + " "
+ "Credential=" + accessKeyId + "/" + credentialScope + ", "
+ "SignedHeaders=" + getSignedHeaders(headers) + ", "
+ "Signature=" + signature;
// 8. 返回需要添加的header
headers.put("x-qcs-date", dateTime);
headers.put("authorization", authorization);
return authorization;
}
private static String buildCanonicalRequest(String method, String path, TreeMap<String, String> headers) {
StringBuilder canonical = new StringBuilder();
canonical.append(method.toUpperCase()).append("\n");
canonical.append(normalizePath(path)).append("\n");
// query string(本例假设没有query参数)
canonical.append("\n");
// 规范headers,按字母顺序排列
for (var entry : headers.entrySet()) {
if (!"authorization".equals(entry.getKey())) {
canonical.append(entry.getKey().toLowerCase())
.append(":")
.append(entry.getValue().trim())
.append("\n");
}
}
canonical.append("\n");
// signed headers
canonical.append(getSignedHeaders(headers));
canonical.append("\n");
// 请求体哈希
canonical.append(sha256Hex(""));
return canonical.toString();
}
private static String normalizePath(String path) {
if (path == null || path.isEmpty()) {
return "/";
}
// 青云OSS要求路径以/开头
if (!path.startsWith("/")) {
path = "/" + path;
}
// 路径中的多个/合并成一个
path = path.replaceAll("/+", "/");
return path;
}
private static String getSignedHeaders(TreeMap<String, String> headers) {
return headers.keySet().stream()
.filter(k -> !"authorization".equals(k))
.map(String::toLowerCase)
.sorted()
.reduce((a, b) -> a + ";" + b)
.orElse("");
}
private static byte[] hmacSha256(byte[] key, String data) throws Exception {
Mac mac = Mac.getInstance(HMAC_SHA256);
mac.init(new SecretKeySpec(key, HMAC_SHA256));
return mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
}
private static String sha256Hex(String data) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(data.getBytes(StandardCharsets.UTF_8));
return hexEncode(hash);
}
private static String hexEncode(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
}
快速排障检查清单
签名验证失败的时候,按这个顺序排查:
- 日期对不对 — 打印
x-qcs-date,确认格式是YYYYMMDDTHHMMSSZ,且是UTC时间 - Secret Key有没有换行 — 打印hex值,确认没有隐藏字符
- HTTP方法大小写 — 确认是全大写
- 路径有没有编码 — 确认规范请求里的路径和实际请求一致
- Header顺序 — 确认是字母序排列
- Content-Type — 确认签名时用的值和实际发出的一致
- region和service — 确认是青云的region格式,service是
qcs3_request
最后说两句
青云OSS的签名机制整体设计是合理的,但和AWS S3的细微差别很容易让有经验的人踩坑。最重要的建议是:不要假设,要验证。每次签名成功之后,把规范请求字符串打印出来,和服务器端能看到的请求做对比,确认每一个字节都对得上。
如果你正在做迁移或者集成,建议先用curl手写一遍完整流程,理解每个字段的含义,再写代码。这个思路能帮你避开80%的坑。
