Merge pull request '修复oss签名时间格式错误' (#7) from hermes/hermes-15235e5a into master
Some checks failed
CI / verify (push) Has been cancelled
Some checks failed
CI / verify (push) Has been cancelled
Reviewed-on: http://82.157.175.59:3000/GenarrativeAI/Genarrative/pulls/7
This commit was merged in pull request #7.
This commit is contained in:
@@ -14,7 +14,13 @@
|
|||||||
- 关联:相关文件、文档、提交或 Issue
|
- 关联:相关文件、文档、提交或 Issue
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
## OSS V4 签名时间和 bucket/object_key 兼容
|
||||||
|
|
||||||
|
- 现象:OSS V4 私有读签名在部分时间点失败,可能出现 `OSS V4 签名时间格式化失败` 或服务端判定签名格式错误;排查用例中 bucket 为 `xushi-dev`,object_key 为 `generated-square-hole-assets/.../image.png`。
|
||||||
|
- 原因:旧逻辑依赖 `time::Time::to_string()` 再去掉冒号,小时小于 10 时输出不稳定补零;同时排查时容易把 bucket 名误当成 object_key 的一部分。
|
||||||
|
- 处理:OSS V4 `x-oss-date` 使用固定宽度 `yyyyMMdd'T'HHmmss'Z'` 格式化;调用读签名或 `HEAD Object` 时只传 object_key,不要传 `bucket/object_key` 拼接路径。
|
||||||
|
- 验证:运行 `cd server-rs && cargo test -p platform-oss -- --nocapture`,并用 bucket=`xushi-dev`、object_key=`generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png` 覆盖签名生成。
|
||||||
|
- 关联:`server-rs/crates/platform-oss/src/lib.rs`、`server-rs/crates/platform-oss/README.md`。
|
||||||
|
|
||||||
## 中文乱码与编码风险
|
## 中文乱码与编码风险
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@
|
|||||||
1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。
|
1. 当前产品口径为服务器上传 AI 生成资源、Web 端只负责读取。
|
||||||
2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract,避免浏览器拿到 OSS 写权限。
|
2. 因此 `STS` 不作为默认上传主链,`api-server` 只暴露禁用式 contract,避免浏览器拿到 OSS 写权限。
|
||||||
3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`。
|
3. 服务端生成资源应优先复用 `OssClient::put_object`,上传成功后再走对象确认链路写入 `asset_object`。
|
||||||
|
4. 读签名和 `HEAD Object` 的入参必须直接传 object_key,不要把 bucket 名拼进路径;例如 `generated-square-hole-assets/.../image.png` 才是正确入参,`xushi-dev/...` 这类前缀不属于 object_key。
|
||||||
|
5. OSS V4 `x-oss-date` 必须固定为 `yyyyMMdd'T'HHmmss'Z'`,不能依赖 `time::Time::to_string()`;后者在小时小于 10 时可能输出非补零时间,导致签名格式错误。
|
||||||
|
|
||||||
## 3. 边界约束
|
## 3. 边界约束
|
||||||
|
|
||||||
|
|||||||
@@ -419,8 +419,11 @@ impl OssClient {
|
|||||||
let policy = serde_json::to_string(&policy_json)
|
let policy = serde_json::to_string(&policy_json)
|
||||||
.map_err(|error| OssError::SerializePolicy(format!("序列化 policy 失败:{error}")))?;
|
.map_err(|error| OssError::SerializePolicy(format!("序列化 policy 失败:{error}")))?;
|
||||||
let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
|
let encoded_policy = BASE64_STANDARD.encode(policy.as_bytes());
|
||||||
let signature =
|
let signature = sign_v4_content(
|
||||||
sign_v4_content(&self.config.access_key_secret, &signature_scope, &encoded_policy)?;
|
&self.config.access_key_secret,
|
||||||
|
&signature_scope,
|
||||||
|
&encoded_policy,
|
||||||
|
)?;
|
||||||
|
|
||||||
Ok(OssPostObjectResponse {
|
Ok(OssPostObjectResponse {
|
||||||
signature_version: "v4",
|
signature_version: "v4",
|
||||||
@@ -492,11 +495,8 @@ impl OssClient {
|
|||||||
let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key));
|
let canonical_uri = build_v4_canonical_uri(&self.config.bucket, Some(&object_key));
|
||||||
let object_url_path = format!("/{}", encode_url_path(&object_key));
|
let object_url_path = format!("/{}", encode_url_path(&object_key));
|
||||||
let additional_headers = "host";
|
let additional_headers = "host";
|
||||||
let canonical_headers = format!(
|
let canonical_headers =
|
||||||
"host:{}.{}\n",
|
format!("host:{}.{}\n", self.config.bucket(), self.config.endpoint());
|
||||||
self.config.bucket(),
|
|
||||||
self.config.endpoint()
|
|
||||||
);
|
|
||||||
let canonical_query = build_canonical_query_string(&query);
|
let canonical_query = build_canonical_query_string(&query);
|
||||||
let canonical_request = build_v4_canonical_request(
|
let canonical_request = build_v4_canonical_request(
|
||||||
Method::GET.as_str(),
|
Method::GET.as_str(),
|
||||||
@@ -506,10 +506,16 @@ impl OssClient {
|
|||||||
additional_headers,
|
additional_headers,
|
||||||
OSS_UNSIGNED_PAYLOAD,
|
OSS_UNSIGNED_PAYLOAD,
|
||||||
);
|
);
|
||||||
let string_to_sign =
|
let string_to_sign = build_v4_string_to_sign(
|
||||||
build_v4_string_to_sign(query["x-oss-date"].as_str(), &signature_scope, &canonical_request);
|
query["x-oss-date"].as_str(),
|
||||||
let signature =
|
&signature_scope,
|
||||||
sign_v4_content(&self.config.access_key_secret, &signature_scope, &string_to_sign)?;
|
&canonical_request,
|
||||||
|
);
|
||||||
|
let signature = sign_v4_content(
|
||||||
|
&self.config.access_key_secret,
|
||||||
|
&signature_scope,
|
||||||
|
&string_to_sign,
|
||||||
|
)?;
|
||||||
query.insert("x-oss-signature".to_string(), signature);
|
query.insert("x-oss-signature".to_string(), signature);
|
||||||
let signed_url = format!(
|
let signed_url = format!(
|
||||||
"{}{}?{}",
|
"{}{}?{}",
|
||||||
@@ -1036,8 +1042,13 @@ fn signed_request_builder(
|
|||||||
additional_headers,
|
additional_headers,
|
||||||
&body_sha256,
|
&body_sha256,
|
||||||
);
|
);
|
||||||
let string_to_sign = build_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
|
let string_to_sign =
|
||||||
let signature = sign_v4_content(config.access_key_secret(), &signature_scope, &string_to_sign)?;
|
build_v4_string_to_sign(&signed_at_text, &signature_scope, &canonical_request);
|
||||||
|
let signature = sign_v4_content(
|
||||||
|
config.access_key_secret(),
|
||||||
|
&signature_scope,
|
||||||
|
&string_to_sign,
|
||||||
|
)?;
|
||||||
let mut builder = client
|
let mut builder = client
|
||||||
.request(method, target_url)
|
.request(method, target_url)
|
||||||
.header("x-oss-content-sha256", body_sha256)
|
.header("x-oss-content-sha256", body_sha256)
|
||||||
@@ -1065,33 +1076,23 @@ fn signed_request_builder(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result<String, OssError> {
|
fn build_v4_signature_scope(endpoint: &str, signed_at: OffsetDateTime) -> Result<String, OssError> {
|
||||||
let date = signed_at
|
let date = signed_at.date().to_string().replace('-', "");
|
||||||
.date()
|
|
||||||
.to_string()
|
|
||||||
.replace('-', "");
|
|
||||||
let region = extract_oss_region(endpoint)?;
|
let region = extract_oss_region(endpoint)?;
|
||||||
|
|
||||||
Ok(format!("{date}/{region}/{OSS_V4_SERVICE}/{OSS_V4_REQUEST}"))
|
Ok(format!("{date}/{region}/{OSS_V4_SERVICE}/{OSS_V4_REQUEST}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_v4_signature_date(signed_at: OffsetDateTime) -> Result<String, OssError> {
|
fn build_v4_signature_date(signed_at: OffsetDateTime) -> Result<String, OssError> {
|
||||||
let date = signed_at
|
// 中文注释:time::Time 的 Display 在小时小于 10 时不会稳定补零,OSS V4 必须使用固定宽度 UTC 时间。
|
||||||
.date()
|
Ok(format!(
|
||||||
.to_string()
|
"{:04}{:02}{:02}T{:02}{:02}{:02}Z",
|
||||||
.replace('-', "");
|
signed_at.year(),
|
||||||
let time = signed_at
|
u8::from(signed_at.month()),
|
||||||
.time()
|
signed_at.day(),
|
||||||
.to_string()
|
signed_at.hour(),
|
||||||
.split('.')
|
signed_at.minute(),
|
||||||
.next()
|
signed_at.second()
|
||||||
.unwrap_or("00:00:00")
|
))
|
||||||
.replace(':', "");
|
|
||||||
|
|
||||||
if time.len() != 6 {
|
|
||||||
return Err(OssError::Sign("OSS V4 签名时间格式化失败".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(format!("{date}T{time}Z"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_v4_canonical_uri(bucket: &str, object_key: Option<&str>) -> String {
|
fn build_v4_canonical_uri(bucket: &str, object_key: Option<&str>) -> String {
|
||||||
@@ -1116,9 +1117,7 @@ fn extract_oss_region(endpoint: &str) -> Result<String, OssError> {
|
|||||||
.map(str::to_string)
|
.map(str::to_string)
|
||||||
.filter(|region| !region.is_empty())
|
.filter(|region| !region.is_empty())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
OssError::InvalidConfig(format!(
|
OssError::InvalidConfig(format!("OSS endpoint 无法解析 region,当前值:{endpoint}"))
|
||||||
"OSS endpoint 无法解析 region,当前值:{endpoint}"
|
|
||||||
))
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1131,7 +1130,10 @@ fn sign_v4_content(
|
|||||||
Ok(hex_sha256_hmac(&signing_key, content.as_bytes()))
|
Ok(hex_sha256_hmac(&signing_key, content.as_bytes()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_v4_signing_key(access_key_secret: &str, signature_scope: &str) -> Result<Vec<u8>, OssError> {
|
fn build_v4_signing_key(
|
||||||
|
access_key_secret: &str,
|
||||||
|
signature_scope: &str,
|
||||||
|
) -> Result<Vec<u8>, OssError> {
|
||||||
let mut parts = signature_scope.split('/');
|
let mut parts = signature_scope.split('/');
|
||||||
let date = parts
|
let date = parts
|
||||||
.next()
|
.next()
|
||||||
@@ -1160,8 +1162,7 @@ fn hmac_sha256_raw(key: &[u8], content: &str) -> Result<Vec<u8>, OssError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn hex_sha256_hmac(key: &[u8], content: &[u8]) -> String {
|
fn hex_sha256_hmac(key: &[u8], content: &[u8]) -> String {
|
||||||
let mut signer = HmacSha256::new_from_slice(key)
|
let mut signer = HmacSha256::new_from_slice(key).expect("HMAC-SHA256 accepts keys of any size");
|
||||||
.expect("HMAC-SHA256 accepts keys of any size");
|
|
||||||
signer.update(content);
|
signer.update(content);
|
||||||
hex_lower(&signer.finalize().into_bytes())
|
hex_lower(&signer.finalize().into_bytes())
|
||||||
}
|
}
|
||||||
@@ -1213,7 +1214,13 @@ fn build_v4_canonical_headers(headers: &BTreeMap<String, String>) -> String {
|
|||||||
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
|
fn build_canonical_query_string(params: &BTreeMap<String, String>) -> String {
|
||||||
params
|
params
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(key, value)| format!("{}={}", encode_url_query_value(key), encode_url_query_value(value)))
|
.map(|(key, value)| {
|
||||||
|
format!(
|
||||||
|
"{}={}",
|
||||||
|
encode_url_query_value(key),
|
||||||
|
encode_url_query_value(value)
|
||||||
|
)
|
||||||
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("&")
|
.join("&")
|
||||||
}
|
}
|
||||||
@@ -1327,18 +1334,19 @@ mod tests {
|
|||||||
response.form_fields.signature_version,
|
response.form_fields.signature_version,
|
||||||
OSS_V4_ALGORITHM.to_string()
|
OSS_V4_ALGORITHM.to_string()
|
||||||
);
|
);
|
||||||
assert!(response
|
assert!(
|
||||||
.form_fields
|
response
|
||||||
.credential
|
.form_fields
|
||||||
.starts_with("test-access-key-id/"));
|
.credential
|
||||||
assert!(response
|
.starts_with("test-access-key-id/")
|
||||||
.form_fields
|
|
||||||
.credential
|
|
||||||
.ends_with("/cn-shanghai/oss/aliyun_v4_request"));
|
|
||||||
assert_eq!(
|
|
||||||
response.form_fields.date.len(),
|
|
||||||
"20260507T120000Z".len()
|
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
response
|
||||||
|
.form_fields
|
||||||
|
.credential
|
||||||
|
.ends_with("/cn-shanghai/oss/aliyun_v4_request")
|
||||||
|
);
|
||||||
|
assert_eq!(response.form_fields.date.len(), "20260507T120000Z".len());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
response.form_fields.metadata.get("x-oss-meta-asset-kind"),
|
response.form_fields.metadata.get("x-oss-meta-asset-kind"),
|
||||||
Some(&"character-visual".to_string())
|
Some(&"character-visual".to_string())
|
||||||
@@ -1441,13 +1449,48 @@ mod tests {
|
|||||||
.signed_url
|
.signed_url
|
||||||
.contains("x-oss-signature-version=OSS4-HMAC-SHA256")
|
.contains("x-oss-signature-version=OSS4-HMAC-SHA256")
|
||||||
);
|
);
|
||||||
assert!(response
|
assert!(
|
||||||
.signed_url
|
response
|
||||||
.contains("x-oss-credential=test-access-key-id%2F"));
|
.signed_url
|
||||||
|
.contains("x-oss-credential=test-access-key-id%2F")
|
||||||
|
);
|
||||||
assert!(response.signed_url.contains("&x-oss-expires=300"));
|
assert!(response.signed_url.contains("&x-oss-expires=300"));
|
||||||
assert!(response.signed_url.contains("&x-oss-signature="));
|
assert!(response.signed_url.contains("&x-oss-signature="));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sign_get_object_url_uses_square_hole_object_key_without_bucket_prefix() {
|
||||||
|
let client = OssClient::new(
|
||||||
|
OssConfig::new(
|
||||||
|
"xushi-dev".to_string(),
|
||||||
|
"oss-cn-shanghai.aliyuncs.com".to_string(),
|
||||||
|
"test-access-key-id".to_string(),
|
||||||
|
"test-access-key-secret".to_string(),
|
||||||
|
DEFAULT_READ_EXPIRE_SECONDS,
|
||||||
|
DEFAULT_POST_EXPIRE_SECONDS,
|
||||||
|
DEFAULT_POST_MAX_SIZE_BYTES,
|
||||||
|
DEFAULT_SUCCESS_ACTION_STATUS,
|
||||||
|
)
|
||||||
|
.expect("OSS config should be valid"),
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client
|
||||||
|
.sign_get_object_url(OssSignedGetObjectUrlRequest {
|
||||||
|
object_key: "generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string(),
|
||||||
|
expire_seconds: Some(300),
|
||||||
|
})
|
||||||
|
.expect("square hole object key should build signed url");
|
||||||
|
|
||||||
|
assert_eq!(response.bucket, "xushi-dev".to_string());
|
||||||
|
assert_eq!(
|
||||||
|
response.object_key,
|
||||||
|
"generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png".to_string()
|
||||||
|
);
|
||||||
|
assert!(response
|
||||||
|
.signed_url
|
||||||
|
.starts_with("https://xushi-dev.oss-cn-shanghai.aliyuncs.com/generated-square-hole-assets/square-hole-session-546d881972684be2980a2a882cd0cc71/square-hole-profile-134411276ce1469cbe398f946a25d7f8/square-hole-shape-image/rabbit-option/asset-1777979289912039/image.png?"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sign_get_object_url_rejects_unsupported_prefix() {
|
fn sign_get_object_url_rejects_unsupported_prefix() {
|
||||||
let client = build_client();
|
let client = build_client();
|
||||||
|
|||||||
Reference in New Issue
Block a user