From 3a41369fcf7fcedf861a0e2ae674fbff552998aa Mon Sep 17 00:00:00 2001 From: Binary Wang Date: Wed, 14 Oct 2026 18:02:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=8E=A5=E5=8F=A3=E4=BE=9D=E8=B5=96access=5F?= =?UTF-8?q?token=E5=B9=B6=E6=94=AF=E6=8C=81=E6=97=A0token=E8=AF=B7?= =?UTF-8?q?=E6=B1=82=20#4138?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../wx/miniapp/api/WxMaService.java | 55 +++++++- .../miniapp/api/impl/BaseWxMaServiceImpl.java | 28 ++-- .../impl/WxMaCode2SessionSecurityTest.java | 126 +++++++++++++++--- 3 files changed, 185 insertions(+), 24 deletions(-) diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java index e2ae6b423..e062189a0 100644 --- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java +++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/WxMaService.java @@ -5,15 +5,20 @@ import cn.binarywang.wx.miniapp.config.WxMaConfig; import cn.binarywang.wx.miniapp.executor.ApiSignaturePostRequestExecutor; import com.google.gson.JsonObject; +import java.io.IOException; import java.util.Map; import java.util.function.Function; +import me.chanjar.weixin.common.enums.WxType; import me.chanjar.weixin.common.error.WxErrorException; +import me.chanjar.weixin.common.error.WxRuntimeException; import me.chanjar.weixin.common.service.WxImgProcService; import me.chanjar.weixin.common.service.WxOcrService; import me.chanjar.weixin.common.service.WxService; import me.chanjar.weixin.common.util.http.MediaUploadRequestExecutor; import me.chanjar.weixin.common.util.http.RequestExecutor; import me.chanjar.weixin.common.util.http.RequestHttp; +import me.chanjar.weixin.common.util.http.SimpleGetRequestExecutor; +import me.chanjar.weixin.common.util.http.SimplePostRequestExecutor; /** * 微信小程序主服务接口,定义了所有小程序相关的核心操作方法。 @@ -37,7 +42,7 @@ public interface WxMaService extends WxService { String SET_DYNAMIC_DATA_URL = "https://api.weixin.qq.com/wxa/setdynamicdata"; /** - * 获取登录后的 session 信息。 + * 获取登录后的 session 信息,不获取或携带 access_token。 * 登录参数按原始值传入,无需 URL 编码。为防止泄露凭证,失败异常保留错误码和栈帧, * 不包含原始请求、响应、cause 或 suppressed 异常;其他运行时异常可能转换为 WxRuntimeException。 * @@ -124,6 +129,54 @@ String getPaidUnionId(String openid, String transactionId, String mchId, String */ T execute(RequestExecutor executor, String uri, E data) throws WxErrorException; + /** + * 执行无需自动获取或携带 access_token 的自定义请求。 + * 默认实现直接执行请求;SDK 内置实现同时支持 API 主机配置和系统繁忙重试, + * 不因 token 错误刷新配置中的 access_token。 + * + * @param executor 请求执行器 + * @param uri 接口地址 + * @param data 请求数据 + * @param 返回类型 + * @param 请求数据类型 + * @return 接口响应 + * @throws WxErrorException 微信接口调用异常 + */ + default T executeWithoutToken(RequestExecutor executor, String uri, E data) + throws WxErrorException { + try { + return executor.execute(uri, data, WxType.MiniApp); + } catch (IOException e) { + throw new WxRuntimeException(e); + } + } + + /** + * 发起无需自动获取或携带 access_token 的 GET 请求。 + * + * @param url 接口地址 + * @param queryParam 已编码的查询参数,可为 null + * @return 接口响应 + * @throws WxErrorException 微信接口调用异常 + */ + default String getWithoutToken(String url, String queryParam) throws WxErrorException { + return executeWithoutToken(SimpleGetRequestExecutor.create(getRequestHttp()), + url, queryParam); + } + + /** + * 发起无需自动获取或携带 access_token 的 POST 请求,不自动启用接口签名。 + * + * @param url 接口地址 + * @param postData 请求体 + * @return 接口响应 + * @throws WxErrorException 微信接口调用异常 + */ + default String postWithoutToken(String url, String postData) throws WxErrorException { + return executeWithoutToken(SimplePostRequestExecutor.create(getRequestHttp()), + url, postData); + } + /** * 执行带有签名的微信API请求。 * diff --git a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/BaseWxMaServiceImpl.java b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/BaseWxMaServiceImpl.java index 49014318c..7309725b2 100644 --- a/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/BaseWxMaServiceImpl.java +++ b/weixin-java-miniapp/src/main/java/cn/binarywang/wx/miniapp/api/impl/BaseWxMaServiceImpl.java @@ -220,7 +220,7 @@ public WxMaJscode2SessionResult jsCode2SessionInfo(String jsCode) throws WxError params.put("grant_type", "authorization_code"); String result = - get(JSCODE_TO_SESSION_URL, Joiner.on("&").withKeyValueSeparator("=").join(params)); + getWithoutToken(JSCODE_TO_SESSION_URL, Joiner.on("&").withKeyValueSeparator("=").join(params)); return WxMaJscode2SessionResult.fromJson(result); } catch (WxErrorException e) { throw SensitiveRequestUtils.sanitize(e); @@ -373,6 +373,12 @@ public R execute(RequestExecutor executor, String uri, T data) dataForLog); } + @Override + public R executeWithoutToken(RequestExecutor executor, String uri, T data) + throws WxErrorException { + return executeWithRetry(url -> executor.execute(url, data, WxType.MiniApp), uri, null, false); + } + @Override public WxMaApiResponse execute( ApiSignaturePostRequestExecutor executor, @@ -393,10 +399,16 @@ private static interface ExecutorAction { private R executeWithRetry(ExecutorAction executor, String uri, String dataForLog) throws WxErrorException { + return executeWithRetry(executor, uri, dataForLog, true); + } + + private R executeWithRetry(ExecutorAction executor, String uri, String dataForLog, + boolean withToken) throws WxErrorException { int retryTimes = 0; do { try { - return this.executeInternal(executor, uri, dataForLog, false, JSCODE_TO_SESSION_URL.equals(uri)); + return this.executeInternal(executor, uri, dataForLog, false, + !withToken || JSCODE_TO_SESSION_URL.equals(uri), withToken); } catch (WxErrorException e) { if (retryTimes + 1 > this.maxRetryTimes) { log.warn("重试达到最大次数【{}】", maxRetryTimes); @@ -430,13 +442,13 @@ private R executeWithRetry(ExecutorAction executor, String uri, String private R executeInternal( ExecutorAction executor, String uri, String dataForLog, boolean doNotAutoRefreshToken, - boolean code2Session) + boolean code2Session, boolean withToken) throws WxErrorException { - if (uri.contains("access_token=")) { + if (withToken && uri.contains("access_token=")) { throw new IllegalArgumentException("uri参数中不允许有access_token: " + uri); } - String accessToken = getAccessToken(false); + String accessToken = withToken ? getAccessToken(false) : null; String effectiveApiHostUrl = this.getWxMaConfig().getEffectiveApiHostUrl(); if (!WxMaConfig.DEFAULT_API_HOST_URL.equals(effectiveApiHostUrl)) { @@ -444,7 +456,7 @@ private R executeInternal( } String uriWithAccessToken = - uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken; + withToken ? uri + (uri.contains("?") ? "&" : "?") + "access_token=" + accessToken : uri; try { R result = executor.execute(uriWithAccessToken); if (code2Session) { @@ -455,7 +467,7 @@ private R executeInternal( return result; } catch (WxErrorException e) { WxError error = e.getError(); - if (WxConsts.ACCESS_TOKEN_ERROR_CODES.contains(error.getErrorCode())) { + if (withToken && WxConsts.ACCESS_TOKEN_ERROR_CODES.contains(error.getErrorCode())) { // 强制设置WxMaConfig的access token过期了,这样在下一次请求里就会刷新access token Lock lock = this.getWxMaConfig().getAccessTokenLock(); lock.lock(); @@ -474,7 +486,7 @@ private R executeInternal( code2Session ? "[redacted]" : error.getErrorMsg()); // 下一次不再自动重试 // 当小程序误调用第三方平台专属接口时,第三方无法使用小程序的access token,如果可以继续自动获取token会导致无限循环重试,直到栈溢出 - return this.executeInternal(executor, uri, dataForLog, true, code2Session); + return this.executeInternal(executor, uri, dataForLog, true, code2Session, withToken); } } diff --git a/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaCode2SessionSecurityTest.java b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaCode2SessionSecurityTest.java index 90b3b30bb..824ee741b 100644 --- a/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaCode2SessionSecurityTest.java +++ b/weixin-java-miniapp/src/test/java/cn/binarywang/wx/miniapp/api/impl/WxMaCode2SessionSecurityTest.java @@ -47,6 +47,24 @@ public void allHttpClientsSendEncodedValuesAndPreserveSession() throws Exception out.write(body); } }); + server.createContext("/custom", exchange -> { + query.set(exchange.getRequestURI().getRawQuery()); + String result = exchange.getRequestMethod(); + if ("POST".equals(result)) { + java.io.ByteArrayOutputStream body = new java.io.ByteArrayOutputStream(); + byte[] buffer = new byte[128]; + int count; + while ((count = exchange.getRequestBody().read(buffer)) != -1) { + body.write(buffer, 0, count); + } + result += ":" + new String(body.toByteArray(), StandardCharsets.UTF_8); + } + byte[] body = ("{\"result\":\"" + result + "\"}").getBytes(StandardCharsets.UTF_8); + exchange.sendResponseHeaders(200, body.length); + try (java.io.OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); server.start(); try (LogCapture logs = new LogCapture()) { BaseWxMaServiceImpl[] services = {new WxMaServiceImpl(), new WxMaServiceHttpComponentsImpl(), @@ -58,15 +76,22 @@ public void allHttpClientsSendEncodedValuesAndPreserveSession() throws Exception config.setApiHostUrl("http://127.0.0.1:" + server.getAddress().getPort()); service.setWxMaConfig(config); try { + config.expireAccessToken(); + assertEquals(service.getWithoutToken("https://api.weixin.qq.com/custom", "value=a%2Fb"), "{\"result\":\"GET\"}"); + assertEquals(decode(query.get()).get("value"), "a/b"); + assertFalse(query.get().contains("access_token=")); + assertEquals(service.postWithoutToken("https://api.weixin.qq.com/custom?value=1", "payload"), + "{\"result\":\"POST:payload\"}"); + assertEquals(query.get(), "value=1"); response.set(RESULT); for (String code : new String[]{"NORMAL_CODE", "a b\n", "a+&=#?%2F", "中文\uD83D\uDE00", ""}) { assertEquals(service.jsCode2SessionInfo(code).getSessionKey(), SESSION); Map params = decode(query.get()); - assertEquals(params.size(), 5); + assertEquals(params.size(), 4); assertEquals(params.get("js_code"), code); assertEquals(params.get("secret"), config.getSecret()); assertEquals(params.get("appid"), config.getAppid()); - assertEquals(params.get("access_token"), TOKEN); + assertNull(params.get("access_token")); } response.set("{\"errcode\":40029,\"errmsg\":\"" + SECRET + TOKEN + "\"}"); try { @@ -98,10 +123,10 @@ public void errorsAndRetryLogsDoNotContainCredentials() throws Exception { TestService service = service(); service.getWxMaConfig().setApiHostUrl("http://proxy.invalid"); when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))) - .thenThrow(error(40001)).thenThrow(error(-1)).thenReturn(RESULT); + .thenThrow(error(-1)).thenReturn(RESULT); assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION); - assertEquals(service.tokenRequests, 1); - verify(service.executor, times(3)).execute(startsWith("http://proxy.invalid/"), anyString(), eq(WxType.MiniApp)); + assertEquals(service.tokenRequests, 0); + verify(service.executor, times(2)).execute(startsWith("http://proxy.invalid/"), anyString(), eq(WxType.MiniApp)); for (Exception failure : new Exception[]{error(40029), new IOException(SECRET + TOKEN), new IllegalArgumentException(SECRET + TOKEN), new IllegalStateException(SECRET + TOKEN)}) { @@ -123,13 +148,13 @@ public void errorsAndRetryLogsDoNotContainCredentials() throws Exception { } @Test - public void tokenAndParsingFailuresAreProtectedAndNullRemainsNullFailure() throws Exception { + public void loginDoesNotRequireTokenAndParsingFailuresRemainProtected() throws Exception { try (LogCapture logs = new LogCapture()) { TestService service = service(); service.getWxMaConfig().expireAccessToken(); when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenReturn(RESULT); assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION); - assertEquals(service.tokenRequests, 1); + assertEquals(service.tokenRequests, 0); when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))) .thenReturn("{\"session_key\":\"" + SESSION + "\",\"expires_in\":"); try { @@ -146,16 +171,82 @@ public void tokenAndParsingFailuresAreProtectedAndNullRemainsNullFailure() throw } service.getWxMaConfig().expireAccessToken(); service.failToken = true; - try { - service.jsCode2SessionInfo("FAKE_CODE"); - fail("Expected token failure"); - } catch (WxRuntimeException e) { - assertSafe(e); - } + when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenReturn(RESULT); + assertEquals(service.jsCode2SessionInfo("FAKE_CODE").getSessionKey(), SESSION); + assertEquals(service.tokenRequests, 0); logs.assertSafe(); } } + @Test + public void tokenFreeExecutionRetriesBusyButDoesNotRefreshInvalidToken() throws Exception { + TestService service = service(); + service.getWxMaConfig().setApiHostUrl("http://proxy.invalid"); + when(service.executor.execute(anyString(), eq("payload"), eq(WxType.MiniApp))) + .thenThrow(error(-1)).thenReturn("ok"); + assertEquals(service.executeWithoutToken(service.executor, + "https://api.weixin.qq.com/custom", "payload"), "ok"); + verify(service.executor, times(2)).execute("http://proxy.invalid/custom", "payload", WxType.MiniApp); + assertEquals(service.tokenRequests, 0); + + reset(service.executor); + when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenThrow(error(40001)); + try { + service.executeWithoutToken(service.executor, "https://api.weixin.qq.com/custom", "payload"); + fail("Expected invalid token error"); + } catch (WxErrorException e) { + assertEquals(e.getError().getErrorCode(), 40001); + } + verify(service.executor).execute(anyString(), anyString(), eq(WxType.MiniApp)); + assertEquals(service.getWxMaConfig().getAccessToken(), TOKEN); + assertFalse(service.getWxMaConfig().isAccessTokenExpired()); + assertEquals(service.tokenRequests, 0); + } + + @Test + public void tokenFreeExecutionStopsAtRetryLimit() throws Exception { + TestService service = service(); + service.setMaxRetryTimes(2); + when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenThrow(error(-1)); + try { + service.executeWithoutToken(service.executor, "https://api.weixin.qq.com/custom", "payload"); + fail("Expected exhausted retries"); + } catch (WxErrorException e) { + assertEquals(e.getError().getErrorCode(), -1); + } + verify(service.executor, times(3)).execute(anyString(), anyString(), eq(WxType.MiniApp)); + assertEquals(service.tokenRequests, 0); + } + + @Test + public void customServiceDefaultExecutionPreservesTransportContract() throws Exception { + cn.binarywang.wx.miniapp.api.WxMaService service = + mock(cn.binarywang.wx.miniapp.api.WxMaService.class, CALLS_REAL_METHODS); + RequestExecutor executor = mock(RequestExecutor.class); + when(executor.execute("https://example.invalid/custom", "payload", WxType.MiniApp)).thenReturn("ok"); + assertEquals(service.executeWithoutToken(executor, "https://example.invalid/custom", "payload"), "ok"); + IOException failure = new IOException("transport failure"); + when(executor.execute(anyString(), anyString(), eq(WxType.MiniApp))).thenThrow(failure); + try { + service.executeWithoutToken(executor, "https://example.invalid/custom", "payload"); + fail("Expected transport failure"); + } catch (WxRuntimeException e) { + assertSame(e.getCause(), failure); + } + verify(service, never()).getAccessToken(anyBoolean()); + } + + @Test + public void ordinaryExecutionStillAddsAndRefreshesToken() throws Exception { + TestService service = service(); + when(service.executor.execute(anyString(), anyString(), eq(WxType.MiniApp))) + .thenThrow(error(40001)).thenReturn("ok"); + assertEquals(service.get("https://api.weixin.qq.com/custom", "query=1"), "ok"); + verify(service.executor, times(2)).execute( + "https://api.weixin.qq.com/custom?access_token=" + TOKEN, "query=1", WxType.MiniApp); + assertEquals(service.tokenRequests, 1); + } + @Test public void ordinaryGetKeepsSerializedQueryAndExceptionContract() throws Exception { TestService service = service(); @@ -186,10 +277,10 @@ public void accountSwitchKeepsCredentialsWithTheirAccount() throws Exception { String uri = invocation.getArgument(0); if ("SECOND_APP_ID".equals(params.get("appid"))) { assertEquals(params.get("secret"), "SECOND_SECRET"); - assertTrue(uri.endsWith("access_token=SECOND_TOKEN")); + assertFalse(uri.contains("access_token=")); } else { assertEquals(params.get("secret"), SECRET); - assertTrue(uri.endsWith("access_token=" + TOKEN)); + assertFalse(uri.contains("access_token=")); } return RESULT; }); @@ -285,6 +376,11 @@ public String get(String url, String query) throws WxErrorException { return (String) super.execute(executor, url, query); } + @Override + public String getWithoutToken(String url, String query) throws WxErrorException { + return (String) super.executeWithoutToken(executor, url, query); + } + @Override protected String doGetAccessTokenRequest() throws IOException { tokenRequests++;