打开某个老项目时,我一度很困惑:明明A服务通过Feign调用B服务已经写得明明白白,结果对方那边每次都在认证环节直接把我拦回来,日志里一溜的401。其实这个问题的本质并不复杂——这就是“微服务之间调用部分Feign接口需要忽略认证授权”的典型场景:服务间调用本来就不该走完整的用户认证链路。这篇内容主要围绕这个需求展开,聊清楚哪些场景该跳、哪些场景不该跳,以及最常见的白名单方案、内部调用标记方案分别怎么落地,适合正在维护Spring Cloud微服务项目、被服务间鉴权搞得焦头烂额的开发者参考。
1. 那些必须跳过认证的真实业务场景
不做服务间调用的项目,几乎体会不到这种痛点。表面上看,每个服务都把token校验写在拦截器里,谁调用都校验一下,挺安全的。但在实际业务里,这种“一刀切”很快就会变成灾难。
1.1 用户上下文不存在时的典型场景
最典型的场景是定时任务触发的批量处理。比如订单服务每天凌晨扫描超时未支付的订单,然后调用支付服务做关闭支付操作。这个时候根本没有“当前用户”,只有任务调度平台发起的后台执行。你要强制它带一个用户token,那是一种折磨。
还有一种非常常见的场景:服务间的状态同步。用户下单后,订单服务需要通知库存服务扣减库存、通知积分服务增加积分。这些操作都是为了完成一个业务闭环,在业务链路里它们更多是“服务身份”在调用,而不是“某个用户身份”在调用。如果所有接口都强制要求userId,你会发现代码里全是伪造用户上下文的骚操作。
另外就是内部数据查询类的接口。比如用户服务需要从订单服务拉一批统计数据,用来做报表展示。这种接口如果被用户的token逻辑卡住,往往就需要架构师专门去开通白名单,否则整个数据分析的链路直接断掉。
1.2 哪些调用反而必须保留用户认证
跳过认证不是无条件的,我见过不少团队把不该跳的也跳了,最后线上出现越权问题。下面这些场景建议保留完整的用户认证链路:
| 业务场景 | 是否跳过 | 原因 |
|---|---|---|
| 定时任务触发服务间操作 | 跳过 | 无用户上下文,且调用方可信 |
| 服务间数据同步与对账 | 跳过 | 属于内部业务闭环 |
| 服务内部报表聚合查询 | 跳过 | 不涉及用户维度的权限隔离 |
| 用户主动发起的查询接口 | 不跳过 | 必须校验登录状态与操作权限 |
| 涉及用户数据越权风险的写操作 | 不跳过 | 必须识别真实操作者,便于审计 |
| 第三方开放平台回调触发操作 | 不跳过 | 需要验证第三方身份,不能只靠内部信任 |
判断标准其实就一条:这个调用是否真的有“用户主体”存在?如果调用来自用户请求链路,哪怕中间转了好几跳,也应该把用户身份透传下去;如果调用来自后台任务、消息队列消费、服务间同步,那它天然没有用户主体,强制走用户认证就是在制造技术债。
1.3 为什么不能图省事全部放行
有些团队嫌麻烦,干脆把服务间所有Feign接口的认证都关掉。这在短期内确实能跑通所有业务,但迟早会出问题。最直接的风险是:服务间的接口如果全部裸奔,一旦某个服务被攻破,攻击者可以横向调用任意内部接口,而且审计日志里什么都查不到。
更隐蔽的问题是:接口一旦习惯性裸奔,后续新开发的同学就不知道哪些接口是有权限控制的,越权操作就这么慢慢长出来了。所以正确的心态不是“图省事全部放行”,而是“有边界地、有条件地跳过认证”。这也是我写这篇分享的核心态度:跳过认证可以,但要让系统知道你跳过了什么、为什么跳、是谁在跳。
2. 两种主流的“忽略认证授权”实现思路与边界
明确了需要跳过的场景之后,真正的问题是:技术上怎么实现“跳过”才是安全的、可维护的、可审计的。我在几个项目里试过不同的做法,概括下来其实就是两条路:URL白名单放行,以及基于Feign内部调用标记的放行。两条路各有各的边界,选错方案的代价后期都会在线上体现。
2.1 思路一:URL白名单直接放行
这是最朴素、最容易想到的方案。被调用方在认证拦截器里维护一份忽略校验的地址列表,如果请求路径命中了列表里的规则,直接放行,不解析token、不校验权限。
配置大概长这样:
auth: ignore-urls: - /api/internal/** # 内部服务间调用接口统一前缀 - /inner/order/** # 内部订单查询 - /actuator/health # 健康检查接口(通常也要放行)拦截器里按AntPattern匹配即可:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String uri = request.getRequestURI(); boolean matched = authProperties.getIgnoreUrls().stream() .anyMatch(pattern -> new AntPathMatcher().match(pattern, uri)); if (matched) { return true; // 忽略认证授权 } // 继续走正常token校验逻辑 // ... return true; }这个方案优点很明显:实现简单,几天就能铺完,排查问题直观——看到一个路径放了就放行了。缺点也同样明显:它只判断了路径,不判断调用者身份。任何能直接访问到这个服务的请求,只要拼对路径,都能避开认证。如果服务暴露在公网或非隔离内网,风险非常大。
2.2 思路二:Feign内部调用标记放行
第二种方案比白名单安全一个层级。Feign在发起调用时,通过自定义RequestInterceptor往请求头里注入一个内部标记,例如X-Internal-Call: true。服务端在认证拦截器里先判断这个标记是否存在且合法,合法就直接跳过用户认证。
Feign侧的拦截器代码:
public class InternalCallFeignInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { template.header("X-Internal-Call", "true"); template.header("X-Caller-Service", "order-service"); } }服务端侧的校验逻辑:
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 先识别内部调用标记 if ("true".equals(request.getHeader("X-Internal-Call"))) { return true; // 内部服务间调用,跳过用户认证 } // 再走白名单逻辑 String uri = request.getRequestURI(); boolean matched = authProperties.getIgnoreUrls().stream() .anyMatch(pattern -> new AntPathMatcher().match(pattern, uri)); if (matched) { return true; } // 最后才是正常的token认证 // ... return true; }这个方案相对安全的原因是:它在“路径”之外多了一层“来源身份”的判断。但这里有个极易被忽略的隐患——X-Internal-Call这个请求头是明文名称,如果一个外部请求绕过网关直接打到服务端口,也能自己伪造这个Header。所以这个方案还需要一个配套动作:要么网关层统一剥离掉这个头,要么服务端进一步校验签名,不然它只是看起来比白名单强一点而已。
2.3 两者怎么选
从我的实践经验来看,不存在绝对最优,只有最合适的组合策略:
| 对比维度 | URL白名单 | Feign内部标记 |
|---|---|---|
| 实现成本 | 低,配置即生效 | 中,需要写拦截器并管理请求头 |
| 维护直观性 | 高,看配置就知道哪些接口放行 | 中,需要看源码才能理解拦截逻辑 |
| 安全强度 | 低,路径可预测即可能被利用 | 中,需要配合网络隔离或签名 |
| 适合场景 | 健康检查、回调接口、内部公开接口 | 服务间业务调用、对身份敏感的内部链路 |
我个人的倾向很明确:能用内部标记解决的,不要只依赖白名单。白名单适合那些“匿名也能访问”的接口,比如健康检查、部分回调;内部标记适合那些“只有可信服务才能访问”的业务接口。把两类混在一起用同一套逻辑,后面维护时会很痛苦。
3. 落地实战:白名单放行加Feign标记的双层配合
理论聊再多,不如把一套真正可复用的配置和代码贴出来。下面这套是我在普通Spring Cloud项目里验证过的做法:服务端拦截器保留两层判断(内部标记优先、白名单兜底),Feign侧统一注入调用方身份,再用网关层挡住外部伪造请求。
3.1 服务端拦截器:完整版代码
先说核心部分,自定义一个认证拦截器,在进入Controller之前完成三层判断:内部调用标记、白名单路径、正常token校验。
@Component public class AuthInterceptor implements HandlerInterceptor { private final AuthProperties authProperties; public AuthInterceptor(AuthProperties authProperties) { this.authProperties = authProperties; } @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { // 第一层:内部服务调用标记 if ("true".equals(request.getHeader("X-Internal-Call"))) { // 建议在这里校验调用方服务名,确认在允许的列表里 String caller = request.getHeader("X-Caller-Service"); if (authProperties.getAllowedCallers().contains(caller)) { return true; } } // 第二层:白名单路径放行 String uri = request.getRequestURI(); boolean inWhiteList = authProperties.getIgnoreUrls().stream() .anyMatch(pattern -> new AntPathMatcher().match(pattern, uri)); if (inWhiteList) { return true; } // 第三层:正常token解析与校验 String token = request.getHeader("Authorization"); if (!isValidToken(token)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } return true; } private boolean isValidToken(String token) { // 这里对接你的认证服务或JWT解析逻辑 return token != null && token.startsWith("Bearer "); } }然后通过WebMvcConfigurer注册这个拦截器,并设置拦截路径:
@Configuration public class WebMvcConfig implements WebMvcConfigurer { private final AuthInterceptor authInterceptor; public WebMvcConfig(AuthInterceptor authInterceptor) { this.authInterceptor = authInterceptor; } @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/**"); // 登录接口本身放行 } }这里有个细节值得说一下:拦截器不要配置成拦截所有路径,只对需要保护的业务路径生效,避免把Spring Boot的Error接口、静态资源也卷进认证逻辑里。
3.2 配置项统一外置
为了让运维和开发不用每次改代码,把关键配置全部放到配置文件里。用Nacos或Apollo管理最好,本地yml也能跑:
auth: ignore-urls: - /api/internal/** allowed-callers: - order-service - user-service - gateway-service配合@RefreshScope就能实现动态刷新,这一点我在后面的踩坑部分会再展开。
3.3 Feign侧:给所有客户端统一加内部标记
在服务启动时,定义一个全局的FeignRequestInterceptor。这样所有的Feign客户端调用都会带上内部身份头,不需要每个FeignClient单独配置。
@Configuration public class FeignClientConfiguration { @Bean public RequestInterceptor internalCallRequestInterceptor() { return new InternalCallFeignInterceptor(); } }拦截器具体实现:
public class InternalCallFeignInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { template.header("X-Internal-Call", "true"); template.header("X-Caller-Service", getCurrentServiceName()); } private String getCurrentServiceName() { // 优先取环境变量或配置 return "order-service"; } }如果你项目里有部分FeignClient是用来调第三方外部接口的,那就别用全局拦截器,改成按客户端指定configuration:
@FeignClient(name = "stock-service", configuration = InternalFeignConfig.class) public interface StockFeignClient { ... }这一点特别容易踩坑,我见过有团队在全局配了拦截器,结果对外API的所有Feign请求都带上了内部标记,对方一看头都大了。
3.4 网关层也要配合放行
整个链路里最容易被忽略的反而是网关。如果你架构里有Spring Cloud Gateway,外部请求先过网关,这时即使服务内部已经放行了内部标记,网关这一层如果对token校验有强逻辑,还是会直接把请求拦下来。
网关层面的处理思路其实很清晰:把“不需要用户认证的内部路径”也加入网关白名单,并且最好在网关层直接剥离掉不可信的请求头。我一般会在网关里放一个过滤器,做两件事:
public class InternalCallFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String path = request.getURI().getPath(); // 网关白名单路径提前放行 if (isInWhiteList(path)) { return chain.filter(exchange); } // 非白名单路径:去掉内部调用标记,防止外部伪造 ServerHttpRequest mutatedRequest = request.mutate() .headers(headers -> headers.remove("X-Internal-Call")) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } }这一步做的核心动作就是“剥离”:外部来的请求,一律不允许携带内部调用标记,避免有人直接伪造请求打服务。内网隔离更严格的话,这一步可能是多余的,但只要服务还可能被外部访问到,剥离动作就很有必要。
4. 这几个坑我基本都踩过
理论方案看完了,代码也贴完了,接下来这部分才是真正值钱的经验。以下每个问题都是我在实际项目里真真切切遇到的,有些问题排查了一个下午才发现原因。
4.1 坑一:服务内放行了,网关还把你拦在外面
还是网关配合的问题。很多团队服务端的拦截器已经放行了内部调用标记,但网关的GlobalFilter只认Authorization头,一看到没有token就直接转发到登录逻辑,请求压根没进到目标服务。
排查这类问题的时候,第一件事就是梳理请求走了哪几层过滤:客户端到网关、网关到服务、服务内部拦截器、最后才是Controller。每一层都会漏。我现在的习惯是,明确把“认证校验”这一职责划分给某一层,其他层只透传或剥离,不做二次校验,避免每层都加一道锁导致互相矛盾。
4.2 坑二:Feign默认不会传递原请求头
我当时犯过一个很低级的错误:以为Feign调用时会把当前HTTP请求的Header自动带过去。实际上Spring Cloud OpenFeign默认不会透传原请求的Header,包括Cookie和Authorization。只有显式配置RequestInterceptor之后,才会把需要的信息加到新的请求里。
这带来的连锁反应是:如果业务链路是用户请求到网关、网关到服务A、服务A通过Feign调服务B,那你服务B那边看到的只有你的自定义Header,没有原始用户的Authorization。所以要么你在每跳Feign调用时手动透传用户token,要么就像这里说的,直接采用内部调用标记跳过用户认证,不要把用户token层层搬运。我的建议是:用户身份该透传的透传,服务身份该跳过的跳过,两条线不要混。
还有一个隐蔽版本的问题:当你用了Hystrix信号量隔离或线程池隔离时,如果认证信息放在线程上下文里,Feign拦截器在另一个线程里运行时取不到,也会导致认证信息丢失。如果确实需要透传,可以考虑用TransmittableThreadLocal配合实现上下文传递。
4.3 坑三:AntPathMatcher路径匹配的过度放行
这个坑很搞心态。/api/internal/**和/api/internal/*看起来只差一个星号,实际匹配范围完全不同。前者匹配/api/internal/order/detail,后者只匹配/api/internal/order,不会继续往下匹配多级路径。
更危险的是,有人配置的时候写成了/api/*,觉得这是内部路径前缀放行,结果所有/api/order、/api/user的接口全部免认证了。排查这种问题的唯一有效方式就是写几个本地单元测试,把每个配置项实际匹配到的路径全部打出来看一遍,不要靠肉眼判断。
我还遇到过另一个相关的问题:服务里统一加了servlet context path,比如所有请求都带/order-service前缀,但白名单配置里没带前缀,导致/api/internal压根匹配不上。这个微调很烦人,因为本地调试时没有前缀,测试环境有前缀,两边表现完全不一样。
4.4 坑四:只做了放行,没做来源校验
有段时间我的服务端只做了白名单判断,结果发现外部直接把内部接口路径摸出来,绕过登录就调走了。原因就是白名单只判断“路径可不可访问”,不判断“谁在访问”。
给内部调用做来源校验,至少要有下面这些手段之一:
- 校验调用方服务名是否在允许列表里
- 网关剥掉外部请求携带的内部标记
- 网络层隔离,内部接口只允许内网访问
- 内部调用标记配合签名和时间戳,防止伪造与重放
别嫌麻烦。一套纯白名单的方案上线后,安心程度真的很低。宁可开发时多写两行判断,也不要上线后开夜车补漏洞。
4.5 坑五:配置不生效,白名单更新后仍然401
如果你把白名单配置放在Nacos或Apollo这类配置中心,直接用@Value读取,而不加@RefreshScope,那配置的变动是没法自动刷新的。而且Spring Cloud的一些版本里,即使加了@RefreshScope,拦截器内部持有的配置对象可能还是旧的,因为拦截器本身是单例,刷新时不会重新创建。
稳妥的做法是配置类单独加@RefreshScope,然后在拦截器里每次都读配置对象的方法,而不是把它缓存在自己的字段里。改配置之后多观察一段时间,确认刷新生效再去操作线上流量。
5. 生产环境收敛:把跳过认证升级为内部身份识别
前面提到的两个方案组合起来,解决的是“这个接口不需要用户认证”的问题。但如果你的服务数量多、调用链路复杂,我更推荐多走一步,把“忽略认证”变成“识别内部调用身份”。两种思路的差别在于:忽略是放行,识别是确认来源。生产环境越往后演进,后者越重要。
5.1 从“放行”到“验签”:用服务间Token代替裸标记
一个比较轻量又实用的方案是:服务间约定一个内部密钥,Feign发起调用时,拦截器根据请求路径、时间戳和密钥算一个签名,放到请求头里。服务端收到请求后,用同样的密钥和参数校验签名,匹配才放行。
public String sign(String path, String timestamp, String secret) { String raw = path + "|" + timestamp + "|" + secret; try { MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] digest = md.digest(raw.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(digest); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } }服务端校验的时候,再检查一下时间戳和当前时间的差值,超过5分钟的直接拒绝,能挡掉大部分重放攻击。这个方案的密钥需要放到配置中心统一管理,定期轮换,同时按服务维度生成不同的密钥,避免一把钥匙开所有门的局面。
5.2 白名单边界的检查清单
维护一份清晰的检查清单,比写多少代码都管用。我每接入一个新接口到白名单或者内部标识体系时,都会按下面几项过一遍:
- 这个接口真的需要跳过用户认证吗?有没有可能接收调用方透传的用户身份?
- 跳过之后,如果外部恶意访问,影响面有多大?
- 内部接口是否统一使用了一个特殊路径前缀,方便防火墙和网关做策略?
- 是否配置了来源服务名校验?允许的服务列表是否是最小集合?
- 审计日志里能否看到“哪个服务在什么时间调用了哪个内部接口”?
- 配置文件里的白名单项是否有人定期review,清理掉已经不再使用的路径?
5.3 最小可行的内部调用身份机制示例
如果你不想直接引入太重度的框架,可以先做这样一套最小组合:
端口层面只允许内网段访问,网关与服务之间走独立内网通道。服务端拦截器不做用户认证,只校验内部签名头,签名密钥按服务配置。网关对来自外部的请求统一剥掉内部签名相关的所有请求头。这四步下来,整体安全性比单纯的白名单放行强很多,而且改动不大。
5.4 一点实际操作后的体会
服务间Feign调用跳过认证授权这个需求,听起来像个“小配置”,实际上牵扯到网关、拦截器、Feign客户端、配置中心、网络策略好几个层面。我自己的改进动作也很简单,就是定一条规矩:以后的内部调用接口统一放在/internal前缀下,凡是没有用户上下文的服务间调用,全部走内部签名头校验,不走白名单。真需要临时放行的非内部接口,单独走审批配置,定期盘点清理。
这样做下来,团队里没人再为“这个接口要不要带token”扯皮,系统的安全边界也清晰很多。功能逻辑没有多复杂,但长期维护起来省心不少。