亮点六 · 22 个服务,怎么只在一处鉴权
业务问题: 系统有 4 个端(快递员、客户、司机、管理后台), 每个端背后是 4~27 个下游微服务。 如果每个服务都自己做鉴权,改一次权限规则要改 22 个服务, 而且任何一个服务漏了就是漏洞。
所以必须在网关统一鉴权——一次校验,全链路可信。
一、业务先讲清楚:四端的用户根本不是同一类人
这是理解整个鉴权设计的前提。很多面试者答不出这一层, 只答"用了 JWT + 网关",那和背八股没区别。
| 端 | 用户是谁 | 身份从哪来 | 需要角色校验吗 |
|---|---|---|---|
客户端 web-customer | C 端消费者(海量、匿名性强) | 自签 RSA JWT,claims 只有 userId | ❌ 不需要——消费者只有"自己的订单"这个权限,天然隔离 |
快递员端 web-courier | 快递员 | 权限中心 | ✅ 需要 |
司机端 web-driver | 司机 | 权限中心(itcast-auth-sdk) | ✅ 需要 ${role.driver} |
管理端 web-manager | 运营/管理员 | 权限中心 | ✅ 需要 ${role.manager} |
C 端为什么要自签 JWT 而不用权限中心?
因为 C 端用户有几千万量级,权限中心是为内部员工设计的(几百到几万人)。 每个 C 端用户都去权限中心建一份档案、走一次 SSO,成本和延迟都不可接受。
所以 C 端走的是轻量方案:服务端自签自验的 RSA JWT,claims 里只有
userId, 不需要角色权限,因为权限模型就是"能查自己的订单"—— 这靠数据归属过滤(每个查询都带userId条件)保证,而不是靠 RBAC。这是一个"身份认证"和"授权模型"的分离决策: 三端用统一身份(权限中心)+ RBAC,C 端用简化身份 + 数据级权限。
这个回答的杀伤力在于: 它证明你理解"鉴权方案要匹配用户规模和组织形态", 而不是"公司有什么就用什么"。
二、方案设计
2.1 网关过滤器:抽象 + 策略
核心抽象 AuthFilter:
// chain-cloud-gateway filter/AuthFilter.java
public interface AuthFilter {
/**
* 解析token(认证)
*/
AuthUserInfoDTO check(String token);
/**
* 权限校验(授权)
*/
Boolean auth(String token, AuthUserInfoDTO authUserInfo, String path);
/**
* token 请求头名称,默认 Authorization
*/
default String tokenHeaderName() {
return Constants.GATEWAY.AUTHORIZATION;
}
}通用过滤器 TokenGatewayFilter:
// chain-cloud-gateway filter/TokenGatewayFilter.java
@Slf4j
public class TokenGatewayFilter implements GatewayFilter, Ordered {
private AuthFilter authFilter;
private MyConfig myConfig;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 白名单直接放行
String path = exchange.getRequest().getPath().toString();
if (StrUtil.startWithAny(path, this.myConfig.getNoAuthPaths())) {
return chain.filter(exchange);
}
// 2. 取 token,校验
String token = exchange.getRequest().getHeaders().getFirst(this.authFilter.tokenHeaderName());
if (StrUtil.isEmpty(token)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
AuthUserInfoDTO authUserInfoDTO = null;
try {
authUserInfoDTO = this.authFilter.check(token);
} catch (Exception e) {
// token不可用,不做处理
}
if (ObjectUtil.isEmpty(authUserInfoDTO)) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
// 3. 校验权限
Boolean auth = false;
try {
auth = this.authFilter.auth(token, authUserInfoDTO, path);
} catch (Exception e) {
log.error("鉴权失败, token = {}", token, e);
}
if (!auth) {
exchange.getResponse().setStatusCode(HttpStatus.BAD_REQUEST);
return exchange.getResponse().setComplete();
}
// 4. 向下游传递用户信息和 token
exchange.getRequest().mutate().header(Constants.GATEWAY.USERINFO,
JSONUtil.toJsonStr(authUserInfoDTO));
exchange.getRequest().mutate().header(Constants.GATEWAY.TOKEN, token);
return chain.filter(exchange);
}
@Override
public int getOrder() {
return Integer.MIN_VALUE; // 最先执行
}
}源码定位
chain-cloud-gateway/src/main/java/com/itheima/gateway/filter/TokenGatewayFilter.java:17-84
这套设计的漂亮之处:TokenGatewayFilter 里没有一行"端"相关的代码。 check 和 auth 完全委托给 AuthFilter 实现, 新增一个端只需要加一个 XxxTokenGatewayFilterFactory,不用改通用逻辑。
而且它是 Spring Cloud Gateway 的标准玩法: AbstractGatewayFilterFactory<Object> 是网关过滤器工厂的基类, 在 YAML 里用 - CustomerToken 就能启用。
2.2 四端的四个实现
A. C 端 —— RSA 自签自验,不需要 RBAC
// CustomerTokenGatewayFilterFactory.java
/**
* 用户端token拦截处理
* 用户端使用自定义 RSA-256 JWT(仅包含 userId),
* 与 itcast-auth SDK 的 token 机制不同。
* 从 access_token 请求头获取 token,
* 使用 JwtUtils + RSA 公钥校验,不需要做角色权限校验。
*/
@Slf4j @Component
public class CustomerTokenGatewayFilterFactory
extends AbstractGatewayFilterFactory<Object> implements AuthFilter {
@Resource private JwtProperties jwtProperties;
@Override
public GatewayFilter apply(Object config) {
return new TokenGatewayFilter(myConfig, this);
}
@Override
public AuthUserInfoDTO check(String token) {
var claims = JwtUtils.checkToken(token, jwtProperties.getPublicKey());
if (claims == null) return null;
var userIdObj = claims.get(Constants.GATEWAY.USER_ID);
if (userIdObj == null) return null;
var userInfo = new AuthUserInfoDTO();
userInfo.setUserId(Long.valueOf(userIdObj.toString()));
return userInfo;
}
@Override
public Boolean auth(String token, AuthUserInfoDTO authUserInfoDTO, String path) {
return true; // 不做角色校验
}
@Override
public String tokenHeaderName() {
return Constants.GATEWAY.ACCESS_TOKEN; // "access_token",不是 Authorization
}
}B. 司机端 —— 权限中心 + 角色交集
// DriverTokenGatewayFilterFactory.java
@Resource TokenCheckService tokenCheckService; // itcast-auth-sdk
@Value("${role.driver}")
private List<Long> driverRoleIds;
@Override
public AuthUserInfoDTO check(String token) {
return tokenCheckService.parserToken(token); // SDK 解析 + 验签
}
@Override
public Boolean auth(String token, AuthUserInfoDTO authUserInfo, String path) {
Long userId = authUserInfo.getUserId();
AuthTemplate authTemplate = AuthTemplateFactory.get(token);
Result<List<Long>> roleByUserId = authTemplate.opsForRole().findRoleByUserId(userId);
if (roleByUserId.getCode() == 0) {
List<Long> data = roleByUserId.getData();
if (CollUtil.isNotEmpty(data)) {
// 用户角色 ∩ 端要求的角色集合,非空即通过
Collection<Long> intersection = CollUtil.intersection(data, driverRoleIds);
if (CollUtil.isNotEmpty(intersection)) {
return true;
}
}
}
return false;
}C. 管理端 —— 和司机端完全同构,只是配置项换成 ${role.manager}。
D. 快递员端 —— 空实现
// CourierTokenGatewayFilterFactory.java(全文 30 行)
public class CourierTokenGatewayFilterFactory
extends AbstractGatewayFilterFactory<Object> implements AuthFilter {
@Override public GatewayFilter apply(Object config) { return null; }
@Override public AuthUserInfoDTO check(String token) { return null; }
@Override public Boolean auth(String token, AuthUserInfoDTO dto, String path) { return null; }
}对应 YAML 里的过滤器也是注释掉的:
- id: chain-cloud-ms-web-courier
uri: lb://chain-cloud-ms-web-courier
predicates:
- Path=/courier/**
filters:
- StripPrefix=1
# - CourierToken ← 注释掉了
- AddRequestHeader=X-Request-From, chain-cloud-gateway⚠️
${role.courier}在配置里定义了但没有任何消费者—— 说明快递员端的角色体系设计了但没接线。
2.3 JWT 工具:RS256 非对称
// chain-cloud-common util/JwtUtils.java
public static String createToken(Map<String, Object> claims,
String privateKeyStr, int dateOffset, DateField dateField) {
RSA rsa = new RSA(privateKeyStr, null);
Map<String, Object> header = MapUtil.<String, Object>builder()
.put(JwsHeader.TYPE, JwsHeader.JWT_TYPE)
.put(JwsHeader.ALGORITHM, SignatureAlgorithm.RS256.getValue()).build();
return Jwts.builder()
.setHeader(header)
.setClaims(claims)
.signWith(SignatureAlgorithm.RS256, rsa.getPrivateKey())
.setExpiration(DateUtil.offset(new Date(), dateField, dateOffset))
.compact();
}
public static Map<String, Object> checkToken(String token, String publicKeyStr) {
RSA rsa = new RSA(null, publicKeyStr);
try {
return Jwts.parser()
.setSigningKey(rsa.getPublicKey())
.parseClaimsJws(token)
.getBody();
} catch (ExpiredJwtException e) {
// token已经过期! ← 消息被注释掉了
} catch (Exception e) {
log.error("token非法传入,token = {}", token, e);
}
return null; // 过期和非法都返回 null,无法区分
}为什么用 RSA 而不是 HMAC?
| HMAC(HS256) | RSA(RS256) | |
|---|---|---|
| 密钥 | 同一个 密钥签名和验签 | 私钥签名,公钥验签 |
| 分发 | 下发密钥 = 下发签发权 | 只下发公钥,无法签发 |
| 适合 | 单一服务内部 | 多服务:只有 auth 服务持有私钥 |
场景: C 端有 web-customer(BFF)、还有网关。 如果用 HMAC,网关也要持有密钥,那任何一个服务被攻破都能伪造任意用户的 Token。 用 RSA 之后,网关只能验签、不能签发——权限被正确地限制了。
而且 C 端的密钥存在哪? JwtProperties 绑定 cc.jwt.publicKey / privateKey, 网关只配了公钥(auth/pub.key),私钥只在签发方(web-customer)本地。 这个"公钥下发、私钥不下发"的部署方式是必须讲清楚的。
2.4 双 Token:access + refresh
双 Token 的完整逻辑(chain-cloud-ms-web-customer):
// TokenServiceImpl.java
public static final String REDIS_REFRESH_TOKEN_PREFIX = "CC_CUSTOMER_REFRESH_TOKEN_";
public String createAccessToken(Map<String, Object> claims) {
// 短令牌有效期单位:分钟
return JwtUtils.createToken(claims, jwtProperties.getPrivateKey(),
jwtProperties.getAccessTtl(), DateField.MINUTE);
}
@Override
public String createRefreshToken(Map<String, Object> claims) {
// 长令牌有效期单位:小时
Integer ttl = jwtProperties.getRefreshTtl();
String refreshToken = JwtUtils.createToken(claims, jwtProperties.getPrivateKey(), ttl);
// 长令牌只能使用一次,需要将其存储到 redis 中,变成有状态的
String userId = claims.get(Constants.GATEWAY.USER_ID).toString();
stringRedisTemplate.opsForValue().set(REDIS_REFRESH_TOKEN_PREFIX + userId, refreshToken);
return refreshToken;
}三个关键设计(这部分是真正值得讲的):
- 短 token 无状态 —— access token 只有
userId,每次请求在网关验签即可,
不需要查任何存储。这是它快的根本原因。
- 长 token 有状态 —— refresh token 存 Redis,用一次就删。
这就是所谓的 refresh token rotation(轮换机制)。
- 有效期不同量纲 —— access 用
MINUTE,refresh 用HOUR,
所以 createToken 才有 DateField 这个参数。
为什么 refresh token 必须有状态?
| 无状态 refresh | 有状态 refresh(Redis 轮换) | |
|---|---|---|
| 校验 | 只验签 | 验签 + 查 Redis 比对 |
| 泄漏后果 | 攻击者可在有效期内随意刷新 | 只能用一次,泄漏后很快失效 |
| 存储压力 | 无 | Redis(TTL = refresh 有效期) |
这一层是"无状态"和"安全"的经典权衡。 C 端选了两者混合:高频路径无状态(access),低频路径有状态(refresh)。 这是目前业界最主流的做法。
2.5 身份透传:三跳
这是整个方案里最容易出错、也最值得讲清的部分。
第 1 跳:Gateway → 下游(HTTP header)
header: userInfo = AuthUserInfoDTO 的 JSON
header: token = 原始 token
header: X-Request-From = chain-cloud-gateway
↓
第 2 跳:下游 Web 模块的拦截器 → ThreadLocal
UserInterceptor : X-Request-From 校验 + userInfo 解析 → UserThreadLocal
TokenInterceptor : token 解析 → AuthTemplateThreadLocal
↓
第 3 跳:Feign 调用 → 再下一个服务
UserFeignRequestInterceptor: userInfo 重新注入到 Feign header第 2 跳的代码:
// chain-cloud-common interceptor/UserInterceptor.java
/**
* 统一对请求的合法性进行校验,需要进行2个方面的校验,
* 一、请求头中是否携带了X-Request-From,判断是否来源网关
* 二、请求头中是否存在userInfo,如果不存在则说明是非法请求,响应403状态码
* 如果是合法请求,将userInfo存储到ThreadLocal中
*/
@Component
public class UserInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) throws Exception {
if (!(handler instanceof HandlerMethod)) return true;
HandlerMethod handlerMethod = (HandlerMethod) handler;
if (handlerMethod.hasMethodAnnotation(NoAuthorization.class)) {
return true; // 标注了免鉴权
}
// 一、校验来源:必须来自网关
String from = request.getHeader("X-Request-From");
if (!StrUtil.equals(from, "chain-cloud-gateway")) {
response.setStatus(403);
return false; // ← 防止绕过网关直连微服务
}
// 二、校验 userInfo 存在
String userInfo = request.getHeader("userInfo");
if (Base64.isBase64(userInfo)) {
userInfo = Base64.decodeStr(userInfo); // 兼容 Base64 编码
}
if (StrUtil.isEmpty(userInfo)) {
response.setStatus(403);
return false;
}
UserThreadLocal.set(JSONUtil.toBean(userInfo, AuthUserInfo.class));
return true;
}
@Override
public void afterCompletion(...) {
UserThreadLocal.remove(); // 必须清理,否则线程复用会串号
}
}这个 X-Request-From 校验是整个安全设计的基石:
没有它,任何人都能绕过网关直连内网微服务(Nacos 服务发现让服务名可解析, 只要能访问注册中心就能拿到全部服务地址)。 加上这个头校验之后,只有网关转发的请求才会被接受。
这是"默认不信任 + 单一入口"原则的落地。
UserThreadLocal 的取值:
// chain-cloud-common util/UserThreadLocal.java
public static Long getUserId() {
AuthUserInfo userInfo = LOCAL.get();
if (userInfo == null || userInfo.getUserId() == null) {
throw new CCException("token异常"); // 不返回 null,强制调用方判空
}
return userInfo.getUserId();
}这个细节值得单独讲: ThreadLocal 的 get() 很容易在别处被写成 UserThreadLocal.getUserId() 然后直接 .equals(), 空指针。在这里直接抛业务异常,把"忘记判空"这个问题从 N 个调用点收敛到 1 个。 这是"把防御放在最底层"的好例子。
AuthUserInfo 用 @Alias 适配两种 JWT 结构:
// chain-cloud-common vo/AuthUserInfo.java
@Data
public class AuthUserInfo extends BaseMsg {
@Alias("sub") private Long userId; // 权限中心 JWT 用 sub
private String account;
private String name;
@Alias("orgid") private Long orgId;
@Alias("stationid") private Long stationId;
private Boolean administrator;
}同一个 UserThreadLocal 里存的 AuthUserInfo, 既可能是权限中心解析出来的(用 sub/orgid 别名), 也可能是 C 端自己塞的(直接 userId)。 @Alias 让上层业务代码完全不用关心 token 从哪来。
2.6 路由配置
# chain-cloud-gateway/src/main/resources/bootstrap-local.yml
spring:
gateway:
discovery:
locator:
enabled: true # 自动为 Nacos 上注册的每个服务创建路由
routes:
- id: chain-cloud-ms-web-courier
uri: lb://chain-cloud-ms-web-courier
predicates:
- Path=/courier/**
filters:
- StripPrefix=1 # 去掉 /courier 前缀
- AddRequestHeader=X-Request-From, chain-cloud-gateway
- id: chain-cloud-ms-web-customer
uri: lb://chain-cloud-ms-web-customer
predicates:
- Path=/customer/**
filters:
- StripPrefix=1
- CustomerToken # ← 每条路由配自己的过滤器
- AddRequestHeader=X-Request-From, chain-cloud-gateway
- id: chain-cloud-ms-web-driver
... - DriverToken ...
- id: chain-cloud-ms-web-manager
... - ManagerToken ...
- id: chain-cloud-ms-trade
uri: lb://chain-cloud-ms-trade
predicates:
- Path=/trade/notify/** # 支付回调专用通道
filters:
- StripPrefix=1
- AddRequestHeader=X-Request-From, chain-cloud-gateway
default-filters:
- DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST两个值得注意的设计:
- 支付回调单独一条路由
/trade/notify/**,不过鉴权。
因为微信/支付宝的回调是服务器到服务器的调用, 它们不可能携带我们系统的 Token。这个通道必须豁免, 靠签名验签(微信的 NotificationHandler + 支付宝的 verifyNotify)来保证安全。
DedupeResponseHeader ... RETAIN_FIRST—— CORS 响应头去重。
因为网关加了 CORS 过滤器,下游服务可能也加了,会导致响应头重复。
2.7 白名单
cc:
noAuthPaths:
- /courier/login/account
- /customer/user/login
- /customer/user/refresh
- /driver/login/account
- /manager/login
- /manager/captcha
- /courier/swagger-ui.html # swagger 系列
- /customer/swagger-ui.html
...注意匹配方式是"前缀匹配"不是"精确匹配":
if (StrUtil.startWithAny(path, this.myConfig.getNoAuthPaths())) {
return chain.filter(exchange);
}StrUtil.startWithAny 是前缀匹配,所以 /manager/loginXyz 也会被白名单放行。这是一个安全边界过宽的问题。
修复: 精确匹配登录接口,其余用前缀:
private static final Set<String> EXACT = Set.of(
"/customer/user/login", "/customer/user/refresh",
"/manager/login", "/manager/captcha", "/driver/login/account",
"/courier/login/account");
if (EXACT.contains(path) || SWAGGER_PREFIXES.stream().anyMatch(path::startsWith)) {
return chain.filter(exchange);
}三、难点拆解
难点 1:WebFlux 响应式编程的过滤器写法
难点在哪: 网关是 WebFlux(响应式),不是 Servlet。 HandlerInterceptor(第 2 跳用的)在网关里完全不可用, 必须用 GatewayFilter,返回 Mono<Void>,且不能有任何阻塞操作。
典型错误(很多人第一版都会写):
// ❌ 在 GatewayFilter 里做阻塞 DB 查询
@Override
public Mono<Void> filter(ServerHttpRequest req, ServerHttpResponse resp, GatewayFilterChain chain) {
UserDTO user = userService.getById(1); // 阻塞!
return chain.filter(exchange);
}WebFlux 的黄金法则:一个线程处理成千上万个请求, 任何阻塞操作都会拖垮整个网关。
我们这个过滤器的处理方式是正确的: 所有校验都是纯内存操作(JWT 验签 + 角色集合求交集), 没有 IO。auth() 里调 authTemplate.opsForRole().findRoleByUserId(userId) 虽然看起来是远程调用,但 SDK 内部有缓存,且这确实是设计上的一处妥协——
改进方案(面试时主动说):
// 1. 角色数据加本地缓存,避免每次请求打权限中心
@Cacheable(value = "userRoles", key = "#userId", unless = "#result == null")
public List<Long> findRoleByUserId(Long userId) { ... }// 2. 或者改用 @CircuitBreaker + 熔断降级:权限中心挂了,按"放行"或"拒绝"降级
@CircuitBreaker(name = "auth", fallbackMethod = "authFallback")
public Boolean auth(...) { ... }难点 2:身份透传的完整链路不能断
难点在哪: 这是分布式系统里一个非常经典的信任链传递问题。
用户身份在网关被验证过 → 但下游服务必须"相信"这个身份 → 而信任的载体是 HTTP header只要中间任何一跳没传递,下游就会:
- 要么
UserThreadLocal.getUserId()抛CCException("token异常")(安全,但功能挂); - 要么拿到
null然后 NPE 或者误认为"没有用户"(危险!)。
怎么解(三层):
| 层 | 手段 | 防的是什么 |
|---|---|---|
| 来源校验 | X-Request-From 必须等于 chain-cloud-gateway | 绕过网关直连微服务 |
| 存在性校验 | userInfo 头不能为空 | 网关没传下来(自身 Bug) |
| 可用性保障 | ThreadLocal + afterCompletion 清理 | 线程复用导致串号 |
第 3 层是最隐蔽的: Tomcat 的线程池是复用的。如果一个请求设置了 UserThreadLocal 但没清理, 下一个请求读到的就是上一个用户的身份——这就是严重的越权漏洞。
而且这个问题只有在高并发 + 特定时序下才会出现,测试环境根本测不出来。
afterCompletion里UserThreadLocal.remove()这一行, 是整个身份透传链路里最重要的一行代码。 面试时一定要主动提"为什么必须清理 ThreadLocal"。
难点 3:C 端的数据级权限(不是 RBAC)
难点在哪: C 端没有角色,权限模型是"只能看自己的东西"。 这个约束必须在每一个查询里手工加 user_id = ? 条件。
漏一个,就是水平越权(IDOR)—— 用户改一下 URL 里的订单 ID 就能看别人的订单。
怎么防:
// ❌ 危险:只按 ID 查
public OrderDTO findById(Long id) {
return orderFeign.findById(id);
}
// ✅ 安全:ID + 当前登录用户 双重条件
public OrderDTO findById(Long id) {
Long userId = UserThreadLocal.getUserId();
LambdaQueryWrapper<OrderEntity> w = Wrappers.lambdaQuery();
w.eq(OrderEntity::getId, id)
.eq(OrderEntity::getMemberId, userId); // ← 关键
return baseMapper.selectOne(w);
}更好的做法是在框架层兜底,比如 MyBatis-Plus 拦截器自动注入:
// 把 UserThreadLocal.getUserId() 自动塞进所有 where 条件
@Intercepts(@Signature(type = Executor.class, method = "update",
args = { MappedStatement.class, Object.class }))
public class TenantLineInnerInterceptor implements InnerInterceptor { ... }这也是项目里没有做的地方(当前 MyBatis-Plus 只配了分页插件)。 面试时可以说"我知道有框架级的方案,但当时没做,因为改动面太大"。
四、实际遇到的困难与踩坑
坑 1(致命):mutate().header() 的返回值被丢弃,身份根本没传下去
事实:
// TokenGatewayFilter.java:73-77
// 4. 校验通过,向下游传递用户信息和token
exchange.getRequest().mutate().header(Constants.GATEWAY.USERINFO, JSONUtil.toJsonStr(authUserInfoDTO));
exchange.getRequest().mutate().header(Constants.GATEWAY.TOKEN, token);
// 4.1 校验通过放行
return chain.filter(exchange);Spring 的 ServerHttpRequest.Builder#header(...) 返回的是一个新 Builder 对象, 不是修改后的原对象。 代码里既没有接收返回值,也没有 .build()。
正确写法:
// 4. 校验通过,向下游传递用户信息和 token
ServerHttpRequest mutatedRequest = exchange.getRequest().mutate()
.header(Constants.GATEWAY.USERINFO, JSONUtil.toJsonStr(authUserInfoDTO))
.header(Constants.GATEWAY.TOKEN, token)
.build();
exchange = exchange.mutate().request(mutatedRequest).build();
return chain.filter(exchange);后果:整个身份透传链路静默失效。
网关放行 → 下游 UserInterceptor 读 X-Request-From
→ AddRequestHeader 滤镜加上了,通过 ✅
→ 读 userInfo 头 → 没有(因为上面那两行是空操作)→ 返回 403 ❌为什么它没有立刻暴露? 因为 AddRequestHeader=X-Request-From 是在 YAML 里配的(真的生效了), 而 userInfo/token 是代码里加的(没生效)。 如果只验 X-Request-From 而不验 userInfo,这个 Bug 就完全不可见。
再加上 Feign 侧也有同样的问题:
// chain-cloud-common interceptor/UserFeignRequestInterceptor.java
/**
* 如果需要通过Feign将用户信息向下游微服务传递,
* 需要在配置文件中设置:cc.feign.user = true
*/
@Component
@ConditionalOnProperty(prefix = "cc.feign", value = "user")
public class UserFeignRequestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
String userInfo = Base64.encode(JSONUtil.toJsonStr(UserThreadLocal.get()));
template.header("userInfo", userInfo);
template.header("X-Request-From", "cc-chaincloud-gateway"); // ← 多了个 cc- 前缀!
}
}两个问题:
cc.feign.user在全仓库的 YAML 里都没有配置 ——
@ConditionalOnProperty 不匹配,所以这个拦截器从来没被注册过, 身份根本不会通过 Feign 传下去。
- 即使配上,头部值也不对:写的是
"cc-chaincloud-gateway",
而 UserInterceptor 校验的是 "chain-cloud-gateway"。 StrUtil.equals 严格比较,不匹配 → 403。
而且即使前两个问题都修了,还有第三个: 它只转发了 userInfo,没有转发 token, 所以下游的 TokenInterceptor 依然会 403。
这个坑必须讲,因为它是"看似完整、实际全断"的典型。 面试时这么讲:
"我们的身份透传设计是三跳的:网关加 header、下游拦截器存 ThreadLocal、 Feign 再传一次。但 code review 的时候我发现, 网关那一步的 header 注入写错了——
mutate().header()返回的是一个新 Builder,必须接住再.build()回 exchange, 原来的写法等于什么都没做。 更要命的是 Feign 那一跳也有问题: 它的@ConditionalOnProperty(cc.feign.user)在所有配置文件里都没开, 所以那个拦截器其实从来没被注册过。这件事让我意识到一个很本质的问题: 隐式约定的传递链路,中间任何一环断了都不会报错,只会让下游"看起来正常地"拒绝你。 所以我给自己定了个规矩:所有涉及信任边界的传递, 必须写一个端到端的集成测试,从网关打到最深的那个服务, 断言 ThreadLocal 里真的能拿到 userId。"
坑 2:白名单是前缀匹配,边界过宽
见 2.7。/manager/loginXyz 这种路径也会被放行。
改成精确匹配 + 受控前缀即可。
坑 3:快递员端过滤器是空实现
public GatewayFilter apply(Object config) { return null; }
public AuthUserInfoDTO check(String token) { return null; }
public Boolean auth(String token, AuthUserInfoDTO dto, String path) { return null; }YAML 里对应行也被注释掉了。
如果哪天真把它打开,会发生什么?
apply() 返回 null → Spring Cloud Gateway 启动时或路由匹配时 NPE如果只是 check() 返回 null 而 apply() 正常, 那么所有快递员端请求都会走到:
Boolean auth = false;
try {
auth = this.authFilter.auth(...); // 返回 null
} catch (Exception e) {
log.error("鉴权失败", e);
}
if (!auth) { // ← 对 null 拆箱,抛 NPE
...
}NPE 被 catch 吞掉,auth 保持 false → 返回 HTTP 400(不是 401)。
这里有个值得讲的小设计: Boolean(包装类型)而不是 boolean, 正是为了让"没实现"这种情况能被 null 表达出来。 但代码用了 if (!auth) 直接拆箱,把这个设计意图又破坏了—— 正确写法是 if (!Boolean.TRUE.equals(auth))。
为什么权限不足返回 400 而不是 403? 这是一个小瑕疵。语义上应该是:
| 场景 | 正确状态码 | 当前实现 |
|---|---|---|
| 没有 token / token 无效 | 401 Unauthorized | ✅ 401 |
| token 有效但权限不足 | 403 Forbidden | ❌ 400 Bad Request |
| 请求格式错误 | 400 Bad Request | — |
400 的语义是"你的请求有问题,改了再来",403 才是"你不够格"。 这个细节能体现你对 HTTP 语义的理解。
坑 4:JwtUtils 无法区分"过期"和"非法"
} catch (ExpiredJwtException e) {
// System.out.println("token已经过期!"); ← 整行被注释掉了
} catch (Exception e) {
log.error("token非法传入,token = {}", token, e);
}
return null; // 两种情况都返回 null后果(这是双 Token 架构最核心的一个问题):
用户拿着一个过期的 access token 来访问, 网关验签失败 → 返回 401 → 前端收到 401, 带着 refresh token 去调 /refresh。
但是:客户端分不清这个 401 是"access 过期了(该刷新)" 还是"refresh 也过期了(该重新登录)"。 因为两种情况都是 401。
正确的设计是区分状态码或错误码:
// 修改 checkToken:区分过期和非法
public static Map<String, Object> checkToken(String token, String publicKeyStr) {
RSA rsa = new RSA(null, publicKeyStr);
try {
return Jwts.parser()
.setSigningKey(rsa.getPublicKey())
.parseClaimsJws(token).getBody();
} catch (ExpiredJwtException e) {
throw new TokenExpiredException("token已过期"); // ← 独立的异常类型
} catch (SignatureException e) {
throw new TokenInvalidException("token签名非法");
} catch (MalformedJwtException e) {
throw new TokenInvalidException("token格式错误");
}
}// 网关按异常类型返回不同状态
try {
authUserInfoDTO = this.authFilter.check(token);
} catch (TokenExpiredException e) {
// 401 + 特殊错误码,让客户端知道该刷新
return writeError(exchange, 401, "TOKEN_EXPIRED");
} catch (TokenInvalidException e) {
// 401 + 另一个错误码,让客户端知道该重新登录
return writeError(exchange, 401, "TOKEN_INVALID");
}顺带说一个安全点: 现在把完整 token 打进日志
log.error("token非法传入,token = {}", token, e);JWT 就是 bearer token,谁拿到都能冒充用户。打进日志就等于泄露给日志平台的所有人。 正确做法是脱敏:token.substring(0, 8) + "***" 或者只打 JTI。
坑 5:白名单配置在网关,但排除路径配在 Web 侧,两边不一致
网关侧(cc.noAuthPaths):
- /customer/user/login
- /customer/user/refreshWeb 侧(CustomerWebConfig):
private static final String[] EXCLUDE_PATH_PATTERNS = new String[]{
"/swagger-ui.html", "/webjars/**", "/swagger-resources", "/v2/api-docs",
"/user/login/**", "/user/refresh/**"};注意两边路径不一样:网关是 /customer/user/login(带端前缀), Web 是 /user/login/**(已经被 StripPrefix=1 剥掉了)。
这意味着两套白名单要人工保持同步, 改了一边忘了另一边,就会出现"网关放行了但 Web 拦截"或反之。
更严重的是:@NoAuthorization 注解定义了、被拦截器识别了,但全仓库零使用。
// chain-cloud-common annotation/NoAuthorization.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoAuthorization { }// UserInterceptor
if (handlerMethod.hasMethodAnnotation(NoAuthorization.class)) {
return true; // 需要接口方法上标注 @NoAuthorization
}所以免鉴权只能靠两处硬编码的路径列表,没有注解式的声明能力。 这是"配置驱动"和"代码驱动"的混用,长期看维护成本很高。
五、如果重做,我会怎么改
| 优先级 | 改动 | 收益 |
|---|---|---|
| P0 | mutate().header() 正确接住并 build() 回 exchange | 让身份透传真正生效 |
| P0 | UserFeignRequestInterceptor 的 X-Request-From 值修正 + 配置 cc.feign.user + 补转发 token | 让 Feign 跨服务调用能带上身份 |
| P0 | JwtUtils 区分"过期"和"非法"两种异常 | 让前端知道该刷新还是重登 |
| P1 | 白名单改精确匹配 | 缩小安全边界 |
| P1 | 日志里的 token 脱敏 | 防日志平台泄露 |
| P1 | 补快递员端过滤器实现(现在全是 null) | 四端能力对齐 |
| P1 | 权限不足返回 403 而不是 400;if (!Boolean.TRUE.equals(auth)) | 语义正确 + 防 NPE |
| P2 | 权限中心调用加本地缓存 + 熔断降级 | 降低网关 RT,防止权限中心抖动拖垮网关 |
| P2 | 白名单统一到一处(网关或配置中心),Web 侧从网关读 | 消除双份配置 |
| P2 | 加端到端鉴权集成测试(网关 → 最深服务,断言 ThreadLocal) | 防止同类问题再次发生 |
P2 里那条集成测试,值得展开讲
这类"信任传递链路"必须有端到端测试,因为:
- 每一环单独看都是对的,只有串起来才知道断没断;
- 这类问题无法通过单元测试发现(单元测试不会经过真实网关);
- 它是回归最容易被破坏的地方(重构、换网关框架、改拦截器顺序)。
测试的设计:
@SpringBootTest(webEnvironment = RANDOM_PORT)
class AuthPropagationIT {
@Test
void 网关到最深层服务的身份透传() {
// 1. 从 auth 服务拿一个真实 token
String token = loginAs("admin");
// 2. 从外部(绕过网关)打 BFF,验证 403(说明 X-Request-From 校验有效)
assertThat(HttpUtil.get("http://127.0.0.1:18093/order/list", token))
.contains("403");
// 3. 从网关打 BFF,验证能拿到数据
ResponseEntity<String> resp = HttpUtil.get("http://127.0.0.1:9527/manager/order/list", token);
assertThat(resp.getStatusCode()).isEqualTo(200);
// 4. ★ 关键断言:在 BFF 侧打印/暴露 UserThreadLocal.getUserId(),断言非空
assertThat(managerService.currentUserId()).isEqualTo(expectedUserId);
// 5. ★ 跨服务断言:Feign 调用链路上游服务能看到同一个 userId
assertThat(orderService.findByOrderId(orderId).getMemberId())
.isEqualTo(expectedUserId);
}
}第 4、5 步是整个测试的价值所在—— 它们断言的正是"隐式传递链路"真正生效了。
六、面试问答
Q1:你们为什么在网关统一鉴权?和 Spring Security 有什么区别?
A: 三个理由:
- 单一入口 —— 网关是所有外部流量的唯一入口。
在这里鉴权,只需要改一处;散落到 22 个服务,改权限规则要改 22 次, 而且任何一个服务漏了就是漏洞。
- 统一鉴权、统一鉴权、统一审计 —— 认证失败、权限不足的日志、
限流、黑名单,都在网关这一层做,后续服务只管业务。
- JWT 是无状态的 —— 验证一个 JWT 是纯 CPU 操作(RSA 验签),
网关做这个几乎零成本。
和 Spring Security 的区别: 项目里没有用 Spring Security(没有 SecurityConfig、 没有 SecurityFilterChain、没有 @EnableWebSecurity)。 Spring Security 是单应用内的过滤器链,天然适合单体或单服务; 微服务下它是每个服务各自一份,做不到"只在一处"。
微服务下鉴权有两条路:
| 方案 | 做法 | 适用 |
|---|---|---|
| 网关统一鉴权 + 下游信任(本项目) | 网关验签,下游用 header + ThreadLocal 取身份 | 信任边界清晰、下游不暴露 |
| 下游各自鉴权 | 每个服务用 Spring Security 验签 | 服务可能被独立访问时 |
本项目选了前者,并且用 X-Request-From 头 + 403 强制"下游只接受网关转发的请求", 确保下游不能被直接访问。这是关键的配套约束—— 如果没有它,"网关统一鉴权"就是一句空话。
追问:那如果内网服务之间要互相调用怎么办? 三种选择:① 服务间调用走 Feign,由调用方从 ThreadLocal 透传(我们现在的做法); ② 内部接口单独开放一套走服务间 mTLS 的鉴权; ③ 区分"面向用户的接口"和"面向服务的接口",后者不鉴权但只允许内网 IP 访问。
Q2:JWT 为什么会失效?双 Token 是怎么解决这个问题的?
A: 先说清楚失效的两个原因:
- Token 泄漏(XSS、日志、传输被劫持)
- 用户权限变更(被禁用、被撤销角色、改了所属机构)
无状态 JWT 的矛盾: 为了性能不查存储, 所以签发出去的 token 在过期前无法撤销。
双 Token 的解法是把两种情况分开:
access token (短,比如 30 分钟)
→ 无状态,网关验签即可,不查 Redis
→ 泄漏的影响窗口小
→ 权限变更的生效延迟 ≤ 30 分钟(可接受)
refresh token(长,比如 7 天)
→ 有状态,存 Redis,且用一次就删(rotation)
→ 每次刷新都重新走一遍鉴权/权限检查
→ 所以权限变更的生效延迟 = 下次刷新时间(< 30 分钟)
→ 而且 refresh token 泄漏后最多被用一次,很快失效这套设计最精妙的地方是"用一次就删": 攻击者偷到 refresh token,即使能调用刷新接口, 刷新成功后原 token 立即失效,合法用户下次刷新时会失败, 从而能发现 token 泄漏(合法用户被迫重新登录)。
追问:refresh token 也应该无状态吗? 不应该。无状态的 refresh token 一旦泄漏, 在有效期内(可能 7 天)攻击者可以随时换新的 access token, 完全无法察觉。有状态是这里必需的。
Q3:C 端为什么不用 Redis Session?
A: 三个理由,核心是用户量级:
Redis Session 方案:
每次请求 → 网关查 Redis → 1 万 QPS 就是 1 万次 Redis 访问
C 端用户几千万 → Session 数据量巨大
移动端弱网,Redis 抖动直接影响登录态无状态 JWT:
每次请求 → 网关验签(RSA 纯 CPU,微秒级)→ 零 IO但纯 JWT 有两个必须解决的问题,我们都用 refresh token 解决了:
- 无法主动撤销 → 用有状态的 refresh token 兜底;
- 无法改 claims(比如改用户名、改权限)→ 所以 access token 里只放 userId,
其他信息要查就现查。
"claims 里只放不变的标识(userId),可变信息实时查" —— 这是无状态 JWT 的最佳实践,也是我们 C 端只塞 userId 的原因。
Q4:怎么防止有人绕过网关直接调微服务?
A: 三层防御:
- 网络层 —— 微服务端口不暴露到公网,只在 VPC 内网可达。
这是最有效的一层。
- 应用层(本项目的主要手段) —— 校验
X-Request-From头,
必须是 chain-cloud-gateway,否则 403。 因为这个头是网关通过 AddRequestHeader 加的, 外部请求带的是用户自己填的值,网关会覆盖掉它。
- 注册中心层 —— Nacos 开启鉴权,防止内网恶意注册假服务。
但我要诚实说第 2 层有一个前提: 网关必须用 AddRequestHeader 覆盖而不是 AddResponseHeader 追加, 否则客户端自己带一个 X-Request-From 就能骗过。 Spring Cloud Gateway 的 AddRequestHeader 过滤器行为是"覆盖同名的已有 header", 这一点是安全的。
追问:那内部服务之间调用 Feign 呢?它们不走网关,X-Request-From 怎么办? 我们是在 Feign 拦截器里再塞一次这个头(UserFeignRequestInterceptor)。 但这里有坑:那个拦截器当前没生效(见坑 1)。 正确做法是让 UserInterceptor 区分两种来源:
String from = request.getHeader("X-Request-From");
boolean fromGateway = "chain-cloud-gateway".equals(from);
boolean fromInternalFeign = "cc-chaincloud-gateway".equals(from); // 区分开
if (!fromGateway && !fromInternalFeign) { response.setStatus(403); return false; }Q5:这套方案你觉得最大的问题是什么?
A: 三个,按严重程度:
- 身份透传链路实际是断的(P0)——
mutate().header() 用法错误 + Feign 拦截器没启用。 这意味着当前系统里只有网关自己知道用户身份,下游全靠猜。 这个问题之所以能存活,是因为它表现为 403 而不是错误答案—— 如果哪天有接口没走 UserInterceptor,就会变成真实的越权。
- JWT 无法区分过期和非法(P0)——
导致客户端无法正确判断该刷新还是重登, 用户体验上是"莫名其妙掉登录"。
- 没有端到端的鉴权集成测试(P1)——
如果有那个测试,上面两个 P0 在 CI 阶段就暴露了。
如果让我重做,我会先写测试再改代码。 这是这件事给我的最大教训: 信任边界上的代码,必须用端到端测试锁住,不能靠人 review。
七、源码定位速查
| 文件 | 行 | 作用 |
|---|---|---|
gateway/filter/TokenGatewayFilter.java | 29-78 | 通用过滤器(4 步:白名单→认证→授权→透传) |
| 同上 | 74-75 | ⚠️ mutate().header() 未接返回值(致命) |
| 同上 | 81-83 | getOrder() = Integer.MIN_VALUE |
gateway/filter/AuthFilter.java | 17-35 | 认证/授权抽象接口 |
gateway/filter/CustomerTokenGatewayFilterFactory.java | 38-61 | C 端:RSA JWT + 免 RBAC + access_token 头 |
gateway/filter/DriverTokenGatewayFilterFactory.java | 42-68 | 司机端:SDK 验签 + 角色交集 |
gateway/filter/ManagerTokenGatewayFilterFactory.java | 40-65 | 管理端:同构 |
gateway/filter/CourierTokenGatewayFilterFactory.java | 全文 30 行 | ⚠️ 全 null 空实现 |
gateway/config/CorsConfig.java | 14-33 | CORS(@Order(HIGHEST_PRECEDENCE)) |
gateway/resources/bootstrap-local.yml | 19-64 | 路由 + 每条路由的 Token 过滤器 |
| 同上 | 80-106 | cc.noAuthPaths 白名单 |
common/util/JwtUtils.java | 30-83 | RS256 签发/校验(过期与非法不分) |
common/interceptor/UserInterceptor.java | 26-60 | 来源校验 + ThreadLocal + 清理 |
common/interceptor/TokenInterceptor.java | 27-55 | token → AuthTemplateThreadLocal |
common/interceptor/UserFeignRequestInterceptor.java | 19-24 | ⚠️ Feign 透传(未启用 + 头部值不符) |
common/vo/AuthUserInfo.java | 16-40 | @Alias 兼容两种 JWT claims |
common/constant/Constants.java | 23-31 | GATEWAY 常量(userId/userInfo/token/access_token) |
ms-web-customer/.../TokenServiceImpl.java | 32-50+ | 双 Token 签发与 Redis 轮换 |
ms-web-manager/.../config/ManagerWebConfig.java | 22-34 | 拦截器注册与排除路径 |
ms-web-customer/.../config/CustomerWebConfig.java | 21-34 | C 端只注册 User 拦截器 |