网关鉴权与身份透传

06 · 核心亮点

亮点六 · 22 个服务,怎么只在一处鉴权

四端差异化的 Token 策略 + 三层拦截器完成身份透传。设计很清晰,但透传链路上有一行致命代码让整条链路静默失效。

涉及模块:chain-cloud-gateway :9527 + chain-cloud-common

亮点六 · 22 个服务,怎么只在一处鉴权

业务问题: 系统有 4 个端(快递员、客户、司机、管理后台), 每个端背后是 4~27 个下游微服务。 如果每个服务都自己做鉴权,改一次权限规则要改 22 个服务, 而且任何一个服务漏了就是漏洞。

所以必须在网关统一鉴权——一次校验,全链路可信。


一、业务先讲清楚:四端的用户根本不是同一类人

这是理解整个鉴权设计的前提。很多面试者答不出这一层, 只答"用了 JWT + 网关",那和背八股没区别。

端用户是谁身份从哪来需要角色校验吗
客户端 web-customerC 端消费者(海量、匿名性强)自签 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:

java
// 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:

java
// 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

java
// 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. 司机端 —— 权限中心 + 角色交集

java
// 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. 快递员端 —— 空实现

java
// 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 里的过滤器也是注释掉的:

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 非对称

java
// 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):

java
// 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;
}

三个关键设计(这部分是真正值得讲的):

  1. 短 token 无状态 —— access token 只有 userId,每次请求在网关验签即可,

不需要查任何存储。这是它快的根本原因。

  1. 长 token 有状态 —— refresh token 存 Redis,用一次就删。

这就是所谓的 refresh token rotation(轮换机制)。

  1. 有效期不同量纲 —— access 用 MINUTE,refresh 用 HOUR,

所以 createToken 才有 DateField 这个参数。

为什么 refresh token 必须有状态?

无状态 refresh有状态 refresh(Redis 轮换)
校验只验签验签 + 查 Redis 比对
泄漏后果攻击者可在有效期内随意刷新只能用一次,泄漏后很快失效
存储压力无Redis(TTL = refresh 有效期)

这一层是"无状态"和"安全"的经典权衡。 C 端选了两者混合:高频路径无状态(access),低频路径有状态(refresh)。 这是目前业界最主流的做法。

2.5 身份透传:三跳

这是整个方案里最容易出错、也最值得讲清的部分。

text
第 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 跳的代码:

java
// 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 的取值:

java
// 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 结构:

java
// 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 路由配置

yaml
# 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

两个值得注意的设计:

  1. 支付回调单独一条路由 /trade/notify/**,不过鉴权。

因为微信/支付宝的回调是服务器到服务器的调用, 它们不可能携带我们系统的 Token。这个通道必须豁免, 靠签名验签(微信的 NotificationHandler + 支付宝的 verifyNotify)来保证安全。

  1. DedupeResponseHeader ... RETAIN_FIRST —— CORS 响应头去重。

因为网关加了 CORS 过滤器,下游服务可能也加了,会导致响应头重复。

2.7 白名单

yaml
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
    ...

注意匹配方式是"前缀匹配"不是"精确匹配":

java
if (StrUtil.startWithAny(path, this.myConfig.getNoAuthPaths())) {
    return chain.filter(exchange);
}

StrUtil.startWithAny 是前缀匹配,所以 /manager/loginXyz 也会被白名单放行。这是一个安全边界过宽的问题。

修复: 精确匹配登录接口,其余用前缀:

java
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>,且不能有任何阻塞操作。

典型错误(很多人第一版都会写):

java
// ❌ 在 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 内部有缓存,且这确实是设计上的一处妥协——

改进方案(面试时主动说):

java
// 1. 角色数据加本地缓存,避免每次请求打权限中心
@Cacheable(value = "userRoles", key = "#userId", unless = "#result == null")
public List<Long> findRoleByUserId(Long userId) { ... }
java
// 2. 或者改用 @CircuitBreaker + 熔断降级:权限中心挂了,按"放行"或"拒绝"降级
@CircuitBreaker(name = "auth", fallbackMethod = "authFallback")
public Boolean auth(...) { ... }

难点 2:身份透传的完整链路不能断

难点在哪: 这是分布式系统里一个非常经典的信任链传递问题。

text
用户身份在网关被验证过  →  但下游服务必须"相信"这个身份  →  而信任的载体是 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 就能看别人的订单。

怎么防:

java
// ❌ 危险:只按 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 拦截器自动注入:

java
// 把 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() 的返回值被丢弃,身份根本没传下去

事实:

java
// 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()。

正确写法:

java
// 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);

后果:整个身份透传链路静默失效。

text
网关放行 → 下游 UserInterceptor 读 X-Request-From
  → AddRequestHeader 滤镜加上了,通过 ✅
  → 读 userInfo 头 → 没有(因为上面那两行是空操作)→ 返回 403 ❌

为什么它没有立刻暴露? 因为 AddRequestHeader=X-Request-From 是在 YAML 里配的(真的生效了), 而 userInfo/token 是代码里加的(没生效)。 如果只验 X-Request-From 而不验 userInfo,这个 Bug 就完全不可见。

再加上 Feign 侧也有同样的问题:

java
// 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- 前缀!
    }
}

两个问题:

  1. cc.feign.user 在全仓库的 YAML 里都没有配置 ——

@ConditionalOnProperty 不匹配,所以这个拦截器从来没被注册过, 身份根本不会通过 Feign 传下去。

  1. 即使配上,头部值也不对:写的是 "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:快递员端过滤器是空实现

java
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 里对应行也被注释掉了。

如果哪天真把它打开,会发生什么?

text
apply() 返回 null  →  Spring Cloud Gateway 启动时或路由匹配时 NPE

如果只是 check() 返回 null 而 apply() 正常, 那么所有快递员端请求都会走到:

java
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 无法区分"过期"和"非法"

java
} 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。

正确的设计是区分状态码或错误码:

java
// 修改 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格式错误");
    }
}
java
// 网关按异常类型返回不同状态
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 打进日志

java
log.error("token非法传入,token = {}", token, e);

JWT 就是 bearer token,谁拿到都能冒充用户。打进日志就等于泄露给日志平台的所有人。 正确做法是脱敏:token.substring(0, 8) + "***" 或者只打 JTI。

坑 5:白名单配置在网关,但排除路径配在 Web 侧,两边不一致

网关侧(cc.noAuthPaths):

yaml
- /customer/user/login
- /customer/user/refresh

Web 侧(CustomerWebConfig):

java
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 注解定义了、被拦截器识别了,但全仓库零使用。

java
// chain-cloud-common annotation/NoAuthorization.java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface NoAuthorization { }
java
// UserInterceptor
if (handlerMethod.hasMethodAnnotation(NoAuthorization.class)) {
    return true;    // 需要接口方法上标注 @NoAuthorization
}

所以免鉴权只能靠两处硬编码的路径列表,没有注解式的声明能力。 这是"配置驱动"和"代码驱动"的混用,长期看维护成本很高。


五、如果重做,我会怎么改

优先级改动收益
P0mutate().header() 正确接住并 build() 回 exchange让身份透传真正生效
P0UserFeignRequestInterceptor 的 X-Request-From 值修正 + 配置 cc.feign.user + 补转发 token让 Feign 跨服务调用能带上身份
P0JwtUtils 区分"过期"和"非法"两种异常让前端知道该刷新还是重登
P1白名单改精确匹配缩小安全边界
P1日志里的 token 脱敏防日志平台泄露
P1补快递员端过滤器实现(现在全是 null)四端能力对齐
P1权限不足返回 403 而不是 400;if (!Boolean.TRUE.equals(auth))语义正确 + 防 NPE
P2权限中心调用加本地缓存 + 熔断降级降低网关 RT,防止权限中心抖动拖垮网关
P2白名单统一到一处(网关或配置中心),Web 侧从网关读消除双份配置
P2加端到端鉴权集成测试(网关 → 最深服务,断言 ThreadLocal)防止同类问题再次发生

P2 里那条集成测试,值得展开讲

这类"信任传递链路"必须有端到端测试,因为:

  1. 每一环单独看都是对的,只有串起来才知道断没断;
  2. 这类问题无法通过单元测试发现(单元测试不会经过真实网关);
  3. 它是回归最容易被破坏的地方(重构、换网关框架、改拦截器顺序)。

测试的设计:

java
@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: 三个理由:

  1. 单一入口 —— 网关是所有外部流量的唯一入口。

在这里鉴权,只需要改一处;散落到 22 个服务,改权限规则要改 22 次, 而且任何一个服务漏了就是漏洞。

  1. 统一鉴权、统一鉴权、统一审计 —— 认证失败、权限不足的日志、

限流、黑名单,都在网关这一层做,后续服务只管业务。

  1. 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: 先说清楚失效的两个原因:

  1. Token 泄漏(XSS、日志、传输被劫持)
  2. 用户权限变更(被禁用、被撤销角色、改了所属机构)

无状态 JWT 的矛盾: 为了性能不查存储, 所以签发出去的 token 在过期前无法撤销。

双 Token 的解法是把两种情况分开:

text
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: 三个理由,核心是用户量级:

text
Redis Session 方案:
  每次请求 → 网关查 Redis → 1 万 QPS 就是 1 万次 Redis 访问
  C 端用户几千万 → Session 数据量巨大
  移动端弱网,Redis 抖动直接影响登录态

无状态 JWT:

text
  每次请求 → 网关验签(RSA 纯 CPU,微秒级)→ 零 IO

但纯 JWT 有两个必须解决的问题,我们都用 refresh token 解决了:

  1. 无法主动撤销 → 用有状态的 refresh token 兜底;
  2. 无法改 claims(比如改用户名、改权限)→ 所以 access token 里只放 userId,

其他信息要查就现查。

"claims 里只放不变的标识(userId),可变信息实时查" —— 这是无状态 JWT 的最佳实践,也是我们 C 端只塞 userId 的原因。

Q4:怎么防止有人绕过网关直接调微服务?

A: 三层防御:

  1. 网络层 —— 微服务端口不暴露到公网,只在 VPC 内网可达。

这是最有效的一层。

  1. 应用层(本项目的主要手段) —— 校验 X-Request-From 头,

必须是 chain-cloud-gateway,否则 403。 因为这个头是网关通过 AddRequestHeader 加的, 外部请求带的是用户自己填的值,网关会覆盖掉它。

  1. 注册中心层 —— Nacos 开启鉴权,防止内网恶意注册假服务。

但我要诚实说第 2 层有一个前提: 网关必须用 AddRequestHeader 覆盖而不是 AddResponseHeader 追加, 否则客户端自己带一个 X-Request-From 就能骗过。 Spring Cloud Gateway 的 AddRequestHeader 过滤器行为是"覆盖同名的已有 header", 这一点是安全的。

追问:那内部服务之间调用 Feign 呢?它们不走网关,X-Request-From 怎么办? 我们是在 Feign 拦截器里再塞一次这个头(UserFeignRequestInterceptor)。 但这里有坑:那个拦截器当前没生效(见坑 1)。 正确做法是让 UserInterceptor 区分两种来源:

java
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: 三个,按严重程度:

  1. 身份透传链路实际是断的(P0)——

mutate().header() 用法错误 + Feign 拦截器没启用。 这意味着当前系统里只有网关自己知道用户身份,下游全靠猜。 这个问题之所以能存活,是因为它表现为 403 而不是错误答案—— 如果哪天有接口没走 UserInterceptor,就会变成真实的越权。

  1. JWT 无法区分过期和非法(P0)——

导致客户端无法正确判断该刷新还是重登, 用户体验上是"莫名其妙掉登录"。

  1. 没有端到端的鉴权集成测试(P1)——

如果有那个测试,上面两个 P0 在 CI 阶段就暴露了。

如果让我重做,我会先写测试再改代码。 这是这件事给我的最大教训: 信任边界上的代码,必须用端到端测试锁住,不能靠人 review。


七、源码定位速查

文件行作用
gateway/filter/TokenGatewayFilter.java29-78通用过滤器(4 步:白名单→认证→授权→透传)
同上74-75⚠️ mutate().header() 未接返回值(致命)
同上81-83getOrder() = Integer.MIN_VALUE
gateway/filter/AuthFilter.java17-35认证/授权抽象接口
gateway/filter/CustomerTokenGatewayFilterFactory.java38-61C 端:RSA JWT + 免 RBAC + access_token 头
gateway/filter/DriverTokenGatewayFilterFactory.java42-68司机端:SDK 验签 + 角色交集
gateway/filter/ManagerTokenGatewayFilterFactory.java40-65管理端:同构
gateway/filter/CourierTokenGatewayFilterFactory.java全文 30 行⚠️ 全 null 空实现
gateway/config/CorsConfig.java14-33CORS(@Order(HIGHEST_PRECEDENCE))
gateway/resources/bootstrap-local.yml19-64路由 + 每条路由的 Token 过滤器
同上80-106cc.noAuthPaths 白名单
common/util/JwtUtils.java30-83RS256 签发/校验(过期与非法不分)
common/interceptor/UserInterceptor.java26-60来源校验 + ThreadLocal + 清理
common/interceptor/TokenInterceptor.java27-55token → AuthTemplateThreadLocal
common/interceptor/UserFeignRequestInterceptor.java19-24⚠️ Feign 透传(未启用 + 头部值不符)
common/vo/AuthUserInfo.java16-40@Alias 兼容两种 JWT claims
common/constant/Constants.java23-31GATEWAY 常量(userId/userInfo/token/access_token)
ms-web-customer/.../TokenServiceImpl.java32-50+双 Token 签发与 Redis 轮换
ms-web-manager/.../config/ManagerWebConfig.java22-34拦截器注册与排除路径
ms-web-customer/.../config/CustomerWebConfig.java21-34C 端只注册 User 拦截器