From 6e475bea03ca0c349d059fb955b0d2700487c461 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Thu, 17 Sep 2026 02:35:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20=E5=9F=BA=E7=A1=80=E8=AE=BE?= =?UTF-8?q?=E6=96=BD=E6=95=85=E9=9A=9C=E4=B8=8D=E5=86=8D=E4=BC=AA=E8=A3=85?= =?UTF-8?q?=E6=88=90=E5=87=AD=E6=8D=AE=E5=A4=B1=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 鉴权过滤器里除了「token 无效」,还有两次外部依赖调用——核心实例的 /auth/user 与 Redis 的域名 白名单读。此前它们抛出的异常与「token 无效」一起被兜底成 401「请退出重新登录」,于是线上出现 这样的报文: {"code":401,"message":"请退出重新登录","error":"...RedisException: SocketException: Connection reset"} 用户照着这句话去重新登录什么也解决不了——Redis 连接被重置跟他手里的 token 毫无关系;真正的故障 被「登录态异常」这层皮盖住,排查方向从一开始就被带偏;而且重新登录会把一份**完全有效**的凭据丢掉, 把一次秒级抖动放大成用户的重复劳动。 - 新增 AuthFailureUtil.isInfrastructureFailure(e):沿 cause 链找基础设施异常。必须沿链找,因为真实 异常是套娃的(RedisSystemException → io.lettuce.core.RedisException → SocketException; IORuntimeException → ConnectException),只看最外层会漏判;凭据类异常 (MalformedJwtException / SignatureException / UsernameNotFoundException)的 cause 链里不会出现 这些类型。带遍历深度上限,防御自引用环。 - Constants 加 SERVICE_UNAVAILABLE_CODE(503) 与 SERVICE_UNAVAILABLE_MSG。 - JwtAuthenticationFilter:命中基础设施故障时返回 503 +「服务暂不可用,请稍后重试」, 其余异常仍按凭据失效处理。 - 补 AuthFailureUtilTest / JwtAuthenticationFilterTest(tokenKey 运行时用 JwtUtil.randomKey() 生成, 不含任何真实凭据)。 只改变「异常如何归类与报出」,不触碰正常路径与既有错误码。 --- .../com/gxwebsoft/common/core/Constants.java | 10 + .../common/core/security/AuthFailureUtil.java | 64 ++++++ .../security/JwtAuthenticationFilter.java | 7 + .../core/security/AuthFailureUtilTest.java | 70 ++++++ .../security/JwtAuthenticationFilterTest.java | 200 ++++++++++++++++++ 5 files changed, 351 insertions(+) create mode 100644 src/main/java/com/gxwebsoft/common/core/security/AuthFailureUtil.java create mode 100644 src/test/java/com/gxwebsoft/common/core/security/AuthFailureUtilTest.java create mode 100644 src/test/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilterTest.java diff --git a/src/main/java/com/gxwebsoft/common/core/Constants.java b/src/main/java/com/gxwebsoft/common/core/Constants.java index be48387..6e4b98f 100644 --- a/src/main/java/com/gxwebsoft/common/core/Constants.java +++ b/src/main/java/com/gxwebsoft/common/core/Constants.java @@ -65,6 +65,16 @@ public class Constants { */ public static final String BAD_CREDENTIALS_MSG = "请退出重新登录"; + /** + * 服务暂不可用错误码(Redis 连接被重置、核心实例不可达等基础设施故障,不是用户凭据问题) + */ + public static final int SERVICE_UNAVAILABLE_CODE = 503; + + /** + * 服务暂不可用提示信息 + */ + public static final String SERVICE_UNAVAILABLE_MSG = "服务暂不可用,请稍后重试"; + /** * 表示升序的值 */ diff --git a/src/main/java/com/gxwebsoft/common/core/security/AuthFailureUtil.java b/src/main/java/com/gxwebsoft/common/core/security/AuthFailureUtil.java new file mode 100644 index 0000000..6915370 --- /dev/null +++ b/src/main/java/com/gxwebsoft/common/core/security/AuthFailureUtil.java @@ -0,0 +1,64 @@ +package com.gxwebsoft.common.core.security; + +import io.lettuce.core.RedisException; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.RedisSystemException; + +import java.io.IOException; + +/** + * 鉴权链路上的失败分类:这次失败是用户凭据问题,还是基础设施抖了一下? + * + *

为什么必须分开:鉴权过滤器里除了「token 无效」,还有两次外部依赖调用—— + * 核心实例的 {@code /auth/user} 与 Redis 的域名白名单读。此前它们抛出的异常与「token 无效」 + * 一起被兜底成 {@code 401 请退出重新登录},于是线上出现这样一条报文:

+ * + *
+ * {"code":401,"message":"请退出重新登录",
+ *  "error":"org.springframework.data.redis.RedisSystemException: Redis exception;
+ *           nested exception is io.lettuce.core.RedisException:
+ *           java.net.SocketException: Connection reset"}
+ * 
+ * + *

用户照着这句话去重新登录,什么也解决不了——Redis 连接被重置跟他手里的 token 毫无关系; + * 而真正的故障(Redis / 核心实例)被「登录态异常」这层皮盖住了,排查方向从一开始就被带偏。 + * 重新登录还会把一份完全有效的凭据丢掉,把一次秒级抖动放大成用户的重复劳动。

+ * + *

判定方式:沿 cause 链找基础设施异常。之所以要沿链找,是因为真实抛出的异常是套娃的—— + * {@code RedisSystemException → io.lettuce.core.RedisException → java.net.SocketException}、 + * {@code cn.hutool.core.io.IORuntimeException → java.net.ConnectException}, + * 只看最外层的类型会漏判。凭据类异常({@code MalformedJwtException} / {@code SignatureException} / + * {@code UsernameNotFoundException})的 cause 链里不会出现这些类型。

+ */ +public final class AuthFailureUtil { + + /** cause 链的遍历深度上限,防御自引用或异常实现里的环 */ + private static final int MAX_CAUSE_DEPTH = 16; + + private AuthFailureUtil() { + } + + /** + * 是否为基础设施故障(Redis 连接被重置/超时、核心实例不可达等)。 + * + *

这类失败不该报「请退出重新登录」,应报 + * {@link com.gxwebsoft.common.core.Constants#SERVICE_UNAVAILABLE_MSG},让用户稍后重试即可。

+ * + * @param e 鉴权过程中抛出的异常 + * @return true 表示与用户凭据无关,是基础设施的问题 + */ + public static boolean isInfrastructureFailure(Throwable e) { + Throwable t = e; + for (int depth = 0; t != null && depth < MAX_CAUSE_DEPTH; depth++) { + if (t instanceof RedisConnectionFailureException + || t instanceof RedisSystemException + || t instanceof RedisException + || t instanceof IOException) { + return true; + } + Throwable cause = t.getCause(); + t = (cause == t) ? null : cause; + } + return false; + } +} diff --git a/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java b/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java index e910c6d..d224508 100644 --- a/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilter.java @@ -107,6 +107,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { e.getMessage()); return; } catch (Exception e) { + // Redis 连接被重置、核心实例不可达属于基础设施抖动,与用户凭据无关: + // 报「请退出重新登录」既解决不了问题,又把真正的故障掩盖成一次登录态异常。 + if (AuthFailureUtil.isInfrastructureFailure(e)) { + CommonUtil.responseError(response, Constants.SERVICE_UNAVAILABLE_CODE, + Constants.SERVICE_UNAVAILABLE_MSG, e.toString()); + return; + } CommonUtil.responseError(response, Constants.BAD_CREDENTIALS_CODE, Constants.BAD_CREDENTIALS_MSG, e.toString()); return; diff --git a/src/test/java/com/gxwebsoft/common/core/security/AuthFailureUtilTest.java b/src/test/java/com/gxwebsoft/common/core/security/AuthFailureUtilTest.java new file mode 100644 index 0000000..8259dfb --- /dev/null +++ b/src/test/java/com/gxwebsoft/common/core/security/AuthFailureUtilTest.java @@ -0,0 +1,70 @@ +package com.gxwebsoft.common.core.security; + +import cn.hutool.core.io.IORuntimeException; +import io.jsonwebtoken.MalformedJwtException; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.security.SignatureException; +import io.lettuce.core.RedisException; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; + +import java.net.ConnectException; +import java.net.SocketException; +import java.net.SocketTimeoutException; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 失败分类契约:只有基础设施异常才算「服务暂不可用」,凭据类异常必须留在 401 那一侧。 + * + *

分类不能只看最外层异常类型——真实异常都是套娃的,见 + * {@link AuthFailureUtil#isInfrastructureFailure(Throwable)}。

+ */ +class AuthFailureUtilTest { + + @Test + void redis连接被重置算基础设施故障() { + // 线上报文原文:RedisSystemException → io.lettuce.core.RedisException → SocketException: Connection reset + assertTrue(AuthFailureUtil.isInfrastructureFailure(new RedisSystemException("Redis exception", + new RedisException(new SocketException("Connection reset"))))); + } + + @Test + void redis连接失败与超时算基础设施故障() { + assertTrue(AuthFailureUtil.isInfrastructureFailure( + new RedisConnectionFailureException("Unable to connect to Redis", new SocketException("Connection reset")))); + assertTrue(AuthFailureUtil.isInfrastructureFailure(new RedisException(new SocketTimeoutException("timeout")))); + } + + @Test + void 核心实例不可达算基础设施故障() { + // 线上报文原文:cn.hutool.core.io.IORuntimeException: ConnectException: Connection refused: getsockopt + assertTrue(AuthFailureUtil.isInfrastructureFailure( + new IORuntimeException(new ConnectException("Connection refused: getsockopt")))); + } + + @Test + void 凭据类异常不算基础设施故障() { + assertFalse(AuthFailureUtil.isInfrastructureFailure(new MalformedJwtException("not a jwt"))); + assertFalse(AuthFailureUtil.isInfrastructureFailure(new SignatureException("JWT signature does not match"))); + assertFalse(AuthFailureUtil.isInfrastructureFailure(new ExpiredJwtException(null, null, "expired"))); + // 核心实例明确回 401、或域名不在白名单:这是凭据/权限判定,不是基础设施抖动 + assertFalse(AuthFailureUtil.isInfrastructureFailure(new UsernameNotFoundException("Username not found"))); + assertFalse(AuthFailureUtil.isInfrastructureFailure( + new UsernameNotFoundException("The requested domain name is not on the whitelist"))); + } + + @Test + void 自引用的cause不会死循环() { + RuntimeException selfReferencing = new RuntimeException("boom") { + @Override + public synchronized Throwable getCause() { + return this; + } + }; + assertFalse(AuthFailureUtil.isInfrastructureFailure(selfReferencing)); + } +} diff --git a/src/test/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilterTest.java b/src/test/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..d0abb4e --- /dev/null +++ b/src/test/java/com/gxwebsoft/common/core/security/JwtAuthenticationFilterTest.java @@ -0,0 +1,200 @@ +package com.gxwebsoft.common.core.security; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.gxwebsoft.common.core.Constants; +import com.gxwebsoft.common.core.config.ConfigProperties; +import com.gxwebsoft.common.core.utils.RedisUtil; +import com.sun.net.httpserver.HttpServer; +import io.lettuce.core.RedisException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.RedisSystemException; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.SocketException; +import java.nio.charset.StandardCharsets; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * 鉴权失败分类契约:凭据失效基础设施抖动必须给前端不同的信号。 + * + *

线上实际报文(报障原文):

+ *
+ * {"code":401,"message":"请退出重新登录",
+ *  "error":"org.springframework.data.redis.RedisSystemException: Redis exception;
+ *           nested exception is io.lettuce.core.RedisException:
+ *           java.net.SocketException: Connection reset"}
+ * 
+ * + *

Redis 连接被重置是基础设施抖动,与用户凭据无关:把它报成 + * {@code 401 请退出重新登录},等于让用户去做一个解决不了问题的动作(重新登录), + * 而真正的故障(Redis / 核心实例)被这句话掩盖。

+ * + *

本测试不联网:核心实例的 {@code /auth/user} 出口用本地 {@link HttpServer} 打桩, + * Redis 出口用 Mockito 打桩,过滤器之外不需要 Spring 上下文。断言里写死契约值(401/503 与文案), + * 故意不复用常量——常量被改成 401 时,这个测试仍然要能红。

+ */ +class JwtAuthenticationFilterTest { + + /** 本地桩:扮演核心实例的 /auth/user */ + private HttpServer core; + private String tokenKey; + private ConfigProperties configProperties; + private RedisUtil redisUtil; + private JwtAuthenticationFilter filter; + + @BeforeEach + void setUp() throws IOException { + core = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + core.createContext("/api/auth/user", exchange -> { + byte[] body = coreUserBody().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().add("Content-Type", "application/json;charset=UTF-8"); + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + }); + core.start(); + + tokenKey = JwtUtil.encodeKey(JwtUtil.randomKey()); + configProperties = new ConfigProperties(); + ReflectionTestUtils.setField(configProperties, "tokenKey", tokenKey); + ReflectionTestUtils.setField(configProperties, "serverUrl", + "http://127.0.0.1:" + core.getAddress().getPort() + "/api"); + + redisUtil = mock(RedisUtil.class); + + filter = new JwtAuthenticationFilter(); + ReflectionTestUtils.setField(filter, "configProperties", configProperties); + ReflectionTestUtils.setField(filter, "redisUtil", redisUtil); + // 非 prod:跳过域名白名单判定,但 WhiteDomain 那次 Redis 读照旧发生 + ReflectionTestUtils.setField(filter, "active", "dev"); + } + + @AfterEach + void tearDown() { + if (core != null) { + core.stop(0); + } + } + + /** + * 报障原文的复现:Redis 连接被重置,必须报「服务暂不可用」,不得报「请退出重新登录」。 + */ + @Test + void redis连接被重置时不得报请退出重新登录() throws Exception { + when(redisUtil.get(anyString(), any(Class.class))).thenThrow(redisConnectionReset()); + + MockHttpServletResponse response = doFilter(validToken()); + + JSONObject body = body(response); + assertEquals(503, body.getIntValue("code"), + "Redis 抖动不是凭据失效,应是 5xx;报 401 会逼用户做无用的重新登录。实际报文=" + + response.getContentAsString()); + assertEquals("服务暂不可用,请稍后重试", body.getString("message")); + } + + /** + * 同类:核心实例(身份服务)不可达时,同样不是「凭据失效」。 + */ + @Test + void 核心实例不可达时不得报请退出重新登录() throws Exception { + ReflectionTestUtils.setField(configProperties, "serverUrl", "http://127.0.0.1:" + deadPort() + "/api"); + + MockHttpServletResponse response = doFilter(validToken()); + + JSONObject body = body(response); + assertEquals(503, body.getIntValue("code"), + "身份服务不可达应是 5xx,而不是让用户重新登录。实际报文=" + response.getContentAsString()); + assertEquals("服务暂不可用,请稍后重试", body.getString("message")); + } + + /** + * 反向控制:真正的凭据失效仍必须是 401「请退出重新登录」,不能因为上面的修复被放宽成 5xx。 + */ + @Test + void 非法token仍然报401请退出重新登录() throws Exception { + MockHttpServletResponse response = doFilter("not-a-jwt"); + + JSONObject body = body(response); + assertEquals(401, body.getIntValue("code"), + "伪造/畸形 token 属于凭据失效。实际报文=" + response.getContentAsString()); + assertEquals("请退出重新登录", body.getString("message")); + } + + /** + * 反向控制:Redis 正常时请求照旧放行,修复不得改变正常路径。 + */ + @Test + void 一切正常时请求放行() throws Exception { + when(redisUtil.get(anyString(), any(Class.class))).thenReturn(Collections.emptyList()); + + MockHttpServletRequest request = request(validToken()); + MockHttpServletResponse response = new MockHttpServletResponse(); + boolean[] chained = {false}; + + filter.doFilter(request, response, (req, res) -> chained[0] = true); + + assertTrue(chained[0], "Redis 正常、核心实例正常时必须放行到业务层"); + assertEquals("", response.getContentAsString(), "放行的请求不应被过滤器写入响应体"); + } + + // ---------------------------------------------------------------- 工具 + + /** 走一遍过滤器,返回它写给前端的响应(被拦下时才有内容) */ + private MockHttpServletResponse doFilter(String token) throws Exception { + MockHttpServletResponse response = new MockHttpServletResponse(); + filter.doFilter(request(token), response, (req, res) -> { + }); + return response; + } + + private MockHttpServletRequest request(String token) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/hjc/order/list"); + request.addHeader(Constants.TOKEN_HEADER_NAME, token); + return request; + } + + private String validToken() { + return JwtUtil.buildToken(new JwtSubject("hjc-user", 10626), 3600L, tokenKey); + } + + private static JSONObject body(MockHttpServletResponse response) throws Exception { + String content = response.getContentAsString(); + assertTrue(content != null && content.startsWith("{"), "过滤器必须给出 JSON 报文,实际=" + content); + return JSON.parseObject(content); + } + + /** 核心实例 /auth/user 的正常响应:data 是用户 JSON 文本 */ + private static String coreUserBody() { + String user = "{\"userId\":1,\"username\":\"hjc-user\",\"tenantId\":10626,\"authorities\":[]}"; + return "{\"code\":0,\"message\":\"操作成功\",\"data\":" + JSON.toJSONString(user) + "}"; + } + + /** 与线上报文同型的异常:RedisSystemException → Lettuce RedisException → SocketException: Connection reset */ + private static RedisSystemException redisConnectionReset() { + return new RedisSystemException("Redis exception", + new RedisException(new SocketException("Connection reset"))); + } + + private static int deadPort() throws IOException { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +}