在微服务项目中,网关承担着请求入口的角色,所有外部请求都要先过网关这一关。如果鉴权逻辑散落在各个业务服务里,不仅代码重复,一旦某个服务漏写了校验就会形成安全缺口。把鉴权统一收敛到网关层,通过全局过滤器拦截请求、解析Token,遇到异常时直接返回统一的错误结构,是最常见的实践方案。这篇文章以Spring Cloud Gateway为基础,完整走一遍过滤器拦截和异常封装的实现过程。

一、网关过滤器的基本原理与选择
Spring Cloud Gateway提供了两种过滤器接口:GlobalFilter和GatewayFilter。前者对经过网关的所有路由生效,不需要额外配置;后者则需要绑定到具体路由上,适合针对个别服务做定制处理。做统一鉴权显然应该选择GlobalFilter,这样无论请求要转发到哪个后端服务,都会先经过我们的校验逻辑。
过滤器的执行顺序由getOrder方法的返回值决定,数值越小优先级越高。Spring Cloud Gateway内置的过滤器中,NettyWriteResponseFilter的order值是-1,负责把响应写回客户端。自定义鉴权过滤器通常设置在-100左右,确保在转发请求之前完成校验。另外要注意,如果项目里同时存在多个全局过滤器,比如日志过滤器、限流过滤器,order值的设计要保证鉴权在限流之后、路由转发之前执行,避免被限流拦截的请求白白消耗Token解析的性能。
过滤器接口的核心方法签名是Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain)。校验通过后调用chain.filter(exchange)把请求交给下一个过滤器;校验失败则不调用链,而是直接向exchange中写入响应,请求到此为止。这个“是否继续调用链”的分支就是网关层拦截的本质。
二、实现一个鉴权全局过滤器
下面写一个完整的鉴权过滤器。它会从请求头中取出Token,校验其有效性,失败时根据不同的错误原因返回不同的HTTP状态码。Token的校验逻辑这里用JWT做示例,实际项目中也可以换成调用认证中心、查询Redis缓存等方式,拦截的结构完全一样。
@Component
public class AuthFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String token = request.getHeaders().getFirst("Authorization");
// 无Token直接拒绝
if (token == null || token.isEmpty()) {
return writeErrorResponse(exchange, 401, "TOKEN_MISSING", "请求未携带令牌");
}
if (token.startsWith("Bearer ")) {
token = token.substring(7);
}
try {
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(token)
.getBody();
// 校验通过,把用户信息透传给下游服务
ServerHttpRequest newRequest = request.mutate()
.header("X-User-Id", claims.getSubject())
.build();
return chain.filter(exchange.mutate().request(newRequest).build());
} catch (ExpiredJwtException e) {
return writeErrorResponse(exchange, 401, "TOKEN_EXPIRED", "令牌已过期");
} catch (JwtException e) {
return writeErrorResponse(exchange, 401, "TOKEN_INVALID", "令牌无效");
}
}
@Override
public int getOrder() {
return -100;
}
}
代码里有几个细节值得注意。第一,校验通过后用request.mutate()构造了一个新的请求对象,把解析出的用户ID塞进自定义请求头X-User-Id,下游服务拿到这个头就能直接知道当前用户是谁,不需要重复解析JWT。第二,Token为空、过期、无效这三种情况要区分开,客户端拿到明确的错误码才知道该跳转登录页还是提示刷新令牌。
白名单也是绕不开的需求。登录接口、验证码接口、健康检查等路径不应该做鉴权,可以在过滤器开头加一段路径匹配逻辑,用AntPathMatcher判断当前路径是否命中白名单,命中则直接放行。白名单建议放在配置文件里而不是硬编码,方便运维随时调整。
三、统一封装异常响应结构
网关层返回的错误信息如果不加约束,可能一会儿是纯文本,一会儿是Spring默认的JSON结构,前端处理起来非常痛苦。规范的做法是定义一套统一的响应体,网关和所有业务服务共用。假设约定的结构包含code、message、data三个字段,那么writeErrorResponse方法的实现如下:
private Mono<Void> writeErrorResponse(ServerWebExchange exchange,
int httpStatus,
String code,
String message) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.valueOf(httpStatus));
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new LinkedHashMap<>();
body.put("code", code);
body.put("message", message);
body.put("data", null);
DataBuffer buffer = null;
try {
byte[] bytes = objectMapper.writeValueAsBytes(body);
buffer = response.bufferFactory().wrap(bytes);
return response.writeWith(Mono.just(buffer));
} catch (Exception e) {
return response.setComplete();
}
}
这里必须强调一点:网关基于WebFlux,是响应式非阻塞模型,过滤器方法体内严禁写阻塞代码,比如直接的JDBC查询或者同步HTTP调用。如果Token校验必须查数据库或调用远程服务,要么换成响应式客户端WebClient,要么用Schedulers.boundedElastic()把阻塞操作调度到独立线程池,否则高并发下会拖垮整个网关的吞吐量。
还有一个容易踩坑的地方:过滤器里主动抛出的异常,比如直接throw new RuntimeException,默认会被网关的全局异常处理器接管,返回的是Spring自带格式的响应,前面定义的统一结构就白费了。所以在过滤器中处理异常时,优先用上面的writeErrorResponse主动写响应,而不是依赖异常向上抛。如果确实需要在多个过滤器之间传播错误,可以返回Mono.error,然后配合自定义的ErrorWebExceptionHandler兜底:
@Component
@Order(-2)
public class GatewayExceptionHandler implements ErrorWebExceptionHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
ServerHttpResponse response = exchange.getResponse();
if (response.isCommitted()) {
return Mono.error(ex);
}
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
Map<String, Object> body = new HashMap<>();
body.put("code", "GATEWAY_ERROR");
body.put("message", ex.getMessage());
body.put("data", null);
byte[] bytes;
try {
bytes = objectMapper.writeValueAsBytes(body);
} catch (Exception e) {
bytes = "{}".getBytes(StandardCharsets.UTF_8);
}
return response.writeWith(Mono.just(response.bufferFactory().wrap(bytes)));
}
}
四、测试验证与常见问题排查
过滤器写完后需要验证几种典型场景:不带Token访问受保护接口,应返回401和TOKEN_MISSING;携带过期Token,应返回TOKEN_EXPIRED;携带合法Token,请求应正常转发,且下游服务能读到X-User-Id请求头。用curl或者Postman逐个跑一遍即可覆盖。
实际部署中常见的问题有几个。一是过滤器没生效,多半是自定义过滤器类没有加@Component注解,Spring没有把它注册为Bean。二是响应是乱码,通常是没设置Content-Type为application/json导致的。三是下游服务读不到透传的请求头,检查是否在跨域预检OPTIONS请求上就被拦截了,OPTIONS请求本身不带Token,过滤器开头应该直接放行:
if (HttpMethod.OPTIONS.matches(request.getMethod().name())) {
return chain.filter(exchange);
}
另外建议把错误码设计得足够细,除了令牌相关的三类错误,还可以加上TOKEN_SIGNATURE_INVALID、ACCOUNT_DISABLED等细分码,前端拿到错误码后可以做差异化处理,比如静默刷新令牌重发请求,用户体验会好很多。网关层把鉴权和异常封装做扎实了,后端业务服务就可以专注在权限判断上,整体架构会清爽不少。