1. 从一个真实的线上故障说起
去年冬天的一个凌晨,我被值班电话叫醒。监控大盘上,订单服务的错误率突然从0.1%飙到了37%,大量请求返回403。奇怪的是,网关层的鉴权日志一切正常,用户明明带着合法的Token进来的。排查了两个小时,最后定位到一个让人哭笑不得的原因:订单服务内部通过Feign调用库存服务时,把用户请求头里的Token弄丢了,库存服务的安全拦截器一看没有身份凭证,直接拒绝。
这个故障暴露了一个在微服务架构里极其常见、又极其容易被忽视的问题:服务间的内部调用,到底要不要做权限校验?如果要做,怎么做才不破坏原有的安全体系?
很多团队在项目初期为了图快,内部Feign调用直接裸奔——网关校验一次,后面的服务之间互相调用就完全信任。这种做法在服务数量少的时候问题不大,一旦服务拆分到几十个,任何一个服务被攻破或者出现越权逻辑,整个系统就形同虚设。但反过来,如果每个内部调用都像外部请求一样走完整的鉴权流程,性能开销和改造成本又让人头疼。
这篇内容就是围绕"Feign接口内部调用进行权限校验"这个主题,把我自己在几个项目中踩过的坑、试过的方案、最终沉淀下来的做法完整梳理一遍。不管你是刚开始接触微服务的新手,还是正在为内部调用安全发愁的老手,应该都能从中找到可以直接抄作业的东西。
2. 为什么内部调用不能简单"信任一切"
2.1 网关鉴权只解决了第一道门的问题
大部分微服务架构的鉴权是这样的:所有外部流量先经过网关,网关负责解析Token、校验签名、检查权限,然后把用户信息透传到下游服务。下游服务默认信任网关传来的用户标识,不再做二次校验。
这个模型在"所有请求都必须经过网关"的前提下是成立的。但现实情况是,服务之间的调用链路往往比想象中复杂。A服务调用B服务,B服务又调用C服务,C服务可能还会回调A服务的某个接口。这些调用根本不经过网关,如果下游服务完全信任上游传来的用户标识,那么只要有一个服务存在SSRF漏洞或者被内部人员误操作,攻击者就可以伪造任意用户身份调用任何接口。
我见过一个真实的案例:某系统的用户服务提供了一个"根据用户ID查询详细信息"的内部接口,设计上只允许其他微服务调用。但这个接口没有做任何调用方身份校验,结果被外部通过一个未授权的路径直接访问,导致大量用户隐私数据泄露。问题不在于接口本身有漏洞,而在于"内部接口"这个假设本身就是不安全的。
2.2 内部调用的三种典型风险场景
把内部调用的风险拆开来看,主要有三类:
第一类是身份伪造。上游服务在调用下游时,如果只是简单地在请求头里塞一个userId,下游服务无法验证这个userId是不是真的由可信服务写入的。任何能访问到下游服务网络地址的客户端都可以伪造这个头。
第二类是权限放大。假设A服务当前处理的用户只有"查看订单"的权限,但A服务调用B服务的"修改库存"接口时,如果B服务不校验权限,那么这个用户就间接获得了修改库存的能力。这就是典型的权限放大漏洞。
第三类是调用链污染。在多层调用中,用户身份信息经过多次传递,每一层都可能被篡改或丢失。如果没有一套机制保证身份信息在整条链路上的完整性和一致性,排查问题时就会非常困难。
2.3 内部调用鉴权的核心诉求
搞清楚风险之后,内部调用鉴权的诉求其实很明确:
- 可验证性:下游服务能够验证调用方身份的合法性,而不是盲目信任请求头里的内容。
- 权限一致性:内部调用不应该绕过业务权限校验,用户A不能通过服务B间接做他没有权限做的事。
- 低侵入性:改造不能要求每个业务接口都手写鉴权代码,最好通过框架层面统一解决。
- 可追溯性:调用链路上的身份信息要完整传递,方便日志记录和问题排查。
这四个诉求决定了方案选型的方向。接下来我会逐一分析几种常见方案,以及它们各自的适用场景和坑。
3. 四种内部调用鉴权方案的实战对比
3.1 方案一:请求头透传Token,下游重新校验
这是最容易想到的方案。Feign在发起调用时,通过拦截器把当前请求的Token原样透传到下游服务,下游服务的安全框架(比如Spring Security)像处理外部请求一样重新解析Token、校验权限。
实现上,你需要一个Feign的RequestInterceptor:
public class FeignTokenRelayInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes != null) { String token = attributes.getRequest().getHeader("Authorization"); if (token != null) { template.header("Authorization", token); } } } }然后在Feign配置里注册这个拦截器。下游服务不需要做任何特殊处理,因为它本来就有安全框架在拦截请求。
这个方案的优点是实现简单、权限校验逻辑统一、不需要额外的密钥管理。但缺点也很明显:每次内部调用都要完整走一遍Token解析和权限查询,如果调用链很深,同一个Token会被反复解析多次,性能开销不容忽视。另外,如果下游服务的接口设计是"内部专用"的,它的权限模型可能和外部接口不一样,强行套用外部鉴权逻辑反而会出问题。
这个方案适合调用链较短、性能要求不极端的场景。如果QPS很高,建议配合本地缓存来减少Token解析的开销。
3.2 方案二:内部服务凭证 + 用户身份透传
这个方案的核心思路是分离"调用方身份"和"用户身份"。调用方身份通过服务间的共享密钥来验证,用户身份则通过一个可信的通道透传。
具体做法是:每个服务实例配置一个服务级密钥(或者使用非对称加密,私钥签名、公钥验签)。Feign调用时,拦截器用服务密钥对请求进行签名,同时把当前用户信息(userId、tenantId等)放在特定的请求头里。下游服务先用公钥验证签名,确认调用方是可信服务,然后再从请求头里取出用户信息进行业务权限校验。
签名部分的代码大概长这样:
public class FeignSignatureInterceptor implements RequestInterceptor { private final String serviceId; private final PrivateKey privateKey; @Override public void apply(RequestTemplate template) { String timestamp = String.valueOf(System.currentTimeMillis()); String nonce = UUID.randomUUID().toString(); String userId = CurrentUserContext.getUserId(); String signContent = serviceId + timestamp + nonce + userId; String signature = sign(signContent, privateKey); template.header("X-Service-Id", serviceId); template.header("X-Timestamp", timestamp); template.header("X-Nonce", nonce); template.header("X-User-Id", userId); template.header("X-Signature", signature); } }下游服务用一个拦截器或过滤器来验证签名,验证通过后把用户信息放入当前上下文。
这个方案的好处是:调用方身份验证和用户权限校验解耦,性能更好(签名验证比Token解析快),而且可以防止请求头被伪造。缺点是密钥管理比较复杂,需要一套安全的密钥分发和轮换机制。另外,nonce和timestamp的校验需要配合缓存来防止重放攻击。
3.3 方案三:OAuth2 Client Credentials模式
如果系统本身就在用OAuth2体系,那么内部调用可以直接复用Client Credentials模式。每个服务作为一个OAuth2 Client,调用前先获取一个access token,然后用这个token去调用下游服务。
这个方案的优点是标准化程度高,和现有的安全体系无缝集成。缺点是每次调用前都要检查token是否过期、是否需要刷新,多了一次网络交互(获取token),延迟会增加。而且Client Credentials模式获取的token通常只代表服务本身,不包含用户信息,如果需要用户级别的权限校验,还需要额外透传用户身份。
在实际项目中,我一般会配合本地缓存来减少token获取的次数,比如token有效期是1小时,本地缓存55分钟,到期前异步刷新。这样大部分调用都不需要额外的网络开销。
3.4 方案四:网关统一代理内部调用
还有一种思路是让内部调用也走网关,由网关统一做鉴权。服务之间不直接通过Feign调用,而是通过网关转发。
这个方案的好处是鉴权逻辑完全统一,不需要在每个服务里重复实现。但缺点也很致命:网关变成了所有内部调用的瓶颈,延迟增加、单点风险加大。而且很多内部调用是高频的、数据量大的,走网关完全不划算。
我个人的经验是,这个方案只适合调用频率低、对延迟不敏感的场景,比如管理后台的跨服务操作。核心业务链路还是应该用前三种方案。
| 方案 | 实现复杂度 | 性能开销 | 安全性 | 适用场景 |
|---|---|---|---|---|
| Token透传 | 低 | 中 | 中 | 调用链短、QPS中等 |
| 服务签名+用户透传 | 中 | 低 | 高 | 核心业务链路 |
| OAuth2 Client Credentials | 中 | 中 | 高 | 已有OAuth2体系 |
| 网关代理 | 低 | 高 | 高 | 低频管理操作 |
4. 落地时必须解决的五个工程问题
4.1 用户上下文在异步线程中的丢失
这是我在实际项目中最常遇到的问题。Feign的调用可能发生在异步线程里,而Spring的RequestContextHolder默认是基于ThreadLocal的,异步线程拿不到当前请求的信息,导致Token或用户信息透传失败。
解决方案有两种。一种是使用InheritableThreadLocal,但它在使用线程池时也会失效,因为线程池里的线程是复用的,不会重新继承。另一种更可靠的方式是手动传递上下文,在提交异步任务时把当前用户信息作为参数传进去,在异步线程里重新设置。
public CompletableFuture<Result> asyncCall() { UserContext ctx = UserContextHolder.get(); return CompletableFuture.supplyAsync(() -> { try { UserContextHolder.set(ctx); return feignClient.doSomething(); } finally { UserContextHolder.clear(); } }, executor); }如果项目里用了Hystrix或Sentinel做熔断降级,还需要注意它们的线程隔离策略。默认情况下,Hystrix会在独立线程池里执行Feign调用,同样会导致上下文丢失。可以通过配置execution.isolation.strategy=SEMAPHORE来使用信号量隔离,避免线程切换,但这样会失去线程池隔离的容错能力,需要根据实际情况权衡。
4.2 调用链路上的身份信息一致性
在多层调用中,用户身份信息需要从最上层一直传递到最底层。如果中间某一层没有正确透传,底层服务就拿不到用户信息,可能导致鉴权失败或者更严重的越权问题。
我的做法是在框架层面统一处理:定义一个UserContext对象,包含userId、tenantId、roles等字段。Feign拦截器负责把它序列化到请求头,下游服务的过滤器负责反序列化并设置到当前上下文。这样业务代码完全不需要关心透传的细节。
需要注意的是,请求头的大小是有限制的。如果用户角色很多,不要把整个角色列表都塞进请求头,只传必要的标识(比如userId和tenantId),具体的权限信息由下游服务自己查询。这样既减少了网络开销,也避免了请求头超限的问题。
4.3 内部接口的权限模型设计
内部接口的权限校验和外部接口有一个本质区别:外部接口的权限模型通常是"用户能做什么",而内部接口还需要考虑"这个服务能做什么"。
举个例子,订单服务调用库存服务的"扣减库存"接口。这个操作需要两个维度的校验:第一,调用方是不是可信的订单服务;第二,当前用户有没有权限触发这个扣减操作。前者是服务级权限,后者是用户级权限。
在实际设计中,我通常会把内部接口分成三类:
- 纯内部接口:只允许特定服务调用,不涉及用户权限。比如服务健康检查、配置同步。这类接口只需要验证调用方身份。
- 用户代理接口:代表用户执行操作,需要同时验证调用方身份和用户权限。比如上面说的扣减库存。
- 系统接口:由定时任务或后台管理触发,没有具体用户。这类接口需要验证调用方身份,并使用系统级的权限标识。
把接口分类之后,鉴权逻辑就可以按类别统一处理,而不是每个接口单独写。
4.4 性能与缓存的平衡
内部调用的鉴权如果每次都查数据库或远程调用权限服务,性能肯定扛不住。缓存是必须的,但缓存的设计有几个坑。
首先是缓存的粒度。按用户缓存权限信息是最细的,但用户数量大时内存占用高。按角色缓存是更常见的选择,因为角色数量通常远小于用户数量。如果权限模型支持租户隔离,还需要按租户维度做缓存分区。
其次是缓存的失效策略。权限变更后,缓存需要及时失效。如果使用本地缓存,多实例之间的缓存不一致是个问题。我的做法是结合消息队列做缓存失效通知:权限变更时发一条消息,所有实例收到后清除对应的本地缓存。这样既保证了性能,又保证了最终一致性。
如果对权限变更的实时性要求极高,可以考虑使用集中式缓存(如Redis),但会增加一次网络调用。实际项目中,大部分场景下秒级的最终一致性是可以接受的。
4.5 灰度发布期间的兼容性
在系统迭代过程中,鉴权方案可能会升级。比如从"完全信任内部调用"切换到"服务签名验证"。灰度发布期间,新旧版本的服务会同时存在,如果新版本强制要求签名,旧版本没有签名,调用就会失败。
解决这个问题的关键是让验证逻辑支持"宽松模式"和"严格模式"的平滑切换。在灰度初期,下游服务对没有签名的请求只记录告警日志,不拒绝;等所有上游服务都升级完成后,再切换到严格模式。这个切换过程可以通过配置中心动态控制,不需要重启服务。
5. 一个可复用的Feign鉴权拦截器实现
5.1 整体设计思路
把前面讨论的内容整合起来,我设计了一个可复用的Feign鉴权拦截器。它的核心职责是:在Feign发起调用前,自动完成服务签名和用户信息透传;在下游服务收到请求后,自动完成签名验证和用户上下文设置。
整个方案基于三个组件:FeignAuthInterceptor(调用方拦截器)、InternalAuthFilter(被调用方过滤器)、UserContextHolder(用户上下文持有器)。三个组件配合工作,业务代码完全无感知。
5.2 调用方拦截器的完整实现
@Component public class FeignAuthInterceptor implements RequestInterceptor { @Value("${internal.auth.service-id}") private String serviceId; @Value("${internal.auth.secret-key}") private String secretKey; @Override public void apply(RequestTemplate template) { // 1. 获取当前用户上下文 UserContext userContext = UserContextHolder.get(); String userId = userContext != null ? userContext.getUserId() : ""; String tenantId = userContext != null ? userContext.getTenantId() : ""; // 2. 生成签名所需的元数据 String timestamp = String.valueOf(System.currentTimeMillis()); String nonce = UUID.randomUUID().toString().replace("-", ""); // 3. 计算签名 String signContent = String.join("|", serviceId, timestamp, nonce, userId, tenantId); String signature = hmacSha256(signContent, secretKey); // 4. 写入请求头 template.header("X-Internal-Service", serviceId); template.header("X-Internal-Timestamp", timestamp); template.header("X-Internal-Nonce", nonce); template.header("X-Internal-User-Id", userId); template.header("X-Internal-Tenant-Id", tenantId); template.header("X-Internal-Signature", signature); } private String hmacSha256(String content, String key) { try { Mac mac = Mac.getInstance("HmacSHA256"); SecretKeySpec spec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); mac.init(spec); byte[] bytes = mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); return Base64.getEncoder().encodeToString(bytes); } catch (Exception e) { throw new IllegalStateException("签名计算失败", e); } } }这里有几个设计细节值得说明。第一,签名内容用|分隔,避免字段拼接时的歧义。第二,nonce用UUID去掉横线,保证唯一性。第三,时间戳用毫秒,配合下游的过期校验可以防止重放攻击。
5.3 被调用方过滤器的完整实现
@Component public class InternalAuthFilter implements Filter { @Value("${internal.auth.secret-key}") private String secretKey; @Value("${internal.auth.signature-expire-ms:300000}") private long signatureExpireMs; private final Cache<String, Boolean> nonceCache = Caffeine.newBuilder().expireAfterWrite(10, TimeUnit.MINUTES).build(); @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; // 只处理内部调用请求 String serviceId = httpRequest.getHeader("X-Internal-Service"); if (serviceId == null) { chain.doFilter(request, response); return; } // 1. 校验时间戳 String timestamp = httpRequest.getHeader("X-Internal-Timestamp"); if (Math.abs(System.currentTimeMillis() - Long.parseLong(timestamp)) > signatureExpireMs) { reject(response, "请求已过期"); return; } // 2. 校验nonce防重放 String nonce = httpRequest.getHeader("X-Internal-Nonce"); if (nonceCache.getIfPresent(nonce) != null) { reject(response, "重复请求"); return; } nonceCache.put(nonce, true); // 3. 校验签名 String userId = httpRequest.getHeader("X-Internal-User-Id"); String tenantId = httpRequest.getHeader("X-Internal-Tenant-Id"); String signature = httpRequest.getHeader("X-Internal-Signature"); String signContent = String.join("|", serviceId, timestamp, nonce, userId, tenantId); String expectedSignature = hmacSha256(signContent, secretKey); if (!expectedSignature.equals(signature)) { reject(response, "签名验证失败"); return; } // 4. 设置用户上下文 try { if (StringUtils.hasText(userId)) { UserContext context = new UserContext(); context.setUserId(userId); context.setTenantId(tenantId); UserContextHolder.set(context); } chain.doFilter(request, response); } finally { UserContextHolder.clear(); } } }这个过滤器有几个关键点。第一,通过X-Internal-Service头来判断是否是内部调用,外部请求没有这个头,直接放行给后续的安全框架处理。第二,时间戳校验和nonce校验配合使用,有效防止重放攻击。第三,签名验证使用常量时间比较(实际项目中应该用MessageDigest.isEqual来避免时序攻击)。第四,用户上下文在finally块中清理,避免线程复用导致的上下文污染。
5.4 用户上下文持有器的设计
public class UserContextHolder { private static final ThreadLocal<UserContext> CONTEXT = new ThreadLocal<>(); public static void set(UserContext context) { CONTEXT.set(context); } public static UserContext get() { return CONTEXT.get(); } public static String getUserId() { UserContext ctx = CONTEXT.get(); return ctx != null ? ctx.getUserId() : null; } public static void clear() { CONTEXT.remove(); } }这个类看起来简单,但有一个容易忽略的点:clear()方法必须用remove()而不是set(null)。set(null)只是把值设为null,ThreadLocal的Entry还在,在线程池场景下仍然存在内存泄漏的风险。remove()才是正确的清理方式。
6. 踩过的坑与排查实录
6.1 签名验证失败但日志显示签名一致
有一次排查一个签名验证失败的问题,日志里打印出来的签名内容和计算的签名看起来一模一样,但就是验证不通过。排查了半天,最后发现是Base64编码的换行问题。调用方用的Base64编码器会在每76个字符后插入换行符,而被调用方的解码器不认这个换行,导致解码后的字节数组不一致。
这个坑的教训是:签名相关的编码解码一定要用同一套工具类,不要一边用Apache Commons Codec,一边用JDK自带的Base64。最好在项目里封装一个统一的签名工具类,所有地方都调它。
6.2 请求头大小超限导致调用失败
前面提到过,不要把太多信息塞进请求头。我踩过的具体坑是:某次为了图方便,把用户的完整角色列表序列化成JSON放进了请求头,单个角色少的时候没问题,遇到一个角色特别多的用户,请求头超过了Tomcat默认的8KB限制,直接返回400。
排查这个问题的过程比较曲折,因为错误信息是"Bad Request",没有更具体的提示。后来通过抓包才看到请求头确实超大了。解决方案很简单:请求头里只放userId和tenantId,角色信息由下游服务根据userId自己查。
6.3 异步调用中上下文丢失的排查链路
异步场景下上下文丢失的问题特别隐蔽,因为同步调用测试完全正常,一上异步就出问题。我的排查思路是这样的:
第一步,确认问题是否与异步相关。在Feign拦截器里打印当前线程名和UserContext的内容,如果线程名是业务线程池的名字,且UserContext为null,基本可以确定是上下文丢失。
第二步,定位上下文丢失的具体位置。是在提交异步任务时丢失的,还是在异步任务执行过程中丢失的?在提交任务前后分别打印上下文,可以快速定位。
第三步,检查线程池的配置。如果是Hystrix导致的,检查execution.isolation.strategy配置;如果是自定义线程池,检查是否使用了InheritableThreadLocal以及线程池是否允许核心线程超时回收。
第四步,修复并验证。修复后不仅要测试正常流程,还要测试线程池满载、任务排队等边界情况,确保上下文在各种场景下都能正确传递。
6.4 灰度期间新旧版本共存的兼容处理
灰度发布期间最头疼的是新旧版本共存。旧版本的服务不会带签名头,新版本的服务如果强制校验签名,调用就会失败。
我的处理方式是在过滤器里加一个开关:
@Value("${internal.auth.strict-mode:false}") private boolean strictMode; // 在签名验证失败时 if (!expectedSignature.equals(signature)) { if (strictMode) { reject(response, "签名验证失败"); return; } else { log.warn("签名验证失败,宽松模式放行。serviceId={}, userId={}", serviceId, userId); } }灰度初期把strict-mode设为false,只记录告警。通过监控确认所有上游服务都已经带上签名后,再动态切换为true。这个开关放在配置中心,切换不需要重启。
7. 几个值得注意的边界情况
7.1 文件上传下载接口的特殊处理
Feign在处理文件上传下载时,请求体的处理方式和普通接口不一样。如果签名是基于请求体的,文件流只能被读取一次,签名计算和实际发送会冲突。
我的做法是对文件类接口单独处理:签名只基于请求头里的元数据(serviceId、timestamp、nonce、userId),不包含请求体。虽然这样安全性略低(无法防止请求体被篡改),但配合HTTPS传输,实际风险可控。如果对安全性要求极高,可以在请求头里加一个请求体的摘要值,但实现复杂度会上升不少。
7.2 定时任务触发的内部调用
定时任务没有用户上下文,调用下游服务时userId为空。下游服务如果强制要求userId非空,就会拒绝请求。
处理方式是在定时任务的入口处设置一个系统级的用户上下文,比如userId设为system,tenantId设为任务所属的租户。下游服务的权限校验逻辑对system用户做特殊处理,只校验服务级权限,跳过用户级权限校验。
7.3 跨租户调用的权限隔离
在多租户系统中,内部调用还需要注意租户隔离。A租户的订单服务不能调用B租户的库存服务。这个校验应该在服务级完成:调用方在签名里带上tenantId,被调用方验证tenantId是否与当前服务实例所属的租户一致。
如果系统支持动态租户(一个服务实例处理多个租户的请求),那么租户隔离应该在数据层做,而不是在服务层做。这种情况下,签名里的tenantId主要用于日志追踪和权限校验,不用于路由。
8. 我个人的一些经验体会
内部调用鉴权这件事,最怕的不是技术方案复杂,而是团队里没有人对这件事负责。我见过太多项目,网关层的鉴权做得滴水不漏,内部调用却完全裸奔。问起来就是"内部调用信任就行了",但真出了安全问题,追责的时候才发现谁都没管。
我的建议是,在项目初期就把内部调用的鉴权框架搭好。不需要一开始就上最复杂的方案,但至少要做到两点:第一,调用方身份可验证;第二,用户上下文可透传。这两点做到了,后面无论怎么升级方案,都有了一个可靠的基础。
另外,鉴权框架的日志一定要打全。每次内部调用,至少要记录调用方服务、被调用方服务、用户ID、接口路径、鉴权结果。这些日志在排查问题时价值极高。我甚至建议把鉴权失败的日志单独打到一个文件里,方便做安全审计和异常告警。
最后说一个容易被忽视的点:密钥的轮换。服务间的共享密钥如果长期不换,一旦泄露就是永久性的风险。建议至少每季度轮换一次,并且支持新旧密钥同时有效,给各服务留出升级窗口。轮换过程可以通过配置中心推送,不需要重启服务。
这些经验都是我在实际项目中一点点积累的,有些是踩坑之后才明白的,有些是看到别人的事故报告后提前预防的。希望对你有所帮助。