带上令牌不等于带上权限:Spring Security 过滤器链、JWT 生命周期与 OAuth 2.0 授权的完整鉴权工程

发布时间:2026/10/25 1:47:06
带上令牌不等于带上权限:Spring Security 过滤器链、JWT 生命周期与 OAuth 2.0 授权的完整鉴权工程

目录

一、先把Spring Security、JWT 与 OAuth 2.0放回各自的位置

(一)一句"给系统加一套权限"里混着的三类需求

(二)执行框架、凭证载体与发放协议

(三)详细展开

二、Spring Security 的骨架:过滤器链、认证三元组与安全上下文

(一)一次请求穿过的过滤器链

(二)认证三元组:管理器、认证器与用户明细服务

(三)安全上下文的线程边界

(四)声明式配置的书写方式

三、认证落地:从口令存储到登录防线

(一)会话与令牌:状态放在哪,代价就在哪

(二)口令存储:慢哈希与委托编码器

(三)登录接口:按被攻击来设计

四、JWT:一段能离线验证的签名快照

(一)三段式结构:编码可读,签名防改

(二)签名算法:看泄露半径,不看算法名气

(三)载荷设计:公开快照只放不变的最小集

(四)资源服务的完整校验清单

五、令牌生命周期:签发、刷新、撤销与换钥

(一)双令牌模型:快与可控各归其位

(二)刷新令牌的轮换与重用检测

(三)注销:三种方案与一个推荐组合

(四)密钥轮换与密钥集端点

六、OAuth 2.0 与 OIDC:把授权做成协议

(一)四个角色与两类客户端

(二)授权码模式与校验码

(三)模式选型:两问一答

(四)OIDC:认证与授权各拿各的令牌

(五)Spring 生态里的三种角色落地

1、资源服务器

2、客户端

3、授权服务器

七、接口鉴权:从 URL 规则到方法注解

(一)三道闸按粒度递进

(二)URL 级规则的两条纪律

(三)方法级注解:表达式、判定时机与代理边界

1、前置判定与后置判定

2、表达式能用的三类事实

3、代理边界:注解会静默失效

八、细粒度权限:模型、数据与生效时刻

(一)RBAC:角色是配置,权限点才是代码

(二)数据权限:行级过滤与归属校验

1、行级过滤的三种实现

1.1 业务代码手写条件

1.2 访问层统一改写

1.3 独立策略引擎

2、写操作的归属校验

(三)权限变更的生效链路

九、工程化防线:存放、架构与门禁

(一)令牌的存放与传输

(二)网关统一鉴权与服务内二次校验

(三)上线前的十六条门禁

结语

参考资料


干货分享,感谢您的阅读!

一个请求带着一枚签名合法的令牌抵达接口,这件事只证明了一点:它出示的凭证没有被篡改。它是谁签的、给谁用的、现在还算不算数、允许调这个接口吗、允许看这行数据吗、它背后的授权上周被撤销了吗——这六个问题里的任何一个答错,都是一次真实的越权。

这次我们把 Spring Security、JWT 与 OAuth 2.0 这三个常被并列却各司其职的名词放回各自的位置,沿着"骨架、认证、令牌、协议、接口鉴权、细粒度权限、工程防线"七段推进,给出一套能被评审、能被脚本验证的权限工程方法。

一、先把Spring Security、JWT 与 OAuth 2.0放回各自的位置

(一)一句"给系统加一套权限"里混着的三类需求

权限体系的设计事故,多数不是从写错代码开始的,而是从需求评审上那句"给系统加一套权限"开始的。这句话至少压着三类协议不同、载体不同、失效形态也完全不同的需求。

第一类是认证,回答"你是谁"。输入是口令、验证码或某个外部身份系统的凭证,输出是一个可信的主体标识。它的失效形态是冒用、爆破与凭证泄露。第二类是授权,回答"你能做什么"。输入是主体、资源与操作三元组,输出是放行或拒绝。它的失效形态是越权——垂直越权拿到更高的操作权,水平越权碰到别人的数据。第三类是委托授权,回答"第三方能替你做什么"。一个日程应用想读取你的邮箱日历,它不该拿到你的邮箱口令,而应拿到一张由你明示同意、范围受限、可以单独撤销的令牌。它的失效形态是授权范围过宽与令牌外泄。

三类需求分开之后,选型讨论才有落点:认证由 Spring Security 的认证器体系承接;授权由 URL 规则、方法注解与数据层过滤承接;委托授权由 OAuth 2.0 与 OIDC 承接。最常见的设计事故不是某一段做错了,而是拿其中一段的工具去硬扛另一段的需求——比如拿 OAuth 的令牌格式去顶替数据权限模型,或者以为引入了 JWT 就等于有了权限体系。

(二)执行框架、凭证载体与发放协议

把三个名词各自"解决什么、不解决什么"的边界画清楚,是全文所有后续判断的基础。

Spring Security 是嵌在请求路径上的执行框架。它规定检查发生在哪里(过滤器链的哪一站)、身份如何被确认(认证器体系)、放行与拒绝如何裁决(授权组件)。但它不规定凭证长什么样,也不规定凭证从哪里来。

JWT 是一种自包含的凭证载体。它把主体、有效期与签名装进一段可以离线验证的字符串,让服务端不必存储会话就能校验身份。但它不负责发放与收回,更不等于一套权限模型——一枚 JWT 里放什么、放多久、怎么作废,全部是使用者自己的设计责任。

OAuth 2.0 是一套授权发放协议,规定用户如何向客户端授权、令牌经由哪些信道流转;OIDC 在它之上补出身份层。但协议不规定令牌内部格式,也不管你的服务内部怎么做方法级鉴权。

三者是"执行框架 × 凭证载体 × 发放协议"的正交组合,而不是三选一。单体应用可能只需要第一个;前后端分离加多服务需要前两个;开放平台三个都要。把这个组合关系说清楚,是这套体系设计里最先要过的一道关。

(三)详细展开

一套完整的权限体系,最终要能回答六个问题:你是谁;这一次请求凭什么信;这个接口你能不能调;这行数据你能不能看;第三方替你做到哪一步;授权变了多快生效。

六个问题里,后三个最少被写进需求文档,却贡献了最贵的事故。接口级越权在测试环境用扫描器就能查出来;数据级越权只有在"换一个用户身份重放同一请求"时才显形,功能测试全绿也拦不住它;而"权限改了、旧令牌还在放行"这类问题,往往在离职、转岗、封禁这些运营动作之后才暴露——那时它已经不是缺陷,而是事故。本文按"骨架、认证、令牌、协议、接口鉴权、细粒度、防线"的次序,把六个问题逐一落到可评审的判据上。

二、Spring Security 的骨架:过滤器链、认证三元组与安全上下文

(一)一次请求穿过的过滤器链

Spring Security 的全部行为都发生在一条过滤器链上。请求从 Servlet 容器进来,先经过上下文装载的过滤器(恢复或新建安全上下文),再经过跨站请求防护,然后到达认证类过滤器——表单登录场景里它解析登录表单,无状态令牌场景里它从请求头取出令牌、验签并构造已认证主体。之后是异常翻译过滤器(把认证异常与授权异常翻成 401 与 403),最后是授权判定过滤器,URL 级规则在这里求值。全部通过,请求才进入 DispatcherServlet 与业务代码。

这条链有两个工程含义,比记住每个过滤器的名字重要得多。其一,链上位置即语义:自定义过滤器必须显式声明加在哪一站之前或之后,加错位置要么拿不到上下文,要么被前面的站点短路。其二,链上任何一站抛出的异常都不会进入业务侧的全局异常处理器——它们发生在 DispatcherServlet 之前。想统一 401/403 的响应格式,要配置的是认证入口点(AuthenticationEntryPoint)与拒绝处理器(AccessDeniedHandler),而不是给控制器加异常通知。

链条还给出了排障的第一条线索:401 产生在认证类过滤器,含义是"不知道你是谁",正确动作是引导重新认证或刷新令牌;403 产生在授权判定,含义是"知道你是谁,但规则不放行",正确动作是核对角色与权限点配置。把这两个状态码用混的系统,客户端只能靠猜来决定"重新登录还是提示无权限"。

(二)认证三元组:管理器、认证器与用户明细服务

认证类过滤器只做一件事:收集凭证。真正的身份确认由三个组件协作完成——认证管理器(AuthenticationManager)接过未认证的凭证对象,按凭证类型分派给能处理它的认证器(AuthenticationProvider);数据库口令场景下,认证器再调用用户明细服务(UserDetailsService)装载用户,交由口令编码器比对口令;比对通过后返回一个已认证的凭证对象,其中原文口令已被抹除、权限集已经填好,最后写入安全上下文。

这套分派结构的价值在扩展点的位置上:新增一种认证方式——短信验证码、扫码、企业单点登录——等于新增一个认证器,过滤器与管理器都不动。反过来说,把新认证方式的逻辑塞进过滤器里堆分支,是这套体系里最典型的反模式:它让每种认证方式都要重新处理一遍"成功后做什么、失败后做什么",而这些恰恰是框架已经做对了的部分。

(三)安全上下文的线程边界

认证结果放在 SecurityContextHolder 里,默认策略是线程私有存储。这带来一个随手可得的便利:控制器、服务层、审计切面,请求线程上的任何位置都能取到当前用户。也带来两个必须直面的边界。

第一个边界是清理。请求结束时上下文必须清除,因为线程要还给线程池——不清除,下一个复用这条线程的请求就会"捡到"上一个用户。框架的过滤器会做这件事,但绕开框架自建入口(比如某些内部调度入口)的代码要自己负责。

第二个边界是跨线程。异步注解、并行流、自建线程池、CompletableFuture 的默认执行器,都会离开请求线程——线程私有存储里的上下文不会跟过去,"当前用户"悄悄变成空。更隐蔽的形态是拷贝了上下文却不清理,让池中线程带着上一个用户的身份运行下一个任务。

把身份带过线程边界的方式按推荐度排序:显式传参最直白也最可测——提交任务前把用户标识取出来当普通参数传进去;其次是用安全上下文感知的执行器包装线程池,提交时快照、执行时恢复、结束时清理;可继承的线程私有变量模式只对新建线程生效,对池化线程会造成身份错乱,线上服务基本不可用。评审判据只有一句:凡在非请求线程里读"当前用户"的代码,必须能指出身份从哪条路径传进来。

(四)声明式配置的书写方式

骨架的最后一块是配置。现代 Spring Security 用 Lambda 风格声明一条过滤器链,一段典型的无状态令牌配置如下:

@Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) // 无浏览器会话,跨站防护按需关闭 .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/public/**", "/actuator/health").permitAll() .requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin") .anyRequest().authenticated()) // 默认拒绝收底 .oauth2ResourceServer(rs -> rs.jwt(Customizer.withDefaults())) .exceptionHandling(e -> e .authenticationEntryPoint(unifiedEntryPoint) // 统一 401 响应 .accessDeniedHandler(unifiedDeniedHandler)); // 统一 403 响应 return http.build(); }

这段配置里每一行都对应前文的一个论点:无状态会话策略对应令牌方案;anyRequest().authenticated()对应默认拒绝;资源服务器一行对应第四章的整套令牌校验;异常处理两行对应 401/403 的统一响应。配置不是模板,是决策的落盘。

三、认证落地:从口令存储到登录防线

(一)会话与令牌:状态放在哪,代价就在哪

在写第一行登录代码之前,要先做一个决定:认证状态放在服务端(会话),还是编码进凭证本体(令牌)。两者不是新旧关系,而是同一道选择题的两个答案,各自的代价清清楚楚。

会话方案里服务端存状态,注销即时生效(删掉会话就行),但多实例需要会话共享或粘性路由;令牌方案里任意实例可独立验签、零存储访问,水平扩展与跨服务校验几乎免费,但"注销"从一个删除操作变成了一个设计难题——令牌签出去就收不回来,在到期之前它天然有效。

选型判据是三个问题:有几个服务要验证身份——只有一个,会话最省事;一群服务,令牌免去会话共享。注销要多快生效——秒级强制下线是硬需求的场景(资金操作、后台管理),纯无状态令牌撑不住,必须配撤销机制。凭证要不要跨域跨端——开放接口、移动端、小程序共用一套身份时,令牌是唯一现实解。本文后续按"无状态令牌为主、以撤销机制补足注销"的主线展开,这是多服务架构下最常见的落点。

(二)口令存储:慢哈希与委托编码器

口令存储的第一原则:数据库里永远不出现可逆的口令。正确形态是代价可调的慢哈希——bcrypt、argon2 这一类。它们与通用哈希的区别是设计目标相反:通用哈希追求快,口令哈希刻意慢。攻击者拿到库之后做的是离线穷举,每次哈希十毫秒的代价对登录用户无感,对要试几十亿次的攻击者是灾难。慢哈希自带随机盐(同一口令两次编码结果不同,彩虹表失效),代价因子还能随硬件升级调大。

Spring Security 的委托式口令编码器把"算法可升级"做成了机制:库里每条口令带着算法前缀(如{bcrypt}、{argon2}),校验时按前缀路由到对应实现,写入时统一用当前默认算法。

算法升级于是有了一条不打扰任何用户的路径:改一行默认算法配置;存量用户登录成功的那一刻,用新算法重新编码并回写;观察前缀分布,存量清零后移除老实现。全程没有一次强制改密,也没有停机窗口。反过来,用 MD5 加固定盐、用可逆加密"以便找回密码"的系统,在第一次拖库时就会把全部用户的通用口令送出去——口令复用的现实让这类泄露的伤害范围远超一个系统。

(三)登录接口:按被攻击来设计

登录是全站唯一一个攻击者可以合法反复调用的写接口,它的设计基准不是"正常用户怎么用",而是"攻击者会怎么打"。五道防线依次是:

第一,不可枚举。账号不存在与口令错误必须返回同一句文案、相近的响应耗时,否则登录接口就是一台免费的账号存在性查询器,撞库攻击的第一步就是拿它筛出有效账号。第二,失败计数与锁定。按账号计数,阈值内递增延迟,阈值外临时锁定并通知用户;计数必须放在集中存储——放在实例内存里,双实例部署下爆破流量在两台机器上各数各的,谁也数不满阈值。第三,人机验证。失败次数过阈值后升级出题,而不是每次登录都出题打扰全体用户;对成规模的撞库流量,还要结合设备指纹与地理特征。第四,双维度限流。按来源地址限总量防扫号,按账号限频次防定点爆破,缺一个维度另一个就会被绕开。第五,留痕与告警。成功与失败都记录来源、设备与结果;失败风暴触发告警;异地登录与新设备登录走通知或二次确认。

这五道防线的共同点是全部可以脚本化验证:枚举脚本、爆破脚本、并发脚本各跑一轮,比任何一轮人工评审都可靠。"登录安全"不是"加了验证码"一句话,而是五道防线各自有阈值、有存储位置、有验证脚本。

四、JWT:一段能离线验证的签名快照

(一)三段式结构:编码可读,签名防改

一枚 JWT 是三段用点号连接的字符串:头部声明签名算法与密钥编号;载荷装着签发方(iss)、主体(sub)、受众(aud)、过期时刻(exp)、签发时刻(iat)、令牌编号(jti)与授权摘要;签名对前两段整体签名。前两段只是地址安全的 Base64Url 编码——是编码,不是加密,任何拿到令牌的人都能解码阅读。

签名保证的是完整性与来源:改动任何一个字节,验签即失败。但要把它"不保证"的两件事同样说清楚:它不保证机密性(载荷全世界可读),也拦不住整枚令牌被复制——谁拿到令牌,谁就是令牌的主人,这是后文全部生命周期治理的出发点。"自包含"的真正含义是:验证方拿到令牌本体加签发方公钥即可离线校验,不用回源查询。这换来了多服务架构下的横向扩展,代价是签发方失去了逐枚收回的能力。

(二)签名算法:看泄露半径,不看算法名气

对称签名(HS256 一族)用同一把密钥签发与验证,速度快、部署简单;非对称签名(RS256、ES256 一族)私钥签发、公钥验证。选型的关键不是算法强度对比,而是信任模型:验证方拿到的那份材料,能不能用来伪造令牌。

对称方案里每个验证方都持有那把能签发任意身份的密钥,泄露半径等于所有持钥服务中最弱的那一个——十个微服务共享一把对称密钥,等于把签发权复制了十份。非对称方案里验证方只有公钥,被攻破也伪造不了令牌,泄露半径收敛到签发服务一处,重点设防一处即可。

选型规则一条:验证方数量大于一,或未来会大于一,直接用非对称。配套两条硬纪律:验证端写死算法白名单,拒绝令牌头部自报的算法取值——历史上多个 JWT 库的经典漏洞,就是攻击者把算法改成"无签名"或把非对称换成对称、用公开的公钥当共享密钥伪造签名;密钥不进代码与配置文件,进凭据管理服务,进程启动时拉取。

(三)载荷设计:公开快照只放不变的最小集

载荷是一份会被到处转发、任何人可读、签发之后改不了的公开快照。三档取舍如下:该放的是校验与路由所需——主体标识(内部编号而非手机号)、签发方、受众、过期与签发时刻、令牌编号、会话版本号、租户标识。慎放的是授权摘要——粗粒度范围(如order:read)与少量角色摘要可以放,供网关粗筛;细粒度权限点清单不要放,一是体积失控,二是权限一变快照全过时。禁放的是敏感与易变数据——手机号、身份证号、姓名明文、口令密钥、余额额度,放进载荷等于贴在公告栏上。

配一条体积纪律:整枚令牌控制在一千字节以内。它出现在每个请求头里、被网关与每一跳服务解析、被写进访问日志。把权限清单塞进令牌的团队,最终都会遇到请求头超限与"改了权限不生效"这两个必然结果。正确分工是:令牌里放"你是谁加粗粒度范围",细粒度权限由资源服务按主体标识实时查询或短缓存获取。

(四)资源服务的完整校验清单

资源服务收到令牌后的校验有七步,次序固定:格式解析、算法白名单、验签名(按密钥编号选公钥)、时间窗(过期与生效,留几十秒时钟偏移)、签发方、受众、映射权限。只验签名的实现,等于把其余五道检查让给了运气。

三个最常被跳过的检查各自对应一类真实事故。跳过受众检查:为服务甲签发的令牌拿到服务乙也能用,一个低敏服务的令牌打开了高敏服务的门。跳过算法白名单:前文说过的算法混淆伪造。时间窗不留偏移:签发机与验证机时钟相差数秒,刚签出的令牌被判"尚未生效",线上出现随机 401,这类问题因为难以复现而格外消耗排查时间。

工程落点是少手写。Spring Security 的资源服务器组件把七步做成声明式配置——给出签发方地址与受众名,框架拉取密钥集并执行全套校验:

@Bean public JwtDecoder jwtDecoder() { NimbusJwtDecoder decoder = JwtDecoders.fromIssuerLocation(issuerUri); // 拉取密钥集并锁定签发方 OAuth2TokenValidator<Jwt> withAudience = new DelegatingOAuth2TokenValidator<>( JwtValidators.createDefaultWithIssuer(issuerUri), // 签名、时间窗、签发方 new JwtClaimValidator<List<String>>(AUD, aud -> aud != null && aud.contains("order-service"))); // 受众必须包含本服务 decoder.setJwtValidator(withAudience); return decoder; }

自己手写解析的团队,几乎必然漏掉受众与算法白名单。能用框架的地方不要手写。

五、令牌生命周期:签发、刷新、撤销与换钥

(一)双令牌模型:快与可控各归其位

无状态令牌的"快"与注销需求的"可控"是一对天然矛盾,双令牌模型把它拆到两枚令牌上解决。访问令牌短命(分钟级)、无状态、随每个请求到处飞,资源服务本地验签即可放行;刷新令牌长命(天级)、有状态、只出现在与认证服务的换发请求里,服务端哈希落库、绑定设备、可随时吊销。

访问令牌过期后客户端拿刷新令牌换一对新令牌,用户无感。这个结构的本质是风险再分配:把"不可收回"限制在一个分钟级的窗口里,把"可控性"集中到一个只见一个端点、可以从容设防的长期凭证上。两枚令牌的有效期是两个独立的决策变量——访问令牌看业务敏感度(资金类五分钟,一般业务十五到三十分钟),刷新令牌看重新登录的容忍度(移动端常给十四到三十天,配合活跃续期)。

(二)刷新令牌的轮换与重用检测

刷新令牌本质是一份长期口令,值得两道额外防线。第一道是轮换:每次刷新都签发新的刷新令牌,旧的立即作废——每枚刷新令牌只能用一次,形成一条家族链。第二道是重用检测:一枚已经被轮换掉的刷新令牌再次到达认证服务,说明它同时存在于两个地方——真用户手里一份、攻击者手里一份,至于哪份是偷来的无从判断,所以正确动作是吊销整个家族,让双方都掉线、真用户重新登录。

为什么"重用"几乎必然意味着泄露:正常客户端每次刷新后都会立刻改用新令牌,旧令牌不会再出现在任何请求里。轮换让"被偷"这件事自己暴露自己——这是无状态体系里少有的、能主动发现凭证泄露的机制,值得在任何生产系统里默认开启。落库纪律同样明确:刷新令牌只存哈希,每条记录挂家族标识、设备信息与上次使用时刻,吊销与审计都以家族为单位。

(三)注销:三种方案与一个推荐组合

"用户点了退出、管理员点了封禁,然后呢?"——无状态令牌收不回来,注销只能靠让验证方不再认它来实现。三种方案的取舍如下。

令牌黑名单:注销时把令牌编号写入集中缓存,有效期切齐令牌剩余寿命,验证时先查名单。代价是每次校验多一次缓存查询;好处是黑名单只存被注销的少数令牌,体量小。会话版本号:给用户一个版本号存进库或缓存,令牌携带签发时的版本,改口令、封禁、强制下线时版本加一,所有旧令牌一次性全部失效。粒度是用户而不是单枚令牌,"一键踢所有设备下线"的场景必备。纯短有效期:把访问令牌压到分钟级,不做任何服务端状态,靠自然过期收敛——注销后仍有分钟级放行窗口,只适合敏感度低的读场景。

推荐组合是三层递进而不是三选一:分钟级访问令牌打底,把风险窗口先压小;会话版本号兜住改口令、封禁、强制下线这三类必须即时的注销;黑名单只处理"单枚令牌被举报泄露"的精确打击。评审判据一句话:说"我们用了无状态令牌"的系统,必须能回答"封禁一个账号后,多少秒内全部接口拒绝它"——答不出这个数字的系统,注销功能只存在于界面上。

(四)密钥轮换与密钥集端点

签名密钥要能换,而且要能在不打扰任何用户的前提下换。轮换不是换锁,而是新旧两把钥匙有秩序的交接:引入新钥后进入双钥并存窗口,密钥集端点(JWKS)同时发布两把公钥;新签发全部用新钥,存量令牌按头部的密钥编号(kid)找旧钥验证;等最长寿命的存量令牌自然过期,旧钥才能安全下线。

验证方的缓存策略有一个细节决定轮换是否无感:遇到未知密钥编号时,先刷新一次密钥集再判失败。做对了这一条,轮换对全部验证方完全透明。反面是没有轮换能力的系统:密钥一旦泄露,只剩"全站令牌立刻作废、所有用户掉线"一个选项。轮换能力必须在和平时期建好并演练过——出事之后再搭,窗口期里每一分钟都在裸奔。

六、OAuth 2.0 与 OIDC:把授权做成协议

(一)四个角色与两类客户端

OAuth 2.0 的全部内容,就是规定四方之间谁可以把什么凭证交给谁。资源拥有者(通常是终端用户)是授权的唯一来源;客户端是想代用户行事的应用;授权服务器认证用户、征得同意、签发令牌;资源服务器持有数据、校验令牌、执行范围。协议最关键的一条底线是:用户的口令只交给授权服务器,永远不经过客户端——这正是它相对"把口令给第三方"这种上古做法的全部进步所在。

客户端分两类,分界决定了它能用哪种流程:机密客户端有服务端环境、能保管密钥(典型是传统 Web 应用的后端);公共客户端保管不了任何秘密——单页应用的代码在浏览器里、移动应用的包可以被反编译,写进去的密钥等于公开。公共客户端的防线由下一节的校验码机制补上。

(二)授权码模式与校验码

授权码模式是有用户参与场景的标准流程,它的设计核心是两条信道的分离:授权码经由用户侧的前端信道(浏览器重定向)返回客户端,令牌经由客户端与授权服务器之间的后端信道换取。前端信道可能被浏览历史、跳转日志、恶意插件窥见,所以只让一次性、分钟级有效的授权码经过它;令牌永远走后端信道。

校验码机制(PKCE)再把两条信道锁成一对:客户端在发起授权前生成随机校验原文,把它的散列作为质询带上授权请求;换令牌时提交原文,授权服务器验证散列匹配。截到授权码的人没有原文,换不出令牌;原文从不出现在前端信道,散列又不可逆推。

这一机制没有任何额外部署成本,当前的最佳实践是所有客户端一律启用,不再区分机密与公共——OAuth 2.1 草案已把它写成授权码模式的必选项。

(三)模式选型:两问一答

授权模式的选择在今天已经大幅简化,两个问题定一个模式。第一问:有没有终端用户参与?没有——服务与服务之间的机器调用,用客户端凭证模式,客户端以自身身份取令牌,令牌代表服务而非任何用户,范围按最小授权收紧。有用户,进入第二问:客户端所在环境能不能弹出浏览器?能——网页、单页应用、移动端,一律授权码加校验码;不能——电视、命令行、IoT 设备,用设备码模式,设备上显示短码,用户到手机上完成登录与授权。

两个模式不再是选项:密码模式让用户把口令交给第三方客户端,摧毁了协议存在的意义(多因子与单点登录也随之全部失效),新一代规范已将其删除;隐式模式把令牌直接暴露在前端信道,已由"授权码加校验码"全面替代。在新系统里看到这两个模式,应当按技术债处理而不是按备选项讨论。

(四)OIDC:认证与授权各拿各的令牌

OAuth 2.0 只发"能做什么"的通行证,它没有定义"你是谁"的标准表达——OIDC 在其上补出这个身份层。核心增量是身份令牌(ID Token):规范规定它必须是 JWT,受众是客户端自己,客户端验签后读取"这个用户是谁、什么时候认证的"。访问令牌的受众则是资源服务器,客户端只负责转交、不应解析它的内容。

两枚令牌不可互换,这是 OIDC 场景里最高频的两类误用:拿访问令牌当登录凭证——资源服务器的令牌证明不了"用户此刻在场",重放一枚偷来的访问令牌就能冒充登录;拿身份令牌去调接口——受众是客户端的令牌,资源服务器根本不该接受。企业内的"扫码登录第三方系统""用统一账号登录内部平台",本质都是 OIDC 的客户端角色落地。

(五)Spring 生态里的三种角色落地

同一套 Spring Security,按系统扮演的协议角色装配三套不同的组件。

1、资源服务器

多数业务服务的角色。引入spring-boot-starter-oauth2-resource-server,配置签发方地址与受众名,框架完成密钥集拉取与七步校验,范围自动映射为SCOPE_前缀的权限进入鉴权体系。这是三种角色里成本最低、也最应该标准化的一种。

2、客户端

需要代用户调用外部接口、或把登录委托给统一身份平台的系统。引入spring-boot-starter-oauth2-client,声明注册信息(客户端标识、授权端点、范围),框架接管授权码流程、令牌存储与自动刷新。

3、授权服务器

签发令牌的那一个系统。Spring 生态由独立的 Spring Authorization Server 项目承担,提供授权端点、令牌端点、密钥集端点与用户同意页的全套实现。中小团队更常见的选择是托管方案或成熟开源实现(如 Keycloak)——授权服务器是安全焦点中的焦点,自研的维护成本要按"长期养一个安全产品"来估算。

七、接口鉴权:从 URL 规则到方法注解

(一)三道闸按粒度递进

接口鉴权不是一道闸,而是三道按粒度递进的闸,各拦各的越权。URL 级规则在过滤器链的授权站求值,按路径与动词做粗粒度放行,圈定匿名区与公开区;方法级注解在进入方法前按表达式求值,主体、参数、返回值都能参与判定,是权限点判定的主战场;数据级过滤在查询与更新语句上收口,是水平越权唯一可靠的拦截点——前两道闸都回答不了"这个订单是不是他的"。

三道闸不是三选一。只有 URL 规则的系统,接口内部的资源归属靠自觉;只有方法注解的系统,一个漏配注解的新接口就是裸奔;三道齐备、且最后一道落在数据访问层的系统,才谈得上纵深防御。

(二)URL 级规则的两条纪律

URL 规则按声明顺序求值,命中即停。由此推出第一条纪律:具体规则在前,通配规则在后——把/api/admin/的收紧规则写在/api/的放行规则之后,等于没写。第二条纪律:默认拒绝收底,规则清单最后一条永远是"其余请求一律要求认证"。它的价值在于改变遗忘的代价方向:新接口忘了配置规则时,默认拒绝的系统表现为"该放的没放",一次调试就会发现;默认放行的系统表现为"该拦的没拦",往往由安全事件来发现。两种遗忘的代价不对称,配置就应该偏向代价小的那一边。

(三)方法级注解:表达式、判定时机与代理边界

1、前置判定与后置判定

@PreAuthorize在方法执行前求值,是绝对主力;@PostAuthorize在方法执行后对返回值求值,适合"先查出来才知道归属"的读场景,但副作用已经发生,写操作不可用它兜底。启用方式是配置类上加@EnableMethodSecurity。

@PreAuthorize("hasAuthority('order:refund')") // 权限点判定 public RefundResult refund(long orderId) { ... } @PreAuthorize("hasAuthority('order:read') and #userId == authentication.name") public List<Order> listOrders(String userId) { ... } // 权限点 + 参数归属双重判定 @PostAuthorize("returnObject.ownerId == authentication.name") public OrderDetail getDetail(long orderId) { ... } // 返回值归属判定(只适合读)

2、表达式能用的三类事实

安全表达式能触达三类事实:主体(authentication及其权限集)、方法入参(#参数名)、返回值(returnObject)。复杂判定不要在注解里堆长表达式,而是注册自定义的鉴权组件,把逻辑收进可测试的 Java 方法:@PreAuthorize("@orderAuth.canRefund(authentication, #orderId)")。注解里留一行语义清晰的调用,逻辑本身获得单元测试与断点。

3、代理边界:注解会静默失效

方法级注解由容器代理在调用边界上执行,这与事务注解是同一个机制,也共享同一类失效:同一个对象内部的自调用不经过代理,注解形同虚设;非公开方法上的注解默认不生效。最危险的性质是它不报错——方法照常执行,只是没人检查权限。功能测试全部通过,安全检查一次都没发生。评审时对每个带鉴权注解的方法问一句"谁调用它",是成本最低的一道人工防线;更硬的做法是静态扫描:搜出所有"同类内部调用带鉴权注解方法"的代码点,一处都不该有。

八、细粒度权限:模型、数据与生效时刻

(一)RBAC:角色是配置,权限点才是代码

细粒度权限的地基是一个两层的 RBAC 结构:用户与角色多对多,角色与权限点多对多。权限点用"资源:操作"命名(order:read、order:refund、report:export),由开发定义并随代码演进;角色(订单客服、财务审核、区域主管)由运营与安全在管理端配置;用户与角色的绑定由人事流程驱动——入职授予、转岗替换、离职回收,最好与人事系统联动而不是靠人记。

这个结构里有一条必须守住的纪律:代码里只判权限点,不判角色。判断写成"有没有order:refund",而不是"是不是客服主管"——角色是组织的映射,组织一年重组几次;权限点是系统能力的映射,只随功能演进。把角色名写死进代码的系统,每次组织调整都要发版。配套两条:角色继承最多两层,层级一深"谁到底有什么权限"就说不清;高危权限点做周期性盘点,谁拥有、多久没用过,长期不用即回收——盘点报表同时是审计的硬要求。

(二)数据权限:行级过滤与归属校验

1、行级过滤的三种实现

1.1 业务代码手写条件

每个查询手工拼上归属条件(部门、租户、本人)。直观、无框架依赖,但每个新查询都可能漏写,漏一处就是一个越权口子。只适合查询入口很少的小系统。

1.2 访问层统一改写

在 ORM 或 MyBatis 的拦截器层按规则给语句自动追加归属条件,白名单放行例外(如管理端全量查询、跨部门报表)。一次接入、全局生效,从机制上防漏写;需要管好例外清单与联表场景。这是多数业务系统的默认解。

1.3 独立策略引擎

主体、资源、环境属性统一进策略引擎,策略集中管理、动态下发,表达力最强,可以做到属性级(ABAC)控制;代价是引入一套引擎的运维与调试成本。适合策略复杂多变的中大型平台,小系统用它属于杀鸡用牛刀。

2、写操作的归属校验

行级过滤讨论最多的是查询,最容易出事的却是写。查询漏了过滤,泄露的是数据;更新与删除漏了归属校验,破坏的是别人的数据。"按主键更新"之前必须先以"主键加归属条件"查一次,或在更新语句里直接带上归属条件——只查主键就更新是高危写法。水平越权的验收同样要脚本化:用甲的令牌重放乙的资源标识,遍历全部读写接口。这类扫描一次编写长期有效,比任何一轮人工测试都更能兜住"新接口忘了带归属条件"。

(三)权限变更的生效链路

管理端把某人移出"财务审核"角色,数据库即刻更新——但旧的授权快照还活在三个地方:令牌里签发时写入的角色与范围,在过期前一直有效;资源服务内为加速判定而做的"用户到权限点"缓存;网关层如果缓存了粗筛结论,同样是一份会过时的快照。权限变更的生效速度,取决于三个快照里收敛最慢的那一个。

给"生效速度"定一个明确的服务等级,是这一段设计的收口:普通权限调整,分钟级收敛可接受——靠令牌短有效期与缓存过期自然完成;封禁、离职、泄密处置必须秒级——走会话版本号强制全端下线,同时主动清掉服务内权限缓存。网关的对策则是釜底抽薪:只做验签等无状态检查,不缓存授权结论。最被低估的事故形态值得单独点名:离职员工的令牌与缓存在回收流程走完之后,还替他工作了一整个有效期。

九、工程化防线:存放、架构与门禁

(一)令牌的存放与传输

浏览器端的令牌存放是一道没有完美答案、只有取舍清楚的选择题。放 localStorage:脚本可读,一次跨站脚本注入(XSS)就能把令牌整枚偷走;放 HttpOnly Cookie:脚本读不到,防住了窃取,但引回跨站请求伪造(CSRF),需要 SameSite 属性与防伪令牌配合。当前较稳的组合是:访问令牌放内存(不落任何持久存储,刷新页面靠静默续期恢复),刷新令牌放 HttpOnly、Secure、SameSite=Lax 的 Cookie 并限定路径只发往刷新端点。传输侧没有讨论余地:全链路 HTTPS,令牌不进 URL——地址栏、浏览历史、跳转来源头与访问日志都会留下它。

移动端放系统级安全存储(钥匙串或密钥库)。所有端共同的纪律:日志脱敏——请求头里的令牌在网关与应用日志里一律掩码,全文令牌进日志等于把凭证写进了保留期最长、访问面最广的存储。

(二)网关统一鉴权与服务内二次校验

多服务架构下,把验签、有效期、黑名单、限流这类无状态检查收敛到统一网关,是正确的减负:改一处、全局生效,各业务服务不必各自维护密钥集拉取。网关通过后把主体信息注入内部请求头,下游直接读头即可。

但"统一鉴权"常被过度引申为"服务内不用再管权限",这是危险的误读。服务内有三件事外包不了:方法级权限点判定(网关不懂业务语义)、数据归属校验(网关看不见数据)、以及校验内部请求头的防伪签名。第三件事的原因是:服务间的横向调用不经过网关——只信网关注入头的服务,在内网里任何一方都能用一个伪造的请求头冒充任意用户。

职责边界三条硬规则:网关只做无状态检查,不做业务级授权、不缓存授权结论;主体信息经防伪头传递,内部签名或双向认证二选一;服务永远兜底——网关被绕过(内网调用、运维直连)时,防线依然完整。

(三)上线前的十六条门禁

把全文的判据收敛成十六条可执行的门禁,按四条链分组:认证链四条(慢哈希、不可枚举、集中计数、上下文显式传递)、令牌链四条(短寿命加轮换、算法白名单、全量校验、密钥治理)、鉴权链四条(默认拒绝、注解位置、自调用禁令、状态码语义)、数据与治理四条(行级过滤加归属校验、越权脚本、秒级收敛、权限盘点)。

门禁的执行方式决定它们是不是摆设:静态可查的(注解位置、规则顺序、口令存储方式)进代码扫描与评审清单;运行期才能验证的(越权重放、枚举与爆破、版本号收敛时长)做成脚本,进回归与发布流水线。落地次序也有讲究:先把注销与越权这两类"看不见的债"还掉,再谈细粒度模型的精致化——一个能秒级踢人下线、扫不出水平越权的朴素系统,比一个策略引擎华丽但令牌收不回的系统安全得多。

结语

这篇文章的主线可以收成一句话:凭证证明的是来源,权限来自它身后的整套治理。

一枚签名合法的令牌,只回答了六个问题里的第一个半——它没被改过,以及它声称的主体是谁。剩下的部分全部依赖结构:受众与签发方的校验决定它能不能跨服务滥用;有效期、轮换与版本号决定授权被撤销后它还能活多久;URL 规则、方法注解与行级过滤决定它在系统内部能走多远;密钥治理与轮换决定整个信任体系的根有多牢。这些结构没有一件是"引入 JWT"或"接了 OAuth"本身能带来的。

因此判断一套权限体系是否完工,不看它用了哪些名词,而看四个问题能不能当场答出数字与位置:封禁一个账号,多少秒内全部接口拒绝他;用甲的令牌请求乙的资源,哪一层拦住、日志落在哪里;每个带鉴权注解的方法由谁调用;签名密钥泄露时,换钥流程走过几次演练。四个问题都有答案的系统可以上生产;答不出来的,无论架构图画得多完整,权限都还只是一种"大概管用"的装饰。

参考资料

  • Spring Security 官方文档:Servlet 架构与过滤器链
  • Spring Security 官方文档:认证体系架构
  • Spring Security 官方文档:口令存储与 DelegatingPasswordEncoder
  • Spring Security 官方文档:授权体系架构
  • Spring Security 官方文档:HTTP 请求级授权
  • Spring Security 官方文档:方法级安全
  • Spring Security 官方文档:OAuth 2.0 资源服务器与 JWT
  • Spring Security 官方文档:OAuth 2.0 客户端
  • Spring Security 官方文档:SecurityContextHolder 与并发支持
  • Spring Authorization Server 官方文档
  • RFC 6749:OAuth 2.0 授权框架
  • RFC 6750:OAuth 2.0 Bearer Token 用法
  • RFC 7519:JSON Web Token(JWT)
  • RFC 7517:JSON Web Key(JWK)
  • RFC 7636:授权码模式的校验码扩展(PKCE)
  • RFC 8628:OAuth 2.0 设备授权模式
  • RFC 9700:OAuth 2.0 安全最佳实践
  • OAuth 2.1 草案:整合与废弃项说明
  • OpenID Connect Core 1.0 规范
  • RFC 8725:JSON Web Token 最佳实践
  • OWASP:认证安全速查表
  • OWASP:JSON Web Token 安全速查表
  • OWASP:会话管理速查表
  • OWASP Top 10:失效的访问控制
  • NIST SP 800-63B:数字身份指南——认证与生命周期管理
  • Keycloak 官方文档:开源身份与访问管理
  • 微服务模式:访问令牌