微服务Feign内部调用权限校验实战:内部Token方案与落地踩坑 1. 微服务内部调用为什么还需要权限校验很多团队在微服务改造初期都会遇到一个很典型的场景订单服务需要调用用户服务查询用户等级或者支付服务需要调用账户服务做余额扣减。这类调用走的是服务内部网络不经过网关看起来“天然可信”于是不少项目直接裸奔——Feign 接口不做任何鉴权谁调到算谁的。我见过不止一个项目在安全扫描时被揪出来内部接口可以越权访问甚至外部通过某种路径能直接打到内部服务上。这个问题的本质是服务间调用不等于可信调用。微服务架构下网络边界变得模糊内部服务可能因为配置疏忽暴露端口也可能被同集群内其他有问题的服务横向调用。一旦内部接口没有权限校验攻击者只要拿到一个低权限服务的调用能力就能顺着调用链拿到高权限数据。所以“Feign 接口内部调用进行权限校验”这个标题核心要解决的就是在服务 A 通过 Feign 调用服务 B 时服务 B 如何确认这次调用是合法的、有权限的而不是随便谁都能调。适合正在做微服务安全加固的后端开发、架构师以及被安全扫描报告折磨过的同学。下面我按实际落地顺序把方案选型、核心实现、踩坑经验完整拆一遍。2. 方案选型内部调用鉴权的几种主流思路2.1 为什么不能简单复用用户 Token最直觉的做法是用户请求进来时带着 Token服务 A 调用服务 B 时把这个 Token 透传过去服务 B 照常做用户级鉴权。这个方案在“用户直接触发”的链路上没问题但有两个硬伤。第一很多内部调用不是用户触发的。比如定时任务扫描订单、消息队列消费后回调服务、服务启动时预热缓存这些场景根本没有用户 Token。第二透传用户 Token 会把用户权限放大到整个调用链服务 B 无法区分“这是用户主动发起的”还是“服务 A 自己发起的”权限模型会变得混乱。我试过在早期项目里透传 Token结果定时任务调用时直接 401最后不得不给定时任务单独开白名单白名单一开就等于没鉴权。所以透传 Token 只适合“用户链路透传”这一种场景不能作为通用方案。2.2 内部服务凭证方案对比真正通用的做法是给服务间调用发一套独立的“内部凭证”。常见的有三种方案核心思路优点缺点固定密钥签名服务间共享一个密钥每次调用对参数时间戳签名实现简单无状态密钥轮换麻烦泄露风险高内部 Token 颁发调用方先向鉴权中心申请短期 Token再带着调业务接口权限可控可细粒度多一次网络往返鉴权中心成瓶颈双向证书服务间用 mTLS 互相验证身份安全性最高运维成本高证书管理复杂对于大多数中小规模团队我推荐内部 Token 颁发 本地缓存的组合。理由很直接固定密钥一旦泄露就是全盘沦陷双向证书的运维门槛又太高而内部 Token 方案在安全性和落地成本之间平衡得最好。下面重点讲这个方案的实现。2.3 鉴权放在 Feign 的哪一层确定用内部 Token 后下一个问题是Token 的申请、携带、校验分别放在哪里。我的经验是分三层申请层调用方在发起 Feign 调用前确保手里有一个有效的内部 Token。这个逻辑封装在 Feign 的RequestInterceptor里最合适对业务代码完全透明。携带层通过 Feign 的拦截器把 Token 塞进请求头比如X-Internal-Token。校验层被调用方用一个统一的拦截器Spring 里就是HandlerInterceptor或OncePerRequestFilter校验 Token校验通过才放行到 Controller。这样分层的好处是业务代码零侵入加解密和 Token 管理都收敛在基础设施层。我见过把 Token 申请逻辑写在每个 Service 方法里的项目后来改一次鉴权规则要动几十个文件非常痛苦。3. 核心实现内部 Token 的申请、携带与校验3.1 鉴权中心的设计要点鉴权中心本身也是一个服务它对外暴露一个“申请内部 Token”的接口。调用方带着自己的服务标识比如serviceId和密钥来换 Token。这里有几个关键设计点。第一服务标识和密钥不能硬编码在代码里。我见过把密钥写在application.yml然后提交到代码仓库的这等于把钥匙挂在门上。正确做法是走配置中心或者环境变量注入不同环境用不同密钥。第二Token 要有明确的权限范围。Token 里应该包含“哪个服务申请的”“能访问哪些接口”“有效期到什么时候”。比如订单服务申请的 Token 只能调用户服务的查询接口不能调用户服务的删除接口。这个权限范围可以用简单的接口白名单也可以用更细的 scope 机制。第三Token 有效期要短。我一般设 5 到 15 分钟配合调用方本地缓存。有效期太长泄露风险大太短又会导致频繁申请。5 分钟是个比较舒服的平衡点调用方在 Token 过期前 1 分钟主动刷新。3.2 Feign 拦截器实现 Token 自动携带Feign 的RequestInterceptor是携带 Token 的最佳位置。核心逻辑是每次请求前检查本地缓存的 Token 是否有效有效就直接用无效或快过期就重新申请。public class InternalAuthInterceptor implements RequestInterceptor { private final InternalTokenClient tokenClient; private final String serviceId; private final String serviceSecret; // 本地缓存实际项目建议用 Caffeine 或 Guava Cache private volatile String cachedToken; private volatile long expireAt; Override public void apply(RequestTemplate template) { String token getValidToken(); template.header(X-Internal-Token, token); template.header(X-Caller-Service, serviceId); } private String getValidToken() { long now System.currentTimeMillis(); // 提前 60 秒刷新避免边界过期 if (cachedToken ! null now expireAt - 60_000) { return cachedToken; } synchronized (this) { if (cachedToken ! null System.currentTimeMillis() expireAt - 60_000) { return cachedToken; } TokenResponse resp tokenClient.apply(serviceId, serviceSecret); cachedToken resp.getToken(); expireAt System.currentTimeMillis() resp.getExpiresIn() * 1000; return cachedToken; } } }这段代码有两个细节值得说。一是双重检查加锁高并发下避免多个线程同时去申请 Token把鉴权中心打挂。二是提前 60 秒刷新因为网络传输和时钟偏差可能导致 Token 在到达服务端时刚好过期提前刷新能规避这个边界问题。3.3 被调用方的统一校验拦截器被调用方需要拦截所有内部接口请求校验 Token 的合法性。用 Spring 的HandlerInterceptor实现如下public class InternalAuthInterceptor implements HandlerInterceptor { private final TokenValidator tokenValidator; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(X-Internal-Token); String caller request.getHeader(X-Caller-Service); if (token null || caller null) { response.setStatus(401); return false; } AuthResult result tokenValidator.validate(token, caller); if (!result.isValid()) { response.setStatus(403); return false; } // 把调用方信息放进上下文方便后续做细粒度权限判断 CallerContext.set(caller, result.getScopes()); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { CallerContext.clear(); } }这里有个容易忽略的点afterCompletion里必须清理上下文。因为线程池会复用线程如果不清理下一个请求可能读到上一个请求的调用方信息造成权限串号。这个坑我在压测时踩过表现为偶发的越权排查了很久才发现是 ThreadLocal 没清理。3.4 Token 的生成与校验算法Token 本身可以用 JWT也可以用简单的加密串。用 JWT 的好处是自包含校验方不需要查库坏处是撤销麻烦。我的建议是短期 Token 用 JWT配合一个黑名单机制处理紧急撤销。JWT 的 payload 里放这几个字段iss签发方固定为鉴权中心标识sub调用方服务标识scopes权限范围比如[user:read, order:read]exp过期时间jtiToken 唯一 ID用于黑名单签名算法用 HS256 就够了因为服务间通信本身在可信网络内不需要 RS256 的非对称开销。密钥长度至少 256 位定期轮换。轮换时新旧密钥并存一段时间避免正在使用的 Token 突然失效。4. 实操落地从零搭建内部鉴权链路4.1 鉴权中心的最小实现鉴权中心的核心接口就一个POST /internal/token入参是serviceId和serviceSecret出参是 Token 和有效期。实现上分三步。第一步校验服务身份。从配置中心或数据库读取该serviceId对应的密钥比对传入的serviceSecret。比对时用常量时间比较避免时序攻击。第二步查询权限范围。根据serviceId查出它能访问的接口列表转成 scopes。第三步签发 JWT设置过期时间返回。public TokenResponse apply(String serviceId, String secret) { ServiceCredential cred credentialRepository.findByServiceId(serviceId); if (cred null || !constantTimeEquals(cred.getSecret(), secret)) { throw new UnauthorizedException(invalid service credential); } ListString scopes permissionRepository.findScopes(serviceId); String token jwtSigner.sign(serviceId, scopes, Duration.ofMinutes(10)); return new TokenResponse(token, 600); }4.2 调用方接入 Feign 的完整配置调用方需要做三件事配置 Feign 拦截器、配置鉴权中心的 Feign 客户端、配置本地缓存。完整配置如下Configuration public class FeignAuthConfig { Bean public InternalAuthInterceptor internalAuthInterceptor( InternalTokenClient tokenClient, Value(${internal.auth.service-id}) String serviceId, Value(${internal.auth.service-secret}) String secret) { return new InternalAuthInterceptor(tokenClient, serviceId, secret); } Bean public InternalTokenClient internalTokenClient() { return Feign.builder() .encoder(new JacksonEncoder()) .decoder(new JacksonDecoder()) .target(InternalTokenClient.class, http://auth-center); } }注意InternalTokenClient自己不能再走InternalAuthInterceptor否则会无限递归申请 Token。实际配置时要么给它单独的 Feign 配置类要么在拦截器里判断目标服务是不是鉴权中心是就跳过。4.3 被调用方的接口权限映射被调用方校验完 Token 后还要判断“这个调用方有没有权限访问当前接口”。最简单的做法是用注解标记接口需要的 scopeRestController public class UserInternalController { GetMapping(/internal/user/{id}) RequireScope(user:read) public UserDTO getUser(PathVariable Long id) { return userService.getById(id); } DeleteMapping(/internal/user/{id}) RequireScope(user:write) public void deleteUser(PathVariable Long id) { userService.delete(id); } }然后在拦截器或 AOP 里读取RequireScope和 Token 里的 scopes 比对。这样权限配置跟着接口走改接口时顺手就改了权限不容易遗漏。4.4 灰度与回滚策略内部鉴权上线最大的风险是某个调用方没配好上线后直接 401业务中断。所以必须灰度。我的做法是分三步走。第一步只记录不拦截。被调用方先上线校验逻辑但校验失败只打日志不返回 401。观察一周把所有未携带 Token 或 Token 非法的调用方找出来。第二步按服务灰度拦截。对已经确认配好的调用方开启拦截其他继续放行。第三步全量拦截。所有调用方都配好后开启全量校验。回滚策略也要提前准备鉴权中心加一个全局开关出问题时一键关闭校验退回到“只记录”模式。这个开关要走配置中心能秒级生效不能靠重新发版。5. 常见问题与排查技巧实录5.1 Token 过期导致的偶发 401这是最常见的问题。表现是大部分请求正常偶尔冒几个 401。原因通常是调用方本地缓存的过期时间和鉴权中心不一致或者提前刷新窗口设得太小。排查时先看 401 的时间分布如果是集中在某个时间点基本就是批量过期。解决办法是把提前刷新窗口从 60 秒调到 120 秒并且给本地缓存加一个随机抖动避免所有实例同时刷新。5.2 时钟偏差引发的签名校验失败如果 Token 校验涉及时间戳服务器之间的时钟偏差会导致校验失败。我遇到过两台机器差 3 分钟结果一台签发的 Token 另一台认为还没生效。解决办法是统一走 NTP 同步并且在校验时留一个容差窗口比如允许 5 分钟的偏差。容差不能太大否则会削弱 Token 的时效性。5.3 线程池复用导致的权限串号前面提过ThreadLocal存调用方信息时如果不在请求结束时清理线程复用会导致下一个请求读到错误的调用方。这个问题的隐蔽性在于它只在并发量上来后才出现本地测试很难复现。排查方法是打印每个请求的调用方信息和线程名看是否有线程处理了不同调用方的请求。解决就是在afterCompletion里无条件清理。5.4 常见问题速查表现象可能原因排查方向解决偶发 401Token 过期边界看 401 时间分布加大提前刷新窗口随机抖动全部 401密钥不匹配比对配置中心密钥统一密钥来源403 权限不足scope 配置缺失查调用方 scopes补充权限映射校验超时鉴权中心瓶颈看鉴权中心 QPS加本地缓存限流权限串号ThreadLocal 未清理打印线程与调用方afterCompletion 清理5.5 几个我踩过的坑第一个坑是把鉴权中心做成了单点。早期所有服务每次调用都去鉴权中心换 Token鉴权中心 QPS 直接爆掉。后来加了本地缓存并且把 Token 有效期从 1 分钟延长到 10 分钟QPS 降了两个数量级。第二个坑是Token 里塞了太多信息。有次把用户的完整权限列表塞进 JWT结果 Token 长度超过 8KBHTTP 头都放不下。内部 Token 只放服务级 scopes 就够了用户级权限不要混进来。第三个坑是忘了给健康检查接口放行。上线后发现 K8s 的存活探针也被拦截了Pod 一直重启。健康检查、监控采集这类接口要单独放行或者用独立的端口。6. 性能与安全的平衡经验内部鉴权一定会带来性能开销关键是怎么把开销控制在可接受范围。我的实测数据是不加缓存时每次调用多一次鉴权中心往返P99 增加 15 到 30 毫秒加了本地缓存后P99 增加不到 1 毫秒基本可以忽略。所以本地缓存是必选项不是可选项。安全方面有几个底线不能破。密钥不能进代码仓库Token 不能永久有效校验失败不能返回具体原因避免给攻击者提供信息日志里不能打印完整 Token。这些看起来是常识但实际项目里每一条我都见过反例。还有一个容易被忽略的点内部 Token 和外部用户 Token 要用不同的请求头。我见过两者都用Authorization的项目结果拦截器分不清是哪种 Token鉴权逻辑一团乱。用X-Internal-Token这种专用头语义清晰也不会和外部鉴权冲突。最后分享一个实用技巧在 Feign 拦截器里加一个开关本地开发时可以关闭 Token 携带直接调本地服务。这样开发时不用起鉴权中心联调效率高很多。这个开关只允许在开发环境生效生产环境强制开启通过配置中心的 profile 隔离。这个方案我们已经在几个项目里跑了一年多中间经历过密钥轮换、鉴权中心扩容、调用方新增等各种情况整体稳定。如果你正准备做内部调用鉴权建议先从“只记录不拦截”开始把调用关系摸清楚再动手比一上来就硬拦截要稳妥得多。