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:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user