fix(core): 基础设施故障不再伪装成凭据失效

鉴权过滤器里除了「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() 生成,
  不含任何真实凭据)。

只改变「异常如何归类与报出」,不触碰正常路径与既有错误码。
This commit is contained in:
2026-09-17 02:35:13 +08:00
parent a3c54249dc
commit 6e475bea03
5 changed files with 351 additions and 0 deletions
@@ -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 = "服务暂不可用,请稍后重试";
/**
* 表示升序的值
*/
@@ -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;
/**
* 鉴权链路上的失败分类:这次失败是<b>用户凭据问题</b>,还是<b>基础设施抖了一下</b>
*
* <p><b>为什么必须分开</b>:鉴权过滤器里除了「token 无效」,还有两次外部依赖调用——
* 核心实例的 {@code /auth/user} 与 Redis 的域名白名单读。此前它们抛出的异常与「token 无效」
* 一起被兜底成 {@code 401 请退出重新登录},于是线上出现这样一条报文:</p>
*
* <pre>
* {"code":401,"message":"请退出重新登录",
* "error":"org.springframework.data.redis.RedisSystemException: Redis exception;
* nested exception is io.lettuce.core.RedisException:
* java.net.SocketException: Connection reset"}
* </pre>
*
* <p>用户照着这句话去重新登录,什么也解决不了——Redis 连接被重置跟他手里的 token 毫无关系;
* 而真正的故障(Redis / 核心实例)被「登录态异常」这层皮盖住了,排查方向从一开始就被带偏。
* 重新登录还会把一份<b>完全有效</b>的凭据丢掉,把一次秒级抖动放大成用户的重复劳动。</p>
*
* <p><b>判定方式</b>:沿 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 链里不会出现这些类型。</p>
*/
public final class AuthFailureUtil {
/** cause 链的遍历深度上限,防御自引用或异常实现里的环 */
private static final int MAX_CAUSE_DEPTH = 16;
private AuthFailureUtil() {
}
/**
* 是否为基础设施故障(Redis 连接被重置/超时、核心实例不可达等)。
*
* <p>这类失败<b>不该</b>报「请退出重新登录」,应报
* {@link com.gxwebsoft.common.core.Constants#SERVICE_UNAVAILABLE_MSG},让用户稍后重试即可。</p>
*
* @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;
}
}
@@ -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;
@@ -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 那一侧。
*
* <p>分类不能只看最外层异常类型——真实异常都是套娃的,见
* {@link AuthFailureUtil#isInfrastructureFailure(Throwable)}。</p>
*/
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));
}
}
@@ -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;
/**
* 鉴权失败分类契约:<b>凭据失效</b>与<b>基础设施抖动</b>必须给前端不同的信号。
*
* <p>线上实际报文(报障原文):</p>
* <pre>
* {"code":401,"message":"请退出重新登录",
* "error":"org.springframework.data.redis.RedisSystemException: Redis exception;
* nested exception is io.lettuce.core.RedisException:
* java.net.SocketException: Connection reset"}
* </pre>
*
* <p>Redis 连接被重置是基础设施抖动,与用户凭据无关:把它报成
* {@code 401 请退出重新登录},等于让用户去做一个<b>解决不了问题</b>的动作(重新登录),
* 而真正的故障(Redis / 核心实例)被这句话掩盖。</p>
*
* <p>本测试不联网:核心实例的 {@code /auth/user} 出口用本地 {@link HttpServer} 打桩,
* Redis 出口用 Mockito 打桩,过滤器之外不需要 Spring 上下文。断言里写死契约值(401/503 与文案),
* 故意不复用常量——常量被改成 401 时,这个测试仍然要能红。</p>
*/
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();
}
}
}