Skip to content

Commit 851f28d

Browse files
sundapengqwencoder
andcommitted
[python] Improve OpenAPI nonce generation and parameter validation to match Java implementation
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
1 parent bda3eb0 commit 851f28d

2 files changed

Lines changed: 133 additions & 18 deletions

File tree

paimon-python/pypaimon/api/auth/dlf_signer.py

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,12 @@
1818
import base64
1919
import hashlib
2020
import hmac
21+
import threading
22+
import time
2123
import uuid
2224
from abc import ABC, abstractmethod
2325
from collections import OrderedDict
24-
from datetime import datetime
26+
from datetime import datetime, timezone
2527
from typing import Dict, Optional
2628
from urllib.parse import unquote
2729

@@ -320,32 +322,37 @@ def sign_headers(
320322
security_token: Optional[str],
321323
host: str
322324
) -> Dict[str, str]:
325+
if now is None:
326+
raise ValueError("Parameter 'now' cannot be None")
327+
if host is None:
328+
raise ValueError("Parameter 'host' cannot be None")
329+
323330
headers = {}
324331

325-
# Date header in RFC 1123 format
326-
headers[self.DATE_HEADER] = now.strftime(self.DATE_FORMAT)
332+
if now.tzinfo is None:
333+
gmt_time = now.replace(tzinfo=timezone.utc)
334+
else:
335+
gmt_time = now.astimezone(timezone.utc)
336+
headers[self.DATE_HEADER] = gmt_time.strftime(self.DATE_FORMAT)
327337

328-
# Accept header
329338
headers[self.ACCEPT_HEADER] = self.ACCEPT_VALUE
330339

331-
# Content-MD5 (if body exists)
332340
if body is not None and body != "":
333341
try:
334342
headers[self.CONTENT_MD5_HEADER] = self._md5_base64(body)
335343
headers[self.CONTENT_TYPE_HEADER] = self.CONTENT_TYPE_VALUE
336344
except Exception as e:
337345
raise RuntimeError(f"Failed to calculate Content-MD5: {e}")
338346

339-
# Host header
340347
headers[self.HOST_HEADER] = host
341348

342-
# x-acs-* headers
343349
headers[self.X_ACS_SIGNATURE_METHOD] = self.SIGNATURE_METHOD_VALUE
344-
headers[self.X_ACS_SIGNATURE_NONCE] = str(uuid.uuid4())
350+
351+
nonce = self._generate_unique_nonce()
352+
headers[self.X_ACS_SIGNATURE_NONCE] = nonce
345353
headers[self.X_ACS_SIGNATURE_VERSION] = self.SIGNATURE_VERSION_VALUE
346354
headers[self.X_ACS_VERSION] = self.API_VERSION
347355

348-
# Security token (if present)
349356
if security_token is not None:
350357
headers[self.X_ACS_SECURITY_TOKEN] = security_token
351358

@@ -358,22 +365,22 @@ def authorization(
358365
host: str,
359366
sign_headers: Dict[str, str]
360367
) -> str:
368+
if rest_auth_parameter is None:
369+
raise ValueError("Parameter 'rest_auth_parameter' cannot be None")
370+
if token is None:
371+
raise ValueError("Parameter 'token' cannot be None")
372+
if host is None:
373+
raise ValueError("Parameter 'host' cannot be None")
374+
if sign_headers is None:
375+
raise ValueError("Parameter 'sign_headers' cannot be None")
376+
361377
try:
362-
# Step 1: Build CanonicalizedHeaders (x-acs-* headers, sorted, lowercase)
363378
canonicalized_headers = self._build_canonicalized_headers(sign_headers)
364-
365-
# Step 2: Build CanonicalizedResource (path + sorted query string)
366379
canonicalized_resource = self._build_canonicalized_resource(rest_auth_parameter)
367-
368-
# Step 3: Build StringToSign
369380
string_to_sign = self._build_string_to_sign(
370381
rest_auth_parameter, sign_headers, canonicalized_headers, canonicalized_resource
371382
)
372-
373-
# Step 4: Calculate signature
374383
signature = self._calculate_signature(string_to_sign, token.access_key_secret)
375-
376-
# Step 5: Build Authorization header
377384
return f"acs {token.access_key_id}:{signature}"
378385

379386
except Exception as e:
@@ -462,6 +469,15 @@ def _calculate_signature(self, string_to_sign: str, access_key_secret: str) -> s
462469
except Exception as e:
463470
raise RuntimeError(f"Failed to calculate signature: {e}")
464471

472+
def _generate_unique_nonce(self) -> str:
473+
"""Generate unique nonce with UUID, timestamp, and thread ID."""
474+
unique_nonce = []
475+
uuid_val = str(uuid.uuid4())
476+
unique_nonce.append(uuid_val)
477+
unique_nonce.append(str(int(time.time() * 1000)))
478+
unique_nonce.append(str(threading.current_thread().ident))
479+
return "".join(unique_nonce)
480+
465481
@staticmethod
466482
def _md5_base64(data: str) -> str:
467483
md5_hash = hashlib.md5(data.encode("utf-8")).digest()

paimon-python/pypaimon/tests/rest/dlf_signer_test.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
# limitations under the License.
1616

1717
import unittest
18+
import re
19+
import threading
1820
from datetime import datetime, timezone
1921

2022
from pypaimon.api.auth import (
@@ -150,6 +152,103 @@ def test_parse_signing_algo_from_uri(self):
150152
self.assertEqual("default", parse(""))
151153
self.assertEqual("default", parse(None))
152154

155+
def test_openapi_sign_headers_with_enhanced_nonce(self):
156+
"""Test enhanced nonce generation."""
157+
signer = DLFOpenApiSigner()
158+
body = '{"CategoryName":"test","CategoryType":"UNSTRUCTURED"}'
159+
now = datetime(2025, 4, 16, 3, 44, 46, tzinfo=timezone.utc)
160+
host = "dlfnext.cn-beijing.aliyuncs.com"
161+
162+
headers = signer.sign_headers(body, now, None, host)
163+
164+
self.assertIsNotNone(headers.get("Date"))
165+
self.assertEqual("application/json", headers.get("Accept"))
166+
self.assertIsNotNone(headers.get("Content-MD5"))
167+
self.assertEqual("application/json", headers.get("Content-Type"))
168+
self.assertEqual(host, headers.get("Host"))
169+
self.assertEqual("HMAC-SHA1", headers.get("x-acs-signature-method"))
170+
171+
nonce_value = headers.get("x-acs-signature-nonce")
172+
self.assertIsNotNone(nonce_value)
173+
174+
uuid_pattern = re.compile(r'[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}')
175+
uuid_match = uuid_pattern.search(nonce_value)
176+
self.assertIsNotNone(uuid_match, f"No UUID pattern found in nonce: {nonce_value}")
177+
178+
digit_pattern = re.compile(r'\d+')
179+
digit_matches = digit_pattern.findall(nonce_value)
180+
self.assertGreater(len(digit_matches), 0, f"No numeric parts found in nonce: {nonce_value}")
181+
182+
timestamp_found = any(len(part) >= 10 for part in digit_matches)
183+
self.assertTrue(timestamp_found, f"No timestamp-like part found in nonce: {nonce_value}")
184+
185+
self.assertEqual("1.0", headers.get("x-acs-signature-version"))
186+
self.assertEqual("2026-01-18", headers.get("x-acs-version"))
187+
188+
def test_concurrent_nonce_generation(self):
189+
"""Test nonce generation thread safety."""
190+
signer = DLFOpenApiSigner()
191+
body = '{"test":"data"}'
192+
now = datetime.now(timezone.utc)
193+
host = "test-host"
194+
thread_count = 10
195+
iterations_per_thread = 50
196+
197+
nonces = set()
198+
199+
def worker():
200+
for _ in range(iterations_per_thread):
201+
headers = signer.sign_headers(body, now, None, host)
202+
nonce = headers.get("x-acs-signature-nonce")
203+
nonces.add(nonce)
204+
205+
threads = []
206+
for _ in range(thread_count):
207+
thread = threading.Thread(target=worker)
208+
threads.append(thread)
209+
thread.start()
210+
211+
for thread in threads:
212+
thread.join()
213+
214+
expected_total = thread_count * iterations_per_thread
215+
self.assertEqual(expected_total, len(nonces),
216+
f"Expected {expected_total} unique nonces, but got {len(nonces)}. "
217+
f"Possible duplicate nonces generated.")
218+
219+
def test_parameter_validation(self):
220+
"""Test parameter validation."""
221+
signer = DLFOpenApiSigner()
222+
223+
with self.assertRaises(ValueError) as context:
224+
signer.sign_headers("body", None, "token", "host")
225+
self.assertIn("'now' cannot be None", str(context.exception))
226+
227+
now = datetime.now(timezone.utc)
228+
with self.assertRaises(ValueError) as context:
229+
signer.sign_headers("body", now, "token", None)
230+
self.assertIn("'host' cannot be None", str(context.exception))
231+
232+
token = DLFToken("ak", "sk", "token", None)
233+
rest_param = RESTAuthParameter("GET", "/", "", {})
234+
headers = signer.sign_headers("", now, "", "host")
235+
236+
with self.assertRaises(ValueError) as context:
237+
signer.authorization(None, token, "host", headers)
238+
self.assertIn("'rest_auth_parameter' cannot be None", str(context.exception))
239+
240+
with self.assertRaises(ValueError) as context:
241+
signer.authorization(rest_param, None, "host", headers)
242+
self.assertIn("'token' cannot be None", str(context.exception))
243+
244+
with self.assertRaises(ValueError) as context:
245+
signer.authorization(rest_param, token, None, headers)
246+
self.assertIn("'host' cannot be None", str(context.exception))
247+
248+
with self.assertRaises(ValueError) as context:
249+
signer.authorization(rest_param, token, "host", None)
250+
self.assertIn("'sign_headers' cannot be None", str(context.exception))
251+
153252

154253
if __name__ == '__main__':
155254
unittest.main()

0 commit comments

Comments
 (0)