diff --git a/src/main/java/com/tencentcloudapi/common/AbstractClient.java b/src/main/java/com/tencentcloudapi/common/AbstractClient.java index 277483aea5..a14324d42a 100644 --- a/src/main/java/com/tencentcloudapi/common/AbstractClient.java +++ b/src/main/java/com/tencentcloudapi/common/AbstractClient.java @@ -206,11 +206,12 @@ public void setCredential(Credential credential) { * @throws TencentCloudSDKException If an error occurs during the API call. */ public String call(String action, String jsonPayload) throws TencentCloudSDKException { - HashMap headers = this.getHeaders(); + Credential credSnapshot = this.credential.getSnapshot(); + HashMap headers = this.getHeaders(credSnapshot); headers.put("X-TC-Action", action); headers.put("Content-Type", "application/json; charset=utf-8"); byte[] requestPayload = jsonPayload.getBytes(StandardCharsets.UTF_8); - String authorization = this.getAuthorization(headers, requestPayload); + String authorization = this.getAuthorization(headers, requestPayload, credSnapshot); headers.put("Authorization", authorization); String url = this.profile.getHttpProfile().getProtocol() + this.getEndpoint() + this.path; return this.getResponseBody(url, headers, requestPayload); @@ -228,10 +229,11 @@ public String call(String action, String jsonPayload) throws TencentCloudSDKExce */ public String callOctetStream(String action, HashMap headers, byte[] body) throws TencentCloudSDKException { - headers.putAll(this.getHeaders()); + Credential credSnapshot = this.credential.getSnapshot(); + headers.putAll(this.getHeaders(credSnapshot)); headers.put("X-TC-Action", action); headers.put("Content-Type", "application/octet-stream; charset=utf-8"); - String authorization = this.getAuthorization(headers, body); + String authorization = this.getAuthorization(headers, body, credSnapshot); headers.put("Authorization", authorization); String url = this.profile.getHttpProfile().getProtocol() + this.getEndpoint() + this.path; return this.getResponseBody(url, headers, body); @@ -240,9 +242,10 @@ public String callOctetStream(String action, HashMap headers, by /** * Generates common HTTP headers for Tencent Cloud API requests. * + * @param credSnapshot a point-in-time credential snapshot used to read the token atomically. * @return A HashMap containing the headers. */ - private HashMap getHeaders() { + private HashMap getHeaders(Credential credSnapshot) { HashMap headers = new HashMap(); String timestamp = String.valueOf(System.currentTimeMillis() / 1000); headers.put("X-TC-Timestamp", timestamp); @@ -250,7 +253,7 @@ private HashMap getHeaders() { headers.put("X-TC-Region", this.getRegion()); headers.put("X-TC-RequestClient", SDK_VERSION); headers.put("Host", this.getEndpoint()); - String token = this.credential.getToken(); + String token = credSnapshot.getToken(); if (token != null && !token.isEmpty()) { headers.put("X-TC-Token", token); } @@ -266,12 +269,13 @@ private HashMap getHeaders() { /** * Generates the authorization header for TC3-HMAC-SHA256 signature. * - * @param headers HTTP headers. - * @param body Request payload. + * @param headers HTTP headers. + * @param body Request payload. + * @param credSnapshot a point-in-time credential snapshot used to read secretId/secretKey atomically. * @return The authorization header string. * @throws TencentCloudSDKException If an error occurs during signature generation. */ - private String getAuthorization(HashMap headers, byte[] body) + private String getAuthorization(HashMap headers, byte[] body, Credential credSnapshot) throws TencentCloudSDKException { String endpoint = this.getEndpoint(); // always use post tc3-hmac-sha256 signature process @@ -314,8 +318,8 @@ private String getAuthorization(HashMap headers, byte[] body) String stringToSign = "TC3-HMAC-SHA256\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest; - String secretId = this.credential.getSecretId(); - String secretKey = this.credential.getSecretKey(); + String secretId = credSnapshot.getSecretId(); + String secretKey = credSnapshot.getSecretKey(); byte[] secretDate = Sign.hmac256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date); byte[] secretService = Sign.hmac256(secretDate, service); byte[] secretSigning = Sign.hmac256(secretService, "tc3_request"); @@ -742,6 +746,7 @@ private Response doRequest(String endpoint, AbstractModel request, String action */ private Response doRequestWithTC3(String endpoint, AbstractModel request, String action) throws TencentCloudSDKException, IOException { + Credential credSnapshot = this.credential.getSnapshot(); String httpRequestMethod = this.profile.getHttpProfile().getReqMethod(); if (httpRequestMethod == null) { throw new TencentCloudSDKException( @@ -809,8 +814,8 @@ private Response doRequestWithTC3(String endpoint, AbstractModel request, String if (skipSign) { authorization = "SKIP"; } else { - String secretId = this.credential.getSecretId(); - String secretKey = this.credential.getSecretKey(); + String secretId = credSnapshot.getSecretId(); + String secretKey = credSnapshot.getSecretKey(); byte[] secretDate = Sign.hmac256(("TC3" + secretKey).getBytes(StandardCharsets.UTF_8), date); byte[] secretService = Sign.hmac256(secretDate, service); byte[] secretSigning = Sign.hmac256(secretService, "tc3_request"); @@ -845,7 +850,7 @@ private Response doRequestWithTC3(String endpoint, AbstractModel request, String if (null != this.getRegion()) { hb.add("X-TC-Region", this.getRegion()); } - String token = this.credential.getToken(); + String token = credSnapshot.getToken(); if (token != null && !token.isEmpty()) { hb.add("X-TC-Token", token); } @@ -956,6 +961,11 @@ private String getCanonicalQueryString(HashMap params, String me */ private String formatRequestData(String action, Map param) throws TencentCloudSDKException { + Credential credSnapshot = this.credential.getSnapshot(); + String secretId = credSnapshot.getSecretId(); + String secretKey = credSnapshot.getSecretKey(); + String token = credSnapshot.getToken(); + param.put("Action", action); param.put("RequestClient", this.sdkVersion); param.put("Nonce", String.valueOf(Math.abs(new SecureRandom().nextInt()))); @@ -963,8 +973,8 @@ private String formatRequestData(String action, Map param) param.put("Version", this.apiVersion); // Add SecretId, Region, SignatureMethod, and Token if available. - if (this.credential.getSecretId() != null && (!this.credential.getSecretId().isEmpty())) { - param.put("SecretId", this.credential.getSecretId()); + if (secretId != null && (!secretId.isEmpty())) { + param.put("SecretId", secretId); } if (this.region != null && (!this.region.isEmpty())) { @@ -975,8 +985,8 @@ private String formatRequestData(String action, Map param) param.put("SignatureMethod", this.profile.getSignMethod()); } - if (this.credential.getToken() != null && (!this.credential.getToken().isEmpty())) { - param.put("Token", this.credential.getToken()); + if (token != null && (!token.isEmpty())) { + param.put("Token", token); } if (null != this.profile.getLanguage()) { @@ -994,7 +1004,7 @@ private String formatRequestData(String action, Map param) this.path); // Generate the signature. String sigOutParam = - Sign.sign(this.credential.getSecretKey(), sigInParam, this.profile.getSignMethod()); + Sign.sign(secretKey, sigInParam, this.profile.getSignMethod()); String strParam = ""; try { diff --git a/src/main/java/com/tencentcloudapi/common/Credential.java b/src/main/java/com/tencentcloudapi/common/Credential.java index 4c898a19dd..b2a80ccd15 100644 --- a/src/main/java/com/tencentcloudapi/common/Credential.java +++ b/src/main/java/com/tencentcloudapi/common/Credential.java @@ -92,6 +92,27 @@ public void setToken(String token) { this.token = token; } + /** + * Returns a point-in-time, self-consistent copy of the (secretId, secretKey, token) triple. + * + *

This is the only thread-safe, atomic way to read the credential triple. The refresh hook + * (if any) is invoked exactly once under a lock, and the three fields are then sampled together + * into a new {@code Credential} that does not carry an {@link Updater}. Use this in any code + * path that consumes more than one of the three fields together (e.g. request signing). + * + *

The returned object should be treated as read-only. It is a fresh instance and mutating it + * via the deprecated setters has no effect on the source credential, but doing so will break + * the consistency guarantee for the holder of the snapshot. + * + * @return a point-in-time copy of the credential triple, with no attached updater. + */ + public Credential getSnapshot() { + synchronized (this) { + tryUpdate(); + return new Credential(secretId, secretKey, token); + } + } + private void tryUpdate() { if (updater == null) { return; diff --git a/src/main/java/com/tencentcloudapi/common/provider/CvmRoleCredential.java b/src/main/java/com/tencentcloudapi/common/provider/CvmRoleCredential.java index 8ed3e1cee1..a766080c4a 100644 --- a/src/main/java/com/tencentcloudapi/common/provider/CvmRoleCredential.java +++ b/src/main/java/com/tencentcloudapi/common/provider/CvmRoleCredential.java @@ -13,22 +13,41 @@ import java.util.HashMap; import java.util.Map; -public class CvmRoleCredential extends Credential { +/** + * CvmRoleCredential fetches ephemeral CAM credentials from the CVM instance metadata service. + * + *

It implements the deprecated {@link Credential.Updater} interface and attaches itself as the + * updater of the underlying {@link Credential} so that {@link Credential#getSnapshot()} (and the + * deprecated individual getters) trigger a refresh of the (secretId, secretKey, token) triple when + * the cached credentials are about to expire. Callers should consume the triple via + * {@link Credential#getSnapshot()} for atomicity. + */ +@SuppressWarnings("deprecation") +public class CvmRoleCredential extends Credential implements Credential.Updater { private static final String ENDPOINT = "http://metadata.tencentyun.com/latest/meta-data/cam/security-credentials/"; private static final int EXPIRED_TIME = 300; private String roleName; - private String secretId; - private String secretKey; - private String token; private int expiredTime; public CvmRoleCredential() { super(); + super.setUpdater(this); } public CvmRoleCredential(String roleName) { super(); this.roleName = roleName; + super.setUpdater(this); + } + + @Override + public void update(Credential credential) throws TencentCloudSDKException { + // Cached credentials are still valid; no refresh needed. This guard is essential — + // without it every getter / getSnapshot() call would trigger a metadata HTTP request. + if (super.getSecretId() != null && !needRefresh()) { + return; + } + updateCredential(); } private void updateCredential() throws TencentCloudSDKException { @@ -41,45 +60,14 @@ private void updateCredential() throws TencentCloudSDKException { if (!maps.get("Code").equals("Success")) { throw new TencentCloudSDKException("CVM role token data failed"); } - secretId = (String) maps.get("TmpSecretId"); - secretKey = (String) maps.get("TmpSecretKey"); - token = (String) maps.get("Token"); + // Write the refreshed triple into the parent class fields so that + // getSnapshot() returns a self-consistent copy. + super.setSecretId((String) maps.get("TmpSecretId")); + super.setSecretKey((String) maps.get("TmpSecretKey")); + super.setToken((String) maps.get("Token")); expiredTime = ((Double) maps.get("ExpiredTime")).intValue(); } - public String getSecretId() { - if (secretId == null || needRefresh()) { - try { - updateCredential(); - } catch (TencentCloudSDKException e) { - return null; - } - } - return secretId; - } - - public String getSecretKey() { - if (secretKey == null || needRefresh()) { - try { - updateCredential(); - } catch (TencentCloudSDKException e) { - return null; - } - } - return secretKey; - } - - public String getToken() { - if (token == null || needRefresh()) { - try { - updateCredential(); - } catch (TencentCloudSDKException e) { - return null; - } - } - return token; - } - private boolean needRefresh() { if (expiredTime - new Date().getTime() / 1000 <= EXPIRED_TIME) { return true; diff --git a/src/main/java/com/tencentcloudapi/common/provider/STSCredential.java b/src/main/java/com/tencentcloudapi/common/provider/STSCredential.java index 2783289057..6c2286f802 100644 --- a/src/main/java/com/tencentcloudapi/common/provider/STSCredential.java +++ b/src/main/java/com/tencentcloudapi/common/provider/STSCredential.java @@ -17,9 +17,6 @@ public class STSCredential extends Credential { private String secretKey; private String roleArn; private String roleSessionName; - private String tmpSecretId; - private String tmpSecretKey; - private String token; private String endpoint; private int expiredTime; @@ -35,39 +32,60 @@ public STSCredential(String secretId, String secretKey, String roleArn, String r this(secretId, secretKey, roleArn, roleSessionName, "sts.tencentcloudapi.com"); } + /** + * @deprecated use {@link #getSnapshot()} for any multi-field read. This getter is retained for + * backward compatibility and triggers a refresh on stale state, but the read is not atomic + * across the three fields. + */ + @Override + @Deprecated + @SuppressWarnings("deprecation") public String getSecretId() { - if (tmpSecretId == null || needRefresh()) { + if (super.getSecretId() == null || needRefresh()) { try { updateCredential(); } catch (TencentCloudSDKException e) { return null; } } - return tmpSecretId; + return super.getSecretId(); } + /** + * @deprecated use {@link #getSnapshot()} for any multi-field read. + */ + @Override + @Deprecated + @SuppressWarnings("deprecation") public String getSecretKey() { - if (tmpSecretKey == null || needRefresh()) { + if (super.getSecretKey() == null || needRefresh()) { try { updateCredential(); } catch (TencentCloudSDKException e) { return null; } } - return tmpSecretKey; + return super.getSecretKey(); } + /** + * @deprecated use {@link #getSnapshot()} for any multi-field read. + */ + @Override + @Deprecated + @SuppressWarnings("deprecation") public String getToken() { - if (token == null || needRefresh()) { + if (super.getToken() == null || needRefresh()) { try { updateCredential(); } catch (TencentCloudSDKException e) { return null; } } - return token; + return super.getToken(); } + @SuppressWarnings("deprecation") private void updateCredential() throws TencentCloudSDKException { Credential cred = new Credential(secretId, secretKey); HttpProfile httpProfile = new HttpProfile(); @@ -81,9 +99,11 @@ private void updateCredential() throws TencentCloudSDKException { }.getType()); Map respmap = (Map) map.get("Response"); Map credmap = (Map) respmap.get("Credentials"); - tmpSecretId = credmap.get("TmpSecretId"); - tmpSecretKey = credmap.get("TmpSecretKey"); - token = credmap.get("Token"); + // Write the refreshed triple into the parent class fields so that + // getSnapshot() returns a self-consistent copy. + super.setSecretId(credmap.get("TmpSecretId")); + super.setSecretKey(credmap.get("TmpSecretKey")); + super.setToken(credmap.get("Token")); expiredTime = ((Double) respmap.get("ExpiredTime")).intValue(); } diff --git a/src/test/java/com/tencentcloudapi/integration/common/CredentialSnapshotTest.java b/src/test/java/com/tencentcloudapi/integration/common/CredentialSnapshotTest.java new file mode 100644 index 0000000000..3e50ec9df0 --- /dev/null +++ b/src/test/java/com/tencentcloudapi/integration/common/CredentialSnapshotTest.java @@ -0,0 +1,158 @@ +package com.tencentcloudapi.integration.common; + +import com.tencentcloudapi.common.Credential; +import com.tencentcloudapi.common.exception.TencentCloudSDKException; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.Assert.*; + +/** + * Unit tests for {@link Credential#getSnapshot()}. + * + *

These tests verify the atomicity and consistency guarantees of getSnapshot() both with and + * without an attached (deprecated) Updater, including a concurrent-refresh scenario. + */ +public class CredentialSnapshotTest { + + @Test + public void snapshotReturnsCurrentTriple() { + Credential cred = new Credential("id-1", "key-1", "token-1"); + Credential snap = cred.getSnapshot(); + assertEquals("id-1", snap.getSecretId()); + assertEquals("key-1", snap.getSecretKey()); + assertEquals("token-1", snap.getToken()); + } + + @Test + public void snapshotIsANewObject() { + Credential cred = new Credential("id", "key", "token"); + Credential snap = cred.getSnapshot(); + assertNotSame(cred, snap); + } + + @Test + public void snapshotHasNoUpdater() { + // Even if the source credential carries an Updater, the snapshot must not — otherwise + // the snapshot would mutate on read and lose its point-in-time guarantee. + Credential.Updater updater = new Credential.Updater() { + @Override + public void update(Credential c) throws TencentCloudSDKException { + // no-op + } + }; + @SuppressWarnings("deprecation") + Credential cred = new Credential("id", "key", "token", updater); + assertNotNull(cred.getUpdater()); + Credential snap = cred.getSnapshot(); + assertNull(snap.getUpdater()); + } + + @Test + public void snapshotIsIndependentOfSubsequentSourceMutation() { + Credential cred = new Credential("id-1", "key-1", "token-1"); + Credential snap = cred.getSnapshot(); + cred.setSecretId("id-2"); + cred.setSecretKey("key-2"); + cred.setToken("token-2"); + // The snapshot must still reflect the values at the time it was taken. + assertEquals("id-1", snap.getSecretId()); + assertEquals("key-1", snap.getSecretKey()); + assertEquals("token-1", snap.getToken()); + } + + /** + * A counting Updater that, on each invocation, writes a fresh self-consistent triple into + * the credential. Used to simulate refreshes driven by the deprecated Updater mechanism. + */ + private static final class CountingUpdater implements Credential.Updater { + final AtomicInteger callCount = new AtomicInteger(0); + + @Override + public void update(Credential c) throws TencentCloudSDKException { + int n = callCount.incrementAndGet(); + c.setSecretId("id-" + n); + c.setSecretKey("key-" + n); + c.setToken("token-" + n); + } + } + + @Test + public void snapshotWithUpdaterTriggersRefreshOnce() { + CountingUpdater updater = new CountingUpdater(); + @SuppressWarnings("deprecation") + Credential cred = new Credential("", "", "", updater); + Credential snap = cred.getSnapshot(); + assertEquals(1, updater.callCount.get()); + // The snapshot reflects the refreshed triple as a self-consistent unit. + assertEquals("id-1", snap.getSecretId()); + assertEquals("key-1", snap.getSecretKey()); + assertEquals("token-1", snap.getToken()); + } + + /** + * Concurrent stress test: N threads each call getSnapshot() many times. Every snapshot must + * be self-consistent — the id/key/token suffix numbers must all match, since the Updater + * writes them as a single triple under the source credential's monitor. + * + *

This test would fail for the plain getter sequence getSecretId(); getSecretKey(); + * getToken() under concurrency, because a refresh between two getters would yield e.g. + * "id-3" + "key-4" + "token-4". getSnapshot() eliminates that race. + */ + @Test + public void snapshotConcurrentRefreshesYieldConsistentTriples() throws InterruptedException { + final CountingUpdater updater = new CountingUpdater(); + @SuppressWarnings("deprecation") + final Credential cred = new Credential("", "", "", updater); + + final int threads = 8; + final int iterationsPerThread = 200; + final ExecutorService pool = Executors.newFixedThreadPool(threads); + final CountDownLatch done = new CountDownLatch(threads); + final AtomicReference failure = new AtomicReference(null); + + for (int t = 0; t < threads; t++) { + pool.submit(new Runnable() { + @Override + public void run() { + try { + for (int i = 0; i < iterationsPerThread; i++) { + Credential snap = cred.getSnapshot(); + String id = snap.getSecretId(); + String key = snap.getSecretKey(); + String tok = snap.getToken(); + // Each is of the form "-"; all three must share the same n. + String idN = id.substring(id.lastIndexOf('-') + 1); + String keyN = key.substring(key.lastIndexOf('-') + 1); + String tokN = tok.substring(tok.lastIndexOf('-') + 1); + if (!idN.equals(keyN) || !idN.equals(tokN)) { + failure.compareAndSet(null, + "inconsistent triple: id=" + id + " key=" + key + " token=" + tok); + return; + } + } + } finally { + done.countDown(); + } + } + }); + } + + pool.shutdown(); + assertTrue("threads did not finish in time", + done.await(30, TimeUnit.SECONDS)); + assertNull("observed an inconsistent triple: " + failure.get(), failure.get()); + // The Updater must have been called at least once per thread (roughly), and at most + // once per getSnapshot() call. We only assert the lower bound to keep the test stable + // across schedulers. + assertTrue("updater should have been invoked", updater.callCount.get() > 0); + } +} diff --git a/src/test/java/com/tencentcloudapi/integration/common/provider/ProfileCredentialsProviderTest.java b/src/test/java/com/tencentcloudapi/integration/common/provider/ProfileCredentialsProviderTest.java index 0bb227fd08..9470e6f5e3 100644 --- a/src/test/java/com/tencentcloudapi/integration/common/provider/ProfileCredentialsProviderTest.java +++ b/src/test/java/com/tencentcloudapi/integration/common/provider/ProfileCredentialsProviderTest.java @@ -20,11 +20,11 @@ public void testGetCredentials() throws Exception { // 保存原始user.home属性 String originalUserHome = System.getProperty("user.home"); - + try { // 设置临时目录为用户主目录 System.setProperty("user.home", tempHomeDir.toString()); - + // 写入配置文件内容 String configContent = "[default]\n" + "secret_id = secret_id_test\n" + @@ -33,18 +33,18 @@ public void testGetCredentials() throws Exception { // 测试ProfileCredentialsProvider是否能正确读取 ProfileCredentialsProvider provider = new ProfileCredentialsProvider(); - Credential cred = provider.getCredentials(); - + Credential cred = provider.getCredentials().getSnapshot(); + // 验证读取的凭据是否正确 assertEquals("secret_id_test", cred.getSecretId()); assertEquals("secret_key_test", cred.getSecretKey()); - + } finally { // 恢复原始user.home属性 if (originalUserHome != null) { System.setProperty("user.home", originalUserHome); } - + // 清理临时文件 Files.deleteIfExists(credentialsFile); Files.deleteIfExists(credentialsDir);