概述
先说明「为什么把鉴权上移到网关」的思路,本文只做一件事:把 AuthFilter 这一个类逐行写透。核心难点不在鉴权逻辑本身,而在网关是 WebFlux 响应式编程,写 401 响应的那几行代码,和 MVC 里习惯的 response.getWriter() 完全是两套世界观。
纲要
- 涉及文件清单与模块结构
- AuthFilter 完整代码逐段讲解
- 类声明:
@Component+GlobalFilter+Ordered,getOrder()返回什么、为什么 - 白名单:
@Value注入excludedUrls,contains精确匹配的局限与改进思路 - token 提取:
Authorization头、replaceAll("Bearer ", "")的由来 responseError:响应式写 401 的完整细节(ObjectMapper → DataBuffer → writeWith)- 响应式 vs MVC 写响应对照表
- 「校验上移、解析下沉」在本工程的最终形态
- 踩坑清单与验证方法(curl 可复现)
- 常见误解澄清
聚焦 mf-gateway 模块,文件不多:
| 文件 | 作用 | 本文角色 |
|---|---|---|
filters/AuthFilter.java | 全局鉴权过滤器 | 核心,逐行讲解 |
resources/bootstrap.yml | 引导配置,指向 Nacos 配置中心 | 白名单配置的实际来源 |
config/CorsConfig.java | 跨域配置类 | 前文已讲,不展开 |
GatewayApplication.java | 启动类 | 无特殊逻辑 |
pom.xml | 依赖:gateway + nacos-discovery + nacos-config + mf-commons | JwtUtils 来自 commons |
mf-gateway
├── pom.xml
└── src/main
├── java/com/mf/gateway
│ ├── GatewayApplication.java
│ ├── config
│ │ └── CorsConfig.java
│ └── filters
│ └── AuthFilter.java
└── resources
└── bootstrap.yml
一个容易疑惑的点:JwtUtils 并不在 gateway 模块里,它在 mf-commons/src/main/java/com/mf/commons/utils/JwtUtils.java,通过 pom 里的 mf-commons 依赖引入。这就是为什么网关不用自己写一遍 JWT 校验。
AuthFilter 完整代码
以下是工程里的真实代码(com/mf/gateway/filters/AuthFilter.java),注释按讲解需要补全:
package com.mf.gateway.filters;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mf.commons.utils.JwtUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@Component
public class AuthFilter implements GlobalFilter, Ordered {
@Value("${gateway.excludedUrls}")
private List<String> excludedUrls; //需要配置不校验的链接
//过滤器核心业务代码
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//1、排除不需要权限校验的链接
String path = exchange.getRequest().getURI().getPath(); //当前请求链接
if(excludedUrls.contains(path)) {
return chain.filter(exchange);
}
//2、获取token并校验 (xxxxxx , Bearer xxxxx)
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if(!StringUtils.isEmpty(token)) {
token = token.replaceAll("Bearer ","");
}
boolean verifyToken = JwtUtils.verifyToken(token);
//3、如果校验失败,响应错误状态:401
if(!verifyToken) {
Map<String, Object> responseData = new HashMap<>();
responseData.put("errCode", 401);
responseData.put("errMessage", "用户未登录");
return responseError(exchange.getResponse(),responseData);
}
return chain.filter(exchange);
}
//响应错误数据
private Mono<Void> responseError(ServerHttpResponse response, Map<String, Object> responseData){
// 将信息转换为 JSON
ObjectMapper objectMapper = new ObjectMapper();
byte[] data = new byte[0];
try {
data = objectMapper.writeValueAsBytes(responseData);
} catch (JsonProcessingException e) {
e.printStackTrace();
}
// 输出错误信息到页面
DataBuffer buffer = response.bufferFactory().wrap(data);
response.setStatusCode(HttpStatus.UNAUTHORIZED);
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
//配置执行顺序
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}
整段代码三步走:白名单放行 → 取 token 校验 → 失败写 401。下面逐段拆。
类声明:两个接口一个注解
@Component
public class AuthFilter implements GlobalFilter, Ordered {
三个角色分工明确:
GlobalFilter:全局过滤器接口。和 yml 里配置的GatewayFilter(只作用于某条路由)不同,GlobalFilter对所有路由生效,鉴权恰恰需要这个语义。接口只有一个方法filter(ServerWebExchange, GatewayFilterChain),返回Mono<Void>。Ordered:让过滤器参与排序。Gateway 会把所有GlobalFilter和当前路由的GatewayFilter合并成一条链,按getOrder()的返回值从小到大执行。@Component:交由 Spring 容器管理。Gateway 启动时会收集容器里所有GlobalFilter类型的 Bean 注册进过滤器链,没有这个注解,你的过滤器写了等于没写。
getOrder() 的实读值与含义
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
实读值是 Ordered.LOWEST_PRECEDENCE,即 Integer.MAX_VALUE(2147483647),排在整条过滤器链的最后。这里有个值得注意的连锁效应:路由上的 StripPrefix=1 过滤器(把 /app/user/login 削成 /user/login)order 比它小,先执行;等 AuthFilter 拿到请求时,path 已经是去掉 /app 前缀之后的路径了。这也是为什么下面的白名单配置写的是 /user/login 而不是 /app/user/login,实测打印结果印证了这一点:请求 /app/smallvideos 时控制台输出的是 url:/smallvideos。
另一个角度:鉴权场景按理说越早拦截越省资源,更常规的做法是 Ordered.HIGHEST_PRECEDENCE。但一旦改小 order,AuthFilter 会跑到 StripPrefix 之前,此时白名单就得写带 /app 前缀的原始路径。顺序值和白名单写法是联动的,改哪个都要一起想。
白名单:@Value 注入与精确匹配
@Value("${gateway.excludedUrls}")
private List<String> excludedUrls;
配置值形如逗号分隔的字符串,Spring 会自动按逗号拆成 List<String> 注入:
gateway:
excludedUrls: /user/login,/user/loginVerification,/system/users/verification,/system/users/login
四条白名单对应两类场景:APP 端的登录接口(/user/login、/user/loginVerification,还没登录哪来的 token),以及管理后台的验证码与登录接口(/system/users/verification、/system/users/login)。
放行判断很直白:
if(excludedUrls.contains(path)) {
return chain.filter(exchange);
}
chain.filter(exchange) 表示「我不拦,交给链上下一个过滤器」,返回它的 Mono 就是放行。
contains 是精确匹配,局限很明显:将来出现 /user/login/xxx 这类带动态参数的路径就匹配不上了。改进思路有两种:
// 方案一:前缀匹配
boolean hit = excludedUrls.stream().anyMatch(path::startsWith);
// 方案二:Ant 风格通配符
AntPathMatcher matcher = new AntPathMatcher();
boolean hit = excludedUrls.stream().anyMatch(p -> matcher.match(p, path));
白名单越宽松,鉴权形同虚设的风险越大,所以匹配规则建议收紧,能精确就精确,确有动态路径需求时再用 AntPathMatcher,并且配置里显式写清通配符。
token 提取与校验
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if(!StringUtils.isEmpty(token)) {
token = token.replaceAll("Bearer ","");
}
getFirst("Authorization"):请求头可能重名,getFirst只取第一个值。头不存在时返回null,不会抛异常。replaceAll("Bearer ",""):处理两种客户端习惯。APP 端直接传裸 token(xxxxx),后台管理的前端页面用 Vue + axios,习惯按 OAuth2 规范传Bearer xxxxx。统一把Bearer前缀删掉,后面的JwtUtils.verifyToken才能拿到干净的 token。讲义里用的是replace,工程里是replaceAll,对这段固定字符串两者效果一致,replaceAll只是多编译了一次正则,算无害差异。verifyToken是项目初期就写好的工具类(mf-commons),内部逻辑:token 为空返回false;解析时抛任何异常(签名不对、过期、格式损坏)都捕获后返回false;解析成功返回true。它只回答「token 有效吗」,不回答「是谁」。校验失败时的错误数据:
Map<String, Object> responseData = new HashMap<>();
responseData.put("errCode", 401);
responseData.put("errMessage", "用户未登录");
return responseError(exchange.getResponse(),responseData);
注意 errMessage 的中文,这直接引出下面 Content-Type 编码的坑。
responseError:响应式写 401 的完整细节
这是全篇最值得抠的一段。五个动作,顺序不能乱:
ObjectMapper objectMapper = new ObjectMapper();
byte[] data = new byte[0];
try {
data = objectMapper.writeValueAsBytes(responseData); // ① Map 转 JSON 字节
} catch (JsonProcessingException e) {
e.printStackTrace();
}
DataBuffer buffer = response.bufferFactory().wrap(data); // ② 字节包装成 DataBuffer
response.setStatusCode(HttpStatus.UNAUTHORIZED); // ③ 状态码 401
response.getHeaders().add("Content-Type",
"application/json;charset=UTF-8"); // ④ 响应头,UTF-8 必须显式给
return response.writeWith(Mono.just(buffer)); // ⑤ 写出并结束
逐行说:
- ObjectMapper 转 JSON 字节:
writeValueAsBytes直接产出byte[],比转 String 再getBytes()少一次编码转换。注意这里new byte[0]的兜底写法,序列化失败时(实际上一个 HashMap 几乎不可能失败)响应体会是空字节,错误被printStackTrace吞掉了。这是教学代码的妥协,生产上至少要记日志。 - bufferFactory().wrap(data):响应式世界里没有
OutputStream,数据以DataBuffer(对 NIOByteBuffer的封装)为单位流动。wrap是零拷贝包装,不复制数组。 - setStatusCode(UNAUTHORIZED):
HttpStatus.UNAUTHORIZED就是 401。 - Content-Type 手动 add:默认的序列化行为不会帮你带 charset,不写
charset=UTF-8,客户端拿到的中文字段就是乱码。 - writeWith(Mono.just(buffer)):把包含 buffer 的
Mono交给底层响应式框架去写,Mono完成信号发出后,框架自动提交响应并结束连接,不需要也不应该再手动调「flush/close」。
为什么不能像 MVC 那样直接 response.getWriter().write(json)?根本原因是线程模型变了:
- MVC(Servlet):一个请求绑一个线程(Thread-Per-Request),线程会阻塞等待,你可以同步地「拿流 → 写数据 → 返回」,写完响应自动提交。
- WebFlux(Gateway):基于 Netty 事件循环,少量线程服务大量连接,禁止阻塞。
ServerHttpResponse根本不提供getWriter(),你要做的是「声明一段数据 + 声明怎么写」,返回一个Mono<Void>描述这件事,由框架在事件循环里异步完成。filter方法签名里的Mono<Void>返回值就是契约:你返回什么,Gateway 就执行什么。
对照表:
| 维度 | MVC(Servlet) | Gateway(WebFlux) |
|---|---|---|
| 写响应的入口 | response.getWriter().write(str) | response.writeWith(Mono.just(DataBuffer)) |
| 数据载体 | String / byte[] 直接写流 | DataBuffer(bufferFactory().wrap(bytes) 包装) |
| 设置状态码 | response.setStatus(401) | response.setStatusCode(HttpStatus.UNAUTHORIZED) |
| 设置响应头 | response.setContentType(...) | response.getHeaders().add(...) |
| 编程模型 | 同步阻塞,方法返回即写完 | 异步非阻塞,返回 Mono<Void> 声明意图 |
| 提交响应 | 容器在方法返回后自动提交 | Mono 完成信号触发框架提交 |
一个真实的坑:如果你在 responseError 里忘了 return response.writeWith(...) 而是写成 response.writeWith(Mono.just(buffer)); return chain.filter(exchange);,既写了 401 声明又放行了请求,客户端会收到一个状态码 401 但响应体为空、且可能继续转发到下游服务的怪异响应。writeWith 返回的 Mono 必须原样 return,这是把「写响应」这件事交给框架的唯一方式。
「校验上移、解析下沉」的最终形态
网关有了 AuthFilter,后端服务里的鉴权逻辑就不重复做了。本工程的最终形态(实读代码确认):
- 网关(
mf-gateway/AuthFilter):管「token 有效吗」。失效直接 401,请求根本到不了后端。 mf-app-server的TokenInterceptor:verifyToken调用被注释掉,只保留JwtUtils.getClaims(token)解析出 id、mobile,构造User存入UserHolder(ThreadLocal)。mf-admin的TokenInterceptor:同样没有校验逻辑,直接getClaims解析出 id、username 存入AdminHolder。
必须如实说一点:网关拦截后,如果有人绕过网关直连后端服务(比如直接访问 mf-app-server 的端口),后端没有任何校验,拿到伪造 token 解析失败会直接抛异常而不是返回 401。教学工程为了演示分工把它简化了,生产环境建议把后端的 verifyToken 补回来做纵深防御,网关校验是第一道门,不是唯一一道门。
踩坑清单
| 坑 | 现象 | 原因 / 解法 |
|---|---|---|
| Content-Type 忘设 UTF-8 | 客户端收到 "errMessage":"ç¨æ·æªç»å½" 之类的乱码 | 响应式默认不带 charset,必须显式 add("Content-Type","application/json;charset=UTF-8") |
| writeWith 漏 return / 漏 Mono | 401 但响应体为空,甚至请求继续转发 | writeWith 返回的 Mono<Void> 必须作为 filter 的返回值交给框架 |
| 白名单精确匹配 | /user/login/1 这类动态路径被 401 | contains 只做全等匹配;动态路径改前缀匹配或 AntPathMatcher |
| JwtUtils 的 TTL 与注释不符 | 注释写「有效期1小时」,实测 token 7 天不过期 | 实读 TOKEN_TIME_OUT = 7 * 24 * 3600,注释没改;排查「token 为什么一直有效」先看这里 |
| Ordered 不设 / 乱设 | 过滤器执行顺序不确定,或白名单路径前缀对不上 | 实读值 LOWEST_PRECEDENCE(最后执行,StripPrefix 已生效);改成更小值时白名单要带 /app 前缀 |
还有一处配置层面的演进值得说明:早期把 gateway.excludedUrls 写在网关的 application.yml 里,但工程快照里网关只剩 bootstrap.yml(内容只有 Nacos 地址),excludedUrls 连同路由配置整体迁到了 Nacos 配置中心的 mf-gateway-dev.yml(bootstrap.yml 里 spring.profiles.active: dev,dataId 由 应用名-profile.扩展名 拼出)。这正是后续配置中心要讲的内容,配置迁移后 @Value 注入的用法完全不变。
验证
重启网关与 app-server 后,用 curl 三步验证(网关端口 8888,路由前缀 /app):
# 1. 不带 token 访问受保护接口 → 401 + JSON 体
curl -i http://localhost:8888/app/smallvideos
预期响应:
HTTP/1.1 401 Unauthorized
Content-Type: application/json;charset=UTF-8
Content-Length: 42
{"errCode":401,"errMessage":"用户未登录"}
同时网关控制台会打印白名单四项与当前路径 url:/smallvideos,这行 System.out.println 就是教学代码里留的观测点。
# 2. 白名单接口不带 token → 放行,正常进入业务(这里用登录接口,无 token 应返回业务层的参数错误而非 401)
curl -i -X POST http://localhost:8888/app/user/login
预期响应是 200 走进 UserController(业务层可能报参数缺失,但不会是网关的 401 JSON),说明白名单生效。
# 3. 带有效 token 访问受保护接口 → 200
curl -i -H "Authorization: Bearer eyJhbGciOiJIUzUxMiJ9.xxx.yyy" http://localhost:8888/app/smallvideos
预期响应 200,业务数据正常返回。token 可先调 /app/user/loginVerification + /app/user/login 从模拟器登录流程获取;按上面说的 TTL 实情,这个 token 拿到手 7 天内都有效。
常见误解澄清
| 误解 | 实情 |
|---|---|
| 「GlobalFilter 是在 yml 里配置的过滤器」 | yml 里配的是 GatewayFilter(作用于单条路由);GlobalFilter 是实现了接口、注册为 Bean 后自动对所有路由生效的过滤器 |
| 「token 校验和解析是一回事」 | 校验(verifyToken)只判断有效性,在网关;解析(getClaims)取出用户信息,在后端拦截器。本工程里它们分居两处 |
| 「Bearer 前缀是网关加的」 | 是后台前端(Vue/axios)按 OAuth2 习惯带上的,网关职责是把它剥掉以兼容两种客户端 |
| 「白名单要写完整路径 /app/user/login」 | 实读 order 为 LOWEST_PRECEDENCE,StripPrefix 先执行,AuthFilter 看到的是 /user/login |
| 「writeWith 写完要手动关响应」 | 不需要。Mono 完成信号触发框架提交并结束响应,手动干预反而出问题 |
| 「网关校验过了,后端就绝对安全」 | 绕过网关直连后端端口即可绕过校验;教学工程后端已无 verifyToken(实读确认),生产建议补回 |
官方文档
- Spring Cloud Gateway - Global Filters
- Spring Cloud Gateway - GatewayFilter Factories(含 StripPrefix)
- Spring Framework - ServerHttpResponse 与 DataBuffer
总结
AuthFilter的骨架是三件事:实现GlobalFilter + Ordered并注册为 Bean,三步业务逻辑(白名单放行、取头校验、失败写 401),getOrder()返回LOWEST_PRECEDENCE使得StripPrefix先于它执行,白名单因此不带/app前缀。- 最容易翻车的不是鉴权逻辑而是
responseError:响应式写响应 = 组装DataBuffer+ 设置状态码与头 +return writeWith(Mono.just(buffer)),任何一环漏掉都会表现为 401 空响应体或中文乱码。 - 最终分工:网关回答「token 有效吗」,后端拦截器回答「是谁」。本工程后端只解析不校验是教学简化,纵深防御要求后端补回校验。
- 白名单与校验规则本身还有演进空间(动态路径匹配、
excludedUrls已随配置中心外置到 Nacos),下一篇配置中心接着讲配置外置与热更新。
253



被折叠的 条评论
为什么被折叠?



