微服务安全边界:网关鉴权与子系统本地鉴权的协作实践
朋友上周找我查一个线上越权问题现象是用户A登录后改一下接口里的某个参数就能看到用户B的订单。我远程看了一眼调用链路第一反应是网关鉴权代码大概没生效。结果网关确实有校验问题恰恰出在订单服务自己完全信任网关没有做子系统本地鉴权。这个案例特别能说明微服务架构里安全边界的真正难点你不能只靠一层守卫。今天想把这个经常被当成二选一的话题讲透——网关鉴权和子系统本地鉴权到底谁负责什么、为什么很多人会踩坑以及一套可以直接抄的落地做法。适合正在做微服务拆分、或者系统已经上线但安全心里没底的团队参考。1. 为什么先要在入口处架一道“大门”网关鉴权能解决什么1.1 从“每个服务写一遍登录”到“统一收口”早期做单体应用时登录校验是写在应用中间件里的所有请求先过一遍拦截器逻辑简单直接。可一旦按微服务拆开第一个问题就来了订单服务需要登录态用户服务需要登录态支付服务也需要登录态。最常见的做法是一个服务写一套鉴权结果每一套的代码风格和漏洞风格都不一样。有人 JWT 验签用错了公钥有人把用户角色放进了前端可改的参数有人根本忘了写。后来大家发现既然所有外部请求都要先经过统一入口那干脆把认证这件事放在入口层收口。这就是网关鉴权最开始的意义让所有外部流量在进入业务服务之前先解决“你是谁”的问题。网关层做统一的登录校验、token 验签、用户状态检查业务服务不需要再关心 token 怎么解析、签名怎么验证、过期时间怎么判断。1.2 网关鉴权的四个核心价值第一认证逻辑从 N 份收敛成 1 份出问题只需要修一个地方。第二网关可以集中处理封禁、黑名单、限流这些“门口守卫”的动作比如某个用户被冻结了直接在网关层拦掉不需要所有服务都去查一遍用户状态。第三审计日志可以在网关统一留痕哪个 IP 在什么时间点了什么接口排查问题的时候不用去十几个服务的日志里翻。第四网关天然做了一层网络隔离后端服务不直接暴露公网地址攻击面小了很多。我记得刚引入网关那阵子团队最直观的感受是新服务上线时不用再写一套登录拦截了复制一下网关配置就行这确实是微服务架构里性价比非常高的一次改造。1.3 但网关鉴权本质上是“粗粒度”的这里必须把话说清楚网关鉴权只回答“你是不是合法用户”也就是认证以及非常基础的“你能不能进这个服务”也就是粗粒度授权。它不知道你提交的订单号是不是你自己的不知道这个商品是否属于你所在的部门更不知道一条数据能不能被当前用户查看。这些判断需要业务上下文是典型的数据级权限问题必须等请求到达业务代码后才能回答。很多人把网关当成了万能安全层觉得网关验过就万事大吉。这是安全边界设计里最危险的心态。真正的问题往往出现在“网关验证通过之后”比如服务间调用绕过了网关、业务逻辑里直接用前端传的参数做数据归属判断。下一节我们展开说为什么子系统本地鉴权不能省。2. 子系统本地鉴权网关验过之后子系统为什么还要自证2.1 东西向流量不总是经过网关微服务架构里的流量分两大类一类是外部进来的请求通常叫南向流量会经过网关另一类是服务之间的内部调用比如订单服务要查用户信息叫东西向流量往往直接走注册中心发现的服务地址根本不经过网关。这就有个大问题如果所有鉴权都放在网关上那么服务之间互相调用时怎么鉴权常见的做法是服务间调用直接带上内部凭证甚至直接裸调。一旦某个服务的地址被泄露、或者测试环境绕过网关直连了业务服务整条链路就等于没有了门禁。大家总觉得“内网不会有人攻击”可实际上内网出问题的案例并不少一次运维脚本误操作、一个服务被反序列化漏洞打穿都可能演变成横向移动。所以子系统本地鉴权的意义在于即使流量没走网关服务自己也有能力识别调用者身份。你可以不重复做完整的登录校验但至少要能判断“这次请求是从可信内部链路过来的”并且能拿到“当前用户是谁”的上下文。2.2 数据级权限必须依赖业务上下文网关可以把用户 ID、角色都塞进请求头传给下游但下游怎么用这些信息网关管不着。我在前面提到的越权漏洞根因就是订单服务在查询接口里直接信任请求参数中的 userId而不是从网关透传的上下文里取当前登录用户。举个例子一个查询订单详情的接口正确做法是强制从 AuthContext 里拿当前用户的 ID再去拼 SQL 查询条件。如果把 ID 参数暴露成可传入的字段用户A把参数改成用户B的ID就可能查到别人的订单。网关在这个场景下帮不上忙因为它根本不知道订单表里的 owner_id 和当前请求用户之间是什么关系。这类数据级权限的校验只能放在子系统里因为只有业务服务知道数据表结构、知道字段含义、知道哪些操作是允许的。网关层面最多做到接口级别的角色控制比如“只有管理员能访问这个接口”但要细化到“只能访问自己创建的订单”就必须在子系统本地做。2.3 纵深防御防止“信任崩塌”安全设计里有个概念叫纵深防御意思是不要依赖单一防线。在微服务安全边界中网关是第一道子系统本地鉴权是第二道。这么做不是重复劳动而是防御“信任崩塌”的意外情况。比如网关的 JWT 密钥泄露了攻击者可以自己造一个合法 token 穿过网关比如网关代码有漏洞被绕过再比如运维配置失误把一个内部服务暴露到了公网。如果子系统完全不做校验那这些情况就等于全线失守。反过来即使网关被绕过子系统本地还有一道校验能拦住一部分攻击多一层防护就多一层容错。我见过一个极端案例某团队把角色判断写在网关里面业务服务接到请求后完全信任网关传来的角色字段。后来网关有一次配置发布出错所有请求都没做角色校验相当于把管理员接口全部打开了。如果他们子系统里哪怕有一个轻量的角色注解也不至于线上裸奔一整天。3. 网关与子系统的边界如何划分对比与选择策略3.1 职责差异对照很多团队争论“到底该用网关鉴权还是本地鉴权”本质上是没理清两者的职责边界。先看对比表再谈选择策略。对比维度网关鉴权子系统本地鉴权处理位置请求进入业务服务之前请求到达业务代码时核心职责认证你是谁、基础准入授权你能干什么、数据归属校验权限粒度接口级、服务级粗粒度接口级到数据行/字段级细粒度典型实现JWT验签、OAuth2网关过滤器、黑白名单拦截器/切面解析AuthContext、RBAC权限注解、数据权限SQL改写性能开销集中一次影响所有南向流量分散在各服务每个请求都会执行故障影响网关挂掉外部流量全部进不来单个服务故障影响面相对可控适用场景外部用户认证、统一入口控制业务权限、数据隔离、服务间调用安全网关鉴权适合做“门槛控制”子系统本地鉴权适合做“房间内控制”。两者不是替代关系而是上下游的协作关系。网关放行之后子系统要基于可信的用户上下文继续完成业务级的权限判断。3.2 什么时候只做网关鉴权也行有一种情况可以优先依赖网关整套系统是封闭的、所有对外流量都强制走网关、服务间调用量少、并且业务权限只有“能访问”和“不能访问”两种状态没有复杂的数据归属概念。比如一个内部后台管理系统数据对所有登录用户可见权限差异就是“管理员可以删数据普通员工只能看数据”这种情况网关层做角色判断基本够用。还有个实际条件团队规模小、服务数量不多、迭代节奏快。如果每个服务都做一套完整鉴权反而会拖慢开发效率投入产出比不划算。我见过不少创业团队这么干先把网关做扎实每个服务只做一个轻量级的用户上下文解析后面权限复杂了再逐步细化。3.3 什么时候必须两层都做一旦出现以下任何一个信号就要把子系统本地鉴权纳入必须项存在服务间调用而且调用链条里需要传递用户身份。业务数据有强归属关系比如订单属于用户、报告属于企业需要防止跨用户越权。有定时任务、MQ消费者、开放API回调等非HTTP入口这些入口不经过网关必须自己处理身份识别。涉及支付、医疗、个人信息等敏感数据合规要求也更严格。团队规模变大服务由不同小组维护安全规范无法靠口头约束。最简单的判断方法画一张流量拓扑图把所有能进入服务的入口都标出来凡是“不经过网关”的入口都必须有本地鉴权兜底。只要这张图里存在一个直连入口子系统本地鉴权就不是可选项而是必选项。4. 一套可落地的“网关鉴权子系统本地鉴权”实施方案4.1 整体调用链路设计用一套电商微服务 demo 来演示组件包括 Spring Cloud Gateway 作为统一入口、订单服务和用户服务作为业务系统、Nacos 做注册发现登录态用 JWTRS256 签名。核心调用链路是客户端先登录换取 token然后带着 token 访问订单接口网关验签之后把用户上下文写入请求头再转发给订单服务订单服务从请求头解析用户上下文做接口级权限和数据归属校验最后访问数据库。之所以选 JWT是因为它无状态、验签效率高适合网关这种高性能节点做快速校验。RS256 的好处是密钥分离网关持有公钥验签签发 token 的服务持有私钥即使某个业务服务被攻破拿不到私钥也无法伪造 token。4.2 网关侧的关键实现网关侧的核心是写一个全局过滤器在所有路由转发之前执行。关键动作有三步第一步判断当前路径是否在白名单里比如登录、刷新 token 和回调接口直接放行第二步从请求头拿到 Bearer token用公钥验签并解析出用户 ID、角色第三步把解析结果写入下游请求头同时必须清理掉客户端自定义的同名请求头。这个第三步是整个链路安全的关键。如果网关直接把客户端传进来的X-User-Id往下游透传攻击者完全可以伪造别人身份。所以网关在处理时一定先把可能伪造的X-User-*请求头全部删除再写入从 token 中解析出的可信值。代码逻辑类似这样Component public class AuthGlobalFilter implements GlobalFilter, Ordered { Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); // 白名单路径直接放行 if (SecurityWhitelist.match(request.getPath().value())) { return chain.filter(exchange); } String token resolveToken(request); if (token null) { return unauthorized(exchange); } try { JwsClaims jws JwtUtils.parse(token); // RS256 公钥验签 Claims claims jws.getBody(); // 关键先移除客户端伪造的同名请求头 ServerHttpRequest mutatedRequest request.mutate() .headers(headers - { headers.remove(X-User-Id); headers.remove(X-User-Roles); }) .header(X-User-Id, claims.get(userId, String.class)) .header(X-User-Roles, claims.get(roles, String.class)) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (JwtException ex) { return unauthorized(exchange); } } }4.3 子系统侧的本地鉴权与数据权限校验订单服务收到请求后不能直接信任X-User-Id作为业务参数使用而是要把它解析成内部的 AuthContext后续接口统一从 AuthContext 取当前用户。解析方式可以写一个OncePerRequestFilter读取请求头后放入 ThreadLocal请求结束时清理避免线程池复用导致串号。接口级权限可以直接用注解方式比如“只有管理员能调用的接口”做一次角色校验数据级权限放在业务层。举个例子创建订单接口不允许客户端传 userId订单表里的 owner_id 必须强制取 AuthContext 里的当前用户 ID查询订单列表时动态拼 SQL 也要加上owner_id 当前用户ID的条件。Service public class OrderService { PreAuthorize(hasRole(USER)) // 接口级登录用户可调用 public Order createOrder(CreateOrderRequest request) { Long currentUserId AuthContext.getUserId(); // 从网关透传头解析必须取服务端上下文 // 关键userId 绝不取 request.getUserId() return orderRepository.save(Order.builder() .ownerId(currentUserId) .productId(request.getProductId()) .amount(request.getAmount()) .build()); } PreAuthorize(hasRole(USER)) public Order getOrderDetail(Long orderId) { Order order orderRepository.findById(orderId); if (order null) { throw new NotFoundException(); } // 数据级权限只有订单归属人本人或管理员能查看 if (!order.getOwnerId().equals(AuthContext.getUserId()) !AuthContext.hasRole(ADMIN)) { throw new ForbiddenException(); } return order; } }这里补充一个重要细节服务间调用也需要传递用户上下文。订单服务要查用户详情时不能把 ThreadLocal 里的上下文丢掉而是通过 OpenFeign 或 RestTemplate 的拦截器把X-User-Id和X-User-Roles自动透传给下游用户服务。否则链路一旦超过两个服务第二个服务就不知道当前操作者是谁了。如果是纯后台定时任务或 MQ 消费者触发没有用户上下文这时要主动标记为“系统内部调用”使用独立的高权限内部身份并确保这类入口不会被外部利用。4.4 几个必须考虑的参数与前提网关和子系统之间默认是可信内网这个前提必须显式写进架构文档业务服务的端口不能暴露到公网只能通过网关访问如果内网还有更复杂的网络环境建议在服务间加 mTLS 双向认证或内部凭证校验防止中间人攻击。JWT 过期时间也要设计合理。我一般建议 access token 设置在 15 分钟到 2 小时之间refresh token 可以到 7 天业务敏感度越高越要缩短。角色和权限点如果很多不建议全部塞进 JWT否则网关拼装出来的一堆请求头可能超过 Web 容器默认 Header 大小限制导致下游报 431。角色少放详细权限点等业务需要时再查库或查缓存。5. 线上常见问题与排查技巧实录5.1 高频问题速查表现象根因解决方案用户A能看到用户B的数据子系统直接用请求参数里的用户ID做查询条件强制从 AuthContext 取当前用户动态 SQL 增加归属过滤请求头X-User-Id被伪造网关没有清理同名头导致客户端自定义头透传到下游网关统一 remove 后再注入可信值拒绝任何直接透传服务间调用后用户身份丢失OpenFeign/RestTemplate 未透传上下文Headers增加 Feign 拦截器或 RestTemplate ClientHttpRequestInterceptor 统一透传用户改角色后旧 token 仍走通JWT 无状态权限变更不能即时生效缩短 token 时效敏感接口查一次最新角色服务端维护权限版本号网关验签通过但下游报 431角色多、权限点多Header 超过容器限制只传用户ID和必要角色权限列表走缓存查询或启用 compressing网关密钥泄露导致伪造token私钥或公钥保管不当、轮换不及时使用 KMS/配置中心管理密钥支持在线轮换泄露后立刻重置并短期拒掉旧token绕过网关直连服务IP却鉴权成功服务本地没有身份识别裸奔所有服务必须能校验内部凭证网络层限制服务端口访问来源5.2 排查顺序与定位思路遇到越权或鉴权相关事故我的排查顺序基本固定先看流量入口请求有没有经过网关、有没有绕过网关直连再看 Header网关有没有正确写入用户上下文、业务服务有没有按可信 Header 取值然后看日志里的权限判断是角色不满足、数据归属不匹配还是根本就没做判断最后看权限模型本身是不是默认配置太宽松。有一次我们排查一个“普通用户能访问管理员接口”的问题先以为网关角色判断写错了结果最后定位到是网关 Router 配置里把一个接口漏加了鉴权过滤器路径匹配规则写成了精确匹配而不是前缀匹配。这种问题靠代码 review 很难发现最好给网关层做一个自检脚本定时模拟调用几个关键接口验证鉴权是否真的生效。5.3 我们团队沉淀的三条纪律第一所有对外流量入口必须收敛到网关业务服务不允许直接暴露公网端口这条写进发布流程上线时自动检查安全组配置。第二所有内部请求 Header 必须遵守“统一命名、网关注入、服务端解析”的规范写进接口文档任何服务不得信任外部自发传入的身份字段。第三涉及用户数据的接口必须强制从服务端上下文取用户ID代码 review 时这条作为一票否决项发现一次直接打回。这些纪律听起来简单但真正做到位需要持续投入。尤其是团队扩容之后新的服务容易忘记这些约定。我建议把安全边界检查做成自动化平台能力而不是靠每个人自觉。至少要让每个服务的鉴权组件来自统一的基础库避免各写各的。