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;