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:过期时间jti:Token 唯一 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"); } List<String> 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 后,还要判断“这个调用方有没有权限访问当前接口”。最简单的做法是用注解标记接口需要的 scope:
@RestController 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 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决 |
|---|---|---|---|
| 偶发 401 | Token 过期边界 | 看 401 时间分布 | 加大提前刷新窗口+随机抖动 |
| 全部 401 | 密钥不匹配 | 比对配置中心密钥 | 统一密钥来源 |
| 403 权限不足 | scope 配置缺失 | 查调用方 scopes | 补充权限映射 |
| 校验超时 | 鉴权中心瓶颈 | 看鉴权中心 QPS | 加本地缓存+限流 |
| 权限串号 | ThreadLocal 未清理 | 打印线程与调用方 | afterCompletion 清理 |
5.5 几个我踩过的坑
第一个坑是把鉴权中心做成了单点。早期所有服务每次调用都去鉴权中心换 Token,鉴权中心 QPS 直接爆掉。后来加了本地缓存,并且把 Token 有效期从 1 分钟延长到 10 分钟,QPS 降了两个数量级。
第二个坑是Token 里塞了太多信息。有次把用户的完整权限列表塞进 JWT,结果 Token 长度超过 8KB,HTTP 头都放不下。内部 Token 只放服务级 scopes 就够了,用户级权限不要混进来。
第三个坑是忘了给健康检查接口放行。上线后发现 K8s 的存活探针也被拦截了,Pod 一直重启。健康检查、监控采集这类接口要单独放行,或者用独立的端口。
6. 性能与安全的平衡经验
内部鉴权一定会带来性能开销,关键是怎么把开销控制在可接受范围。我的实测数据是:不加缓存时,每次调用多一次鉴权中心往返,P99 增加 15 到 30 毫秒;加了本地缓存后,P99 增加不到 1 毫秒,基本可以忽略。所以本地缓存是必选项,不是可选项。
安全方面,有几个底线不能破。密钥不能进代码仓库,Token 不能永久有效,校验失败不能返回具体原因(避免给攻击者提供信息),日志里不能打印完整 Token。这些看起来是常识,但实际项目里每一条我都见过反例。
还有一个容易被忽略的点:内部 Token 和外部用户 Token 要用不同的请求头。我见过两者都用Authorization的项目,结果拦截器分不清是哪种 Token,鉴权逻辑一团乱。用X-Internal-Token这种专用头,语义清晰,也不会和外部鉴权冲突。
最后分享一个实用技巧:在 Feign 拦截器里加一个开关,本地开发时可以关闭 Token 携带,直接调本地服务。这样开发时不用起鉴权中心,联调效率高很多。这个开关只允许在开发环境生效,生产环境强制开启,通过配置中心的 profile 隔离。
这个方案我们已经在几个项目里跑了一年多,中间经历过密钥轮换、鉴权中心扩容、调用方新增等各种情况,整体稳定。如果你正准备做内部调用鉴权,建议先从“只记录不拦截”开始,把调用关系摸清楚再动手,比一上来就硬拦截要稳妥得多。