qinghuan
← Spring Cloud / 全部笔记

单元 86 / Spring Cloud

P0 · 必会主线L2 · 进阶

网关认证:验证身份之后还要守住哪些入口

先验证令牌确实适用于本系统,再可靠传递身份,并在下游继续检查资源权限和绕过网关的入口。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能明确网关与业务服务各自的安全职责,并验证绕过网关时仍有资源保护。

学习理由、准备要求与知识点

为什么学它

网关验证身份并不能替代服务内部的资源授权,旁路入口和伪造请求头必须纳入设计。

先读这些内容

知识点清单

  • 路由过滤器
  • JWT 校验
  • issuer 与 audience
  • TokenRelay
  • 身份请求头
  • 服务侧授权
  • 旁路入口
  • 最小暴露

运行前先确认以 Gateway Server WebFlux 4.3.x 与 Spring Security 6.5 为学习主线,源码固定 6.5.0。Server MVC 使用不同过滤器模型。Java 17 算法只计算已验证身份的角色权限,不实现 JWT 密码学验证或资源归属校验。

01先弄懂它是什么

网关把外部请求集中接进来,可以分发请求、检查身份、做粗略权限控制和限制流量,但通常不知道每笔订单属于谁。认证回答调用者是谁,授权回答他能不能操作这条资源。用户登录成功,仍可能无权改别人订单;网关核验通过以后,下游还要按对象、租户和状态检查业务权限。

JWT 能解码出内容,不代表内容可信。要按允许的算法和可信密钥验证签名,再检查发行方、有效时间及应用要求的接收方,还要明确令牌用途和权限怎样对应。其他系统签发的有效 JWT,也不应随意用于当前服务。令牌核验应交给成熟安全组件,不能拿 Base64 解码代替认证。

身份传播也有信任边界。客户端自己带来的 X-User-Id 不是证据,网关若添加内部身份信息,必须先清理伪造输入并通过受保护链路传递,下游要验证调用来源或重新验证令牌。TokenRelay 的职责是转发适用的访问令牌,不会自动给令牌扩大受众或给用户授予订单权限。直接绕过网关的入口同样需要纳入部署与授权设计。

文中这几个词,先认一认

认证 Authentication
根据有效凭据建立可信身份,确认这次请求代表谁。
授权 Authorization
结合可信身份、目标资源、请求动作和规则,判断这一次操作是否允许。
Reactor Context
在响应式调用过程中传递关联信息的机制,适合请求可能切换线程的情况。
TokenRelay
把适用的 OAuth2 访问令牌转发给下游,不会自动增加权限、扩大接收范围或完成令牌交换。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

这里有两层过滤流程。Spring Security 通过 WebFlux 的 WebFilter 处理身份认证和访问权限;Gateway 匹配到路由后,才把全局过滤器 GlobalFilter 与该路由的 GatewayFilter 组合起来。

后一条链中,请求进入时按优先顺序执行,响应返回时再反向经过对应的后续逻辑。理解顺序时,要先问自己正在看哪一层,再看排序值。不能因为某个 GlobalFilter 的顺序很靠前,就认为它会在所有请求的所有安全检查之前运行。

响应式代码描述的是订阅后执行的流水线。读取数据库、调用远端鉴权或读取大文件若直接阻塞事件循环,会拖慢共享线程上的其它连接。确需接入阻塞边界时,应采用受控执行器、并发上限和超时,并理解切换线程后普通 ThreadLocal 可能不再代表当前请求。认证信息应沿受支持的安全上下文传播,而不是在某线程存一个用户对象后期待它永远有效。

对订单修改可分两道判断:网关检查访问令牌和 orders:write 之类的能力,下游结合可信 userId、tenantId、订单 ownerId 与订单状态作最终授权。数据查询应在数据库条件中限定租户和归属,避免先返回整条敏感对象再在前端隐藏。批量操作要逐项或用统一约束核对全部对象,不能只验证列表第一项就放行整批。

正在加载流程图…

身份验证、凭据传播和资源授权各有独立裁决点,网关放行不等于任意对象都可操作。
查看流程图源码(Mermaid)
flowchart TD
 R["外部请求与不可信请求头"] --> A["安全链验证令牌"]
 A --> V["签名、发行方、时间、受众与用途"]
 V --> G["请求级权限判断"]
 G --> C["清理伪造内部身份头"]
 C --> P["按受保护身份传播契约转发"]
 P --> D["下游验证凭据或可信调用来源"]
 D --> O["租户、资源归属、业务状态授权"]
 O --> B["执行条件更新"]
 A --> X["认证失败响应"]
 O --> Y["授权失败响应"]

按顺序看,每一步做了什么

  1. 定义可信发行方、受众、算法和权限映射契约。
  2. 安全链验证凭据,按路由判断所需粗粒度能力。
  3. 去除外部伪造的内部身份头,按可信链路转发。
  4. 下游从可信上下文读取身份并限定资源查询范围。
  5. 业务更新再次使用条件约束,记录脱敏后的授权结果与关联 ID。

03对照实现代码

AuthorizationWebFilter 把身份与请求交给授权管理器 (新标签页)↗

Spring Security 6.5.0 · AuthorizationWebFilter#filter

原创同步 Java 教学模型,仅映射“取得可信身份、执行策略、成功后继续链路”的关系。真实源码使用 Reactor Context、Mono 与 ReactiveAuthorizationManager.verify;模型不执行真实认证。

核心机制代码 · java
import java.util.*;
import java.util.function.*;

final class AuthorizationFlowModel {
    record Principal(String user, Set<String> authorities) {}
    static void protectedRoute(Optional<Principal> authenticated,
                               Predicate<Principal> policy, Runnable next) {
        Principal principal = authenticated.orElseThrow(
            () -> new SecurityException("authentication required")); // 受保护路由缺身份拒绝。
        if (!policy.test(principal))
            throw new SecurityException("access denied"); // 有身份仍需策略同意。
        next.run(); // 只在前两关通过后进入后续链路。
    }
}

这段实现先从 ReactiveSecurityContextHolder 取出 Authentication,再交给 authorizationManager.verify,结合当前 ServerWebExchange 判断是否允许请求继续。这里不负责执行 JWT 签名算法,也不会自动理解全部业务角色规则。要查清身份为什么可信、权限具体怎么判断,需要继续看前面的认证组件和配置的授权管理器。

verify 返回 Mono<Void>,用“正常完成”表示校验通过,用“出错”表示失败,不需要再返回一个业务值。因此,没有值但正常完成,并不等于没有作出授权判断。AccessDeniedException 会沿错误处理路径继续传递,日志只是记录结果,不能代替判断。读 switchIfEmpty 时,要看它作用于链条中的哪一步,分清没有元素、正常完成和报错各自代表什么。

普通 ThreadLocal 依赖线程,而响应式请求可能在不同线程继续。真实实现使用响应式安全上下文,说明身份应与订阅链条关联,不能仅靠某个线程曾经处理过这个用户。自定义过滤器若跨执行器或自行 subscribe,可能丢失上下文或脱离正常响应生命周期,既影响授权也影响错误处理。

模型将缺少身份固定为拒绝,是针对受保护路由的教学约定。实际公开端点、匿名身份与多条安全链要通过明确配置表达,并对匹配顺序做测试。不能把模型直接复制为“所有路径都一样”的框架替代物;源码理解的重点是验证由哪个策略作裁决、失败是否阻止后续执行,以及资源级规则是否在正确层出现。

代码先调用谁,再调用谁

  1. 认证链验证凭据并建立响应式安全上下文
  2. AuthorizationWebFilter 取得 Authentication 流
  3. ReactiveAuthorizationManager.verify 结合当前请求判断
  4. 拒绝结果进入安全异常处理;成功完成后继续 WebFilterChain
  5. Gateway 转发,下游继续执行资源与业务状态授权

04动手写一个例子

角色继承图上的权限闭包与显式拒绝

给定用户已验证角色集合、角色继承有向图及各角色权限集合,计算继承后的有效权限,再移除显式拒绝集合。继承图允许错误配置的环,算法必须终止;未知角色默认不产生权限。结果按权限名排序。

输入 1
admin继承editor,editor继承reader;权限依次delete,write,read;拒绝delete
输出 1
[read,write]
输入 2
a继承b,b继承a;a有read
输出 2
[read],遍历终止

角色继承是一个有向可达性问题。用户具有 admin,就需要检查从 admin 沿父角色边可以到达的全部角色,再收集它们的权限。使用队列做广度优先遍历,visited 记录已经处理过的角色;没有这个集合,配置中的环会造成无限遍历,多条路径到同一角色也会重复收集。

先合并允许权限,再统一移除显式拒绝,定义拒绝优先的规则。这样无论某个权限被多少个角色授予,只要它在拒绝集合中就不能出现在结果里。真实权限系统可能采用不同策略组合语义,所以必须在接口契约中固定规则,不能让遍历顺序决定“最后遇到允许还是拒绝”。

角色闭包只计算粗粒度能力,不包含订单归属。得到 orders:write 后,仍应在具体订单请求中判断租户、owner、状态等属性。用一个返回 Set 的算法替代全部授权,会把“允许操作某类资源”错误扩大为“允许操作任意实例”。算法测试因此专门包含未知角色与空身份,确保没有配置就不会凭空获得能力。

为什么这样写是对的visited 中每个角色都是从用户初始角色可达的;每个角色最多展开一次。输出是所有可达角色权限的并集减去显式拒绝集合。

时间 O(V+E+G+U log U)额外空间 O(V+E+A),另有 O(U) 结果空间

V、E 是可达角色与继承边数,G 是这些角色的权限条目数,A 是移除显式拒绝前的唯一权限数,U 是最终有效权限数。visited 在出队时去重,每个角色只展开一次,但同一角色仍可被重复入队,队列峰值需要计入 O(E)。中间权限集合即使最终被全部拒绝也占 O(A)。散列集合通常均摊常数;输出排序为 O(U log U),长名称还要计入字符处理成本。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 38 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;

public class Main {
    static List<String> effective(Set<String> initial,
            Map<String, Set<String>> parents, Map<String, Set<String>> grants,
            Set<String> denied) {
        Objects.requireNonNull(initial); Objects.requireNonNull(parents);
        Objects.requireNonNull(grants); Objects.requireNonNull(denied);
        ArrayDeque<String> queue = new ArrayDeque<>(initial);
        Set<String> visited = new HashSet<>(), permissions = new HashSet<>();
        while (!queue.isEmpty()) {
            String role = queue.remove();
            if (!visited.add(role)) continue; // 环与菱形继承都只处理一次。
            permissions.addAll(grants.getOrDefault(role, Set.of()));
            queue.addAll(parents.getOrDefault(role, Set.of())); // 沿继承边继续。
        }
        permissions.removeAll(denied); // 明确拒绝优先,不依赖角色遍历顺序。
        List<String> result = new ArrayList<>(permissions);
        Collections.sort(result);
        return List.copyOf(result); // 不允许调用者改写已计算的权限快照。
    }
    static void eq(Object expected, Object actual) {
        if (!Objects.equals(expected, actual)) throw new AssertionError(actual);
    }
    public static void main(String[] args) {
        var parents = Map.of("admin",Set.of("editor"),"editor",Set.of("reader"));
        var grants = Map.of("admin",Set.of("delete"),"editor",Set.of("write"),
                "reader",Set.of("read"));
        eq(List.of("read","write"), effective(Set.of("admin"),parents,grants,Set.of("delete")));
        eq(List.of("read"), effective(Set.of("reader"),parents,grants,Set.of()));
        eq(List.of(), effective(Set.of("unknown"),parents,grants,Set.of()));
        eq(List.of(), effective(Set.of(),parents,grants,Set.of()));
        eq(List.of("read"), effective(Set.of("a"),
            Map.of("a",Set.of("b"),"b",Set.of("a")),Map.of("a",Set.of("read")),Set.of()));
        eq(List.of(), effective(Set.of("reader"),parents,grants,Set.of("read")));
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 继承图有环时必须终止,生产配置还应报告环作为治理问题。
  • 未知角色不自动映射成管理员或默认高权限。
  • 显式拒绝覆盖来自多条继承路径的允许。
  • 权限快照不包含资源归属,也不替代令牌验证。
进阶选读 · 权限撤销、缓存版本与属性访问控制

场景用户离职后权限应尽快撤销,但网关与下游都缓存了角色闭包,令牌又仍未到期。

做法为策略与用户授权记录版本,缓存键包含租户、主体和策略版本;通过失效通知与有界 TTL 控制陈旧窗口。高风险操作读取权威状态或校验撤销版本,再结合资源属性作 ABAC 判断。策略更新采用不可变快照,避免一次请求混用新旧规则。

复杂度缓存命中可近似常数读取,但撤销传播、权威查询和角色闭包重算仍有成本;任意属性策略的复杂度取决于表达式与数据访问,不能一概称为 O(1)。

好处与代价更短陈旧窗口增加权威服务依赖和延迟;更长缓存提高可用性却延迟撤销。应按操作风险明确允许窗口与故障时拒绝或降级策略。

05项目里怎么用

网关把什么交给下游、下游相信哪些信息,需要提前约定。下游若接收原访问令牌,应检查令牌是否发给自己使用,以及权限是否够用;若使用内部服务凭据,则要验证调用服务的身份,并保证传来的用户信息没有被篡改、没有超出允许范围。转发令牌不会自动变成令牌交换,也不会增加权限。管理和调试入口同样要做授权检查,不能仅凭地址在内网就信任来访请求。

JWT 验证应使用成熟组件并固定可信配置,及时处理密钥轮换、时钟偏差和未知 key id。受众校验往往需要应用显式配置,不能假定默认签名校验已经覆盖所有业务声明。日志记录授权结果、拒绝原因类别和关联 ID 即可,避免打印完整令牌、密码或敏感请求体。错误响应也不应泄漏其它租户资源的详细存在性。

验收用多类请求覆盖边界:无令牌、过期令牌、错误发行方或受众、权限不足、伪造内部身份头、合法身份访问他人资源,以及批量列表中的混合归属。每次同时核对响应与数据库是否无副作用。测试环境中从下游直连入口发起同类请求,验证部署边界与应用授权都符合设计,不能只测浏览器隐藏了哪些按钮。

容易踩的坑

  • 仅解码 JWT 就信任里面的用户与角色。
  • 未清理客户端提供的内部身份头。
  • 有路由级权限就允许任意订单或任意租户。
  • 在 WebFlux 事件循环中直接执行阻塞鉴权查询。
  • 把 TokenRelay 当成自动受众转换或资源授权。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
网关集中认证,下游资源授权统一入口策略,业务规则留在掌握数据的一层。需要清晰身份传播契约与下游保护。多数服务化系统的职责分层。
每个下游独立验证访问令牌降低对单个入口信任的依赖。重复验证与密钥、策略配置成本。存在多入口或较强服务边界要求。
内部凭据加受保护用户上下文可限制服务间调用范围。签发、轮换、审计与上下文防篡改复杂。有成熟服务身份基础设施。
集中在线授权查询撤销与策略更新更及时。增加延迟和依赖,需要有界缓存与故障策略。高风险操作或动态属性规则较多。

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01验证 JWT 签名后为何仍要检查受众?

展开参考答案与推导

同一发行方可能给多个应用签发令牌,签名可信不代表当前服务就是允许接收和使用它的对象。

02角色图遍历为何必须 visited?

展开参考答案与推导

防止环无限循环和菱形继承重复展开,保证每个可达角色只处理一次。

03TokenRelay 能否代替订单归属检查?

展开参考答案与推导

不能。转发的是凭据,是否允许操作当前订单仍需结合可信身份与数据库业务属性。

换个情况,还会处理吗

01外部传 X-User-Id=管理员,系统该如何处理?

展开参考答案与推导

不能直接信任,应在入口移除或覆盖不可信内部头,下游只接受经过验证的身份契约,并以授权测试证明无越权副作用。

02网关 CPU 不高却所有请求延迟上涨,可能与鉴权有关吗?

展开参考答案与推导

可能事件循环被阻塞数据库或网络等待占住;检查线程与调用耗时,将必要阻塞工作放到受控边界并限制并发。

03用户角色已撤销,旧令牌仍能访问,怎样定位?

展开参考答案与推导

核对令牌有效期、角色来源、授权缓存及策略版本,按风险定义撤销窗口并验证失效传播,不能只改前端菜单。

写代码试一试

01增加菱形继承的去重测试。

输入 / 条件
admin继承a,b,a与b都继承reader;reader有read
输出 / 预期
[read]

提示 同一角色可能从多条路径到达。

展开参考答案与推导

验证 visited 使 reader 只展开一次,输出 Set 防止权限重复,加入显式deny后结果为空。

02给访问订单函数加入租户与归属条件。

输入 / 条件
用户tenantA/u1,订单tenantB/u1
输出 / 预期
拒绝

提示 用户名相同不表示同租户。

展开参考答案与推导

查询同时限定 tenantId 与 ownerId,只有可信上下文提供这两项,批量请求全部对象都必须满足。

03为授权快照增加版本键。

输入 / 条件
同用户策略v1允许write,v2撤销write
输出 / 预期
v2请求不得复用v1结果

提示 缓存键要包含策略版本。

展开参考答案与推导

构造不可变缓存键(tenant,user,version),切换策略后用新版本查找并重算;同时设计旧缓存清理和撤销通知。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能明确网关与业务服务各自的安全职责,并验证绕过网关时仍有资源保护。

本篇重点加练

令牌的信任范围和资源级授权必须分开验证,跨入口或跨租户时尤其容易混淆。

  • issuer 与 audience
  • 服务侧授权

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    同一路由依次有路径改写、令牌解析和鉴权过滤器。构造一个不存在令牌及一个损坏令牌的请求,标明在哪一步生成响应以及下游是否会执行。

    提交内容
    过滤器时序与请求记录
    怎样算完成
    • 注明过滤器顺序
    • 覆盖异常和正常短路

    练到的知识点路由过滤器 · JWT 校验

  2. 作业 02原理问答

    公司有管理端与开放 API 两类受众,用户令牌来自同一发行方。评审把管理端令牌直接转发到开放服务的约定。

    提交内容
    令牌声明与信任关系表
    怎样算完成
    • 区分 issuer、audience 和转发行为
    • 说明服务对令牌的预期

    练到的知识点issuer 与 audience · TokenRelay

  3. 作业 03原理问答

    一个发行者给移动端与批处理任务签发令牌,二者权限名称相同但用途不同。建立受众、客户端和主体三种身份的区分。

    提交内容
    声明与信任关系图
    怎样算完成
    • 不把权限字符串当作受众
    • 注明多受众令牌的业务约定

    练到的知识点issuer 与 audience

综合应用

  1. 作业 04面试场景

    请求经过公网网关和内部代理,两处都可能添加身份头。为订单读取制定身份来源优先规则及对象授权证据。

    提交内容
    请求头契约与授权矩阵
    怎样算完成
    • 包含同名头的重复值
    • 检查服务侧租户和对象归属

    练到的知识点身份请求头 · 服务侧授权

  2. 作业 05设计评审

    部署后开发端口仍能直接访问业务服务。列出网关、业务服务与管理入口应暴露的功能,并设计本地入口审查。

    提交内容
    网络入口图与检查表
    怎样算完成
    • 包含健康和管理端点
    • 区分网络限制与业务授权

    练到的知识点旁路入口 · 最小暴露

  3. 作业 06编程实践

    搭建仅回环可访问的网关与桩服务,用测试令牌和伪造身份头执行一组正反请求,记录每一层实际接收的身份信息。

    提交内容
    本地工程与请求日志
    怎样算完成
    • 不得使用真实生产令牌
    • 覆盖网关路径和受控直连路径

    练到的知识点路由过滤器 · JWT 校验 · issuer 与 audience · 身份请求头 · 服务侧授权 · 旁路入口

  4. 作业 07代码推演

    批量订单查询包含当前用户和另一用户的订单 ID,产品允许返回部分结果。设计逐项授权与整体失败两种契约的可观察差异。

    提交内容
    批量授权矩阵
    怎样算完成
    • 包含重复 ID 和不存在对象
    • 说明数量是否泄露额外信息

    练到的知识点服务侧授权

  5. 作业 08面试场景

    内部服务收到为网关签发的令牌,调用链里另带“代表用户执行”标识。评审实际主体、委托主体与订单授权证据。

    提交内容
    委托调用身份表
    怎样算完成
    • 区分服务身份和用户权限
    • 不凭可伪造头声明委托成立

    练到的知识点issuer 与 audience · 服务侧授权

  6. 作业 09编程实践

    用本地测试密钥和授权桩生成令牌声明组合,自动运行发行方、受众、租户及对象权限矩阵,输出实际决策与决策来源。

    提交内容
    本地工程与矩阵报告
    怎样算完成
    • 只使用虚构身份及密钥
    • 分别核验令牌校验和资源授权

    练到的知识点issuer 与 audience · 服务侧授权

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    引入第二个身份提供者,两个提供者都可能签发相同 sub。审查账户映射、令牌转发和最小权限策略,设计迁移期测试。

    提交内容
    信任模型与冲突用例
    怎样算完成
    • 区分全局账户与发行方主体
    • 包含未知发行方和受众不符

    练到的知识点JWT 校验 · issuer 与 audience · TokenRelay · 服务侧授权 · 最小暴露

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 反例构造

    构造“签名、过期时间和角色检查全部通过,业务访问仍越权”的两个反例,要求两例的缺陷不属于同一控制点。

    提交内容
    反例请求及缺失检查
    怎样算完成
    • 每例说明已通过的检查
    • 不靠伪造签名绕过前提

    练到的知识点issuer 与 audience · 服务侧授权

  2. 作业 12思维拓展 · 跨域迁移

    将网关信任模型迁移到 AI Agent 代用户调用工具,Agent 可持有长期服务凭据。重新定义工具目标受众、委托范围及每次调用的授权依据。

    提交内容
    工具调用信任图
    怎样算完成
    • 区分模型建议与可信身份
    • 覆盖多用户同时委托

    练到的知识点issuer 与 audience · 服务侧授权

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗