Spring Security JWT 认证失败时,如何返回正确的异常消息和 URI?
结论
如果业务规则规定 isVerify:false 的 token 不能通过认证,应在自定义 JwtDecoder 中抛出 BadJwtException("User not verified"),让 Spring Security 将其作为无效 token 处理。普通 JwtException 会进入另一条异常处理路径。
响应中的通用认证提示和 /error 很可能来自错误分派期间的第二次认证失败。要确认这一点,需要查看 DispatcherType 和进入 AuthenticationEntryPoint 时的异常类型;现有代码和堆栈还不足以证明完整的分派过程。
为什么异常消息和 URI 都变了?
普通 JwtException 会被当作认证服务异常
日志显示,自定义 decoder 抛出了:
org.springframework.security.oauth2.jwt.JwtException: User not verified
在日志对应的 Spring Security 7.0.3 中,JwtAuthenticationProvider 对这两类异常的处理不同:
BadJwtException被包装成InvalidBearerTokenException,保留异常消息。- 其他
JwtException被包装成AuthenticationServiceException。
可以在 7.0.3 的 JwtAuthenticationProvider 源码 中确认这一行为。
资源服务器的 Bearer token 过滤器默认使用 AuthenticationEntryPointFailureHandler。该处理器默认会重新抛出 AuthenticationServiceException,因此仅配置自定义 authenticationEntryPoint,不一定能让它处理这类异常。参见过滤器源码和失败处理器 API。
/error 可能触发第二次授权检查
如果异常继续传播到容器,应用可能进入指向 /error 的 ERROR 分派。你的配置要求未匹配其他规则的请求拥有 SCOPE_USER。如果 /error 也落入这条规则,且当前没有有效认证,就可能再次触发认证入口,生成通用的“需要完整认证”提示。
Spring Security 的请求授权也适用于 ERROR 分派,所以错误处理请求本身可能被拦截。参见请求授权文档。
这也可能是 webRequest.getDescription(false) 返回 uri=/error 的原因:它描述的是当前处理的请求。删除 uri= 前缀只能改变字符串格式,无法还原原始 URI。
第一步:调整 decoder 抛出的异常类型
如果 CustomJWTDecoder.java:36 中抛出的是:
throw new JwtException("User not verified");
将它改为:
throw new BadJwtException("User not verified");
对应导入为:
import org.springframework.security.oauth2.jwt.BadJwtException;
如果验证逻辑基于已完成签名验证和既有校验的 Jwt 对象,可以这样写:
// jwt 应来自已经执行签名验证及既有校验的 decoder。
if (Boolean.FALSE.equals(jwt.getClaims().get("isVerify"))) {
throw new BadJwtException("User not verified");
}
这个条件只拒绝布尔值 false。字段缺失、值为字符串 "false" 或其他类型时如何处理,需要根据实际 token 格式和业务规则决定。
保留现有的签名、有效期以及已配置的 issuer、audience 等校验。不要为了读取 isVerify 而直接解析未经验证的 JWT payload。
调整后,默认处理链中的预期异常路径为:
BadJwtException("User not verified")
→ InvalidBearerTokenException
→ CustomAuthenticationEntryPoint
→ HandlerExceptionResolver
→ 全局 AuthenticationException 处理方法
第二步:确认全局处理器能处理过滤器转交的异常
认证发生在过滤器链中,@ExceptionHandler 通常不会自动捕获过滤器异常。你已经通过 resolver.resolveException(...) 显式转交异常,可以保留这种做法。
由于传入的 handler 参数是 null,应确保处理方法位于全局生效的 @RestControllerAdvice 中。如果 advice 配置了控制器包或注解筛选条件,还要确认这些条件不会使它在此场景下被排除。
下面的示例使用现有的 ErrorResponse 类型,并通过 Servlet 请求属性获取错误分派时的原始 URI:
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import java.util.Date;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.AuthenticationException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalHandler {
@ExceptionHandler(AuthenticationException.class)
@ResponseStatus(HttpStatus.UNAUTHORIZED)
public ErrorResponse handleAuthenticationException(
AuthenticationException exception,
HttpServletRequest request) {
Object originalUri = request.getAttribute(
RequestDispatcher.ERROR_REQUEST_URI);
String path = originalUri instanceof String uri
? uri
: request.getRequestURI();
return ErrorResponse.builder()
.message(exception.getMessage())
.path(path)
.timestamp(new Date())
.build();
}
}
Unauthorized 是原代码中的业务异常类型;如果仍需处理它,可以保留相应映射。示例单独展示 AuthenticationException 的处理过程。
在当前的 BadJwtException 路径中,包装后的异常会保留 User not verified。如果生产接口有固定错误协议,应将允许公开的失败原因映射为稳定文案,避免把任意底层异常消息直接返回给客户端。
路径回退逻辑只能恢复 URI,不能恢复已被第二次异常替换的消息。因此应优先修正 decoder 的异常类型。
第三步:检查 ERROR 分派是否被重复拦截
如果确认应用需要允许容器进入统一错误处理流程,可以在现有授权规则前添加 ERROR 分派规则:
import jakarta.servlet.DispatcherType;
httpSecurity.authorizeHttpRequests(req -> req
.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
.requestMatchers(PUBLIC_ENDPOINT).permitAll()
.requestMatchers("/admin/**").hasAuthority("SCOPE_ADMIN")
.anyRequest().hasAuthority("SCOPE_USER"));
将这段配置合并进原来的 authorizeHttpRequests。它允许的是 ERROR 分派;客户端直接请求 /error 时通常属于 REQUEST 分派,仍由后续规则判断。
这项调整可以避免错误处理流程被再次拦截,但不会把普通 JwtException 自动变成正常的无效 token 响应。
如何验证修复是否生效?
在 CustomAuthenticationEntryPoint.commence() 设置断点,或临时记录以下信息:
authException.getClass().getName()和authException.getMessage()。request.getDispatcherType()。request.getRequestURI()。request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI)。
用签名有效、但包含布尔值 isVerify:false 的 token 请求受保护接口。修复后的预期是:认证入口收到 InvalidBearerTokenException,消息为 User not verified,响应状态为 401,路径为实际请求 URI。
还要检查缺少 token、token 过期,以及认证成功但缺少权限的情况。缺少权限通常应由 AccessDeniedHandler 返回 403,不应统一改成 401。自定义认证响应时,也应保留适用的 WWW-Authenticate: Bearer 响应头。
备注:内容仅供参考。
