楼层: 首页/ 软件技术/ Spring 核心框架/ OAuth2 / OIDC / JWT 实战:把"第三方登录"讲成一条能跑的链路
12

OAuth2 / OIDC / JWT 实战:把"第三方登录"讲成一条能跑的链路

OAuth2 · OIDC · JWT in Practice

上一章讲了 Spring Security 的骨架,但真实项目里的认证往往不是"表单登录",而是"用户从企业微信/钉钉/Google 点进来"或者"服务 A 拿令牌去调服务 B"。这一章把 OAuth2、OIDC、JWT 这三个总被混着说的东西拆开,讲清楚它们各自解决什么问题、令牌长什么样、服务端每一步要校验什么。

先把四个概念摆正:别再混着说

很多人张口就是"我们用 OAuth2 登录",其实这句话本身就不严谨——OAuth2 是授权协议,不是认证协议。混用会导致你在设计权限模型时走偏。

概念它是什么解决什么问题
OAuth2授权(Authorization)框架,RFC 6749让第三方拿到有限的访问权限,而不必知道用户密码。回答的是"这个应用能代表用户访问哪些资源"。
OIDCOpenID Connect,构建在 OAuth2 之上的认证(Authentication)层补上 OAuth2 缺的那一环——"用户是谁"。它规定了一个 id_token(一定是 JWT)和 /userinfo 接口。
JWT一种令牌的编码格式,RFC 7519把声明的信息自包含地放进一个字符串里,可离线验签。它只是一种格式,不是协议,也不等于"登录方案"。
Session服务端保存的会话状态有状态方案:服务端存一份,客户端只拿一个 ID。可主动失效,但需要共享存储。

论一张表看懂角色和术语

① Resource Owner(资源所有者):就是用户本人。他拥有"我的头像、我的订单"这些资源。

② Client(客户端):想访问用户资源的那个应用。你要做的"支持微信登录"的那个后端,就是 Client。注意 Client 有 client_id 和 client_secret——能安全保存 secret 的叫"机密客户端"(后端服务),保存不了的(SPA、手机 App)叫"公开客户端",公开客户端必须用 PKCE。

③ Authorization Server(授权服务器):发令牌的地方。可以是自建的 Spring Authorization Server,也可以是微信/Google 的开放平台。

④ Resource Server(资源服务器):持有受保护资源、收到令牌后做校验的那一方。你自己写的大部分业务服务,在这个模型里就是资源服务器——这也是为什么微服务架构里你会到处配 oauth2ResourceServer()。

⑤ Scope(作用域):权限的范围,如 profile、email、read:orders。它是粗粒度的、用户可见可授权的;而 Role/Authority 是应用内部的细粒度权限。两者不是一回事。

OAuth2 四种授权模式:谁活着,谁已经进坟墓

这是 OAuth2 里最需要"更新认知"的一节。网上大量教程还在讲"四种模式",但实际上有两种已经被正式判了死刑。

模式状态说明与适用场景
授权码模式
Authorization Code
推荐最适合"有后端的应用"。用户跳到授权页 → 同意 → 回调带 code → 后端用 code 换 access_token。令牌不经过浏览器地址栏,最安全。所有需要用户身份的第三方登录都用它。
授权码 + PKCE必须公开客户端(SPA、原生 App)的标准姿势。RFC 7636 引入,RFC 9700(OAuth 2.0 安全最佳实践)明确要求所有客户端默认使用 PKCE,机密客户端也建议加上。
客户端凭证模式
Client Credentials
推荐没有用户参与,服务调服务。用 client_id + client_secret 直接换令牌,令牌代表的是"这个服务",不是某个用户。微服务间内网调用、定时任务拉数据都用它。
密码模式
Resource Owner Password
已废弃客户端直接拿用户密码去换令牌。这等于把密码交给了第三方,彻底违背 OAuth2 的设计初衷。RFC 6749 里只是"不推荐",RFC 9700 已经明确要求移除。只有一种情况例外:你在迁移一个无法改造的老客户端,且自有授权服务器。
隐式模式
Implicit
已废弃令牌直接从 URL 片段返回给浏览器。缺点致命:令牌进浏览器历史、可能被 Referer 泄露、无法用 refresh token。已被 PKCE 完全取代。看到还在教隐式模式的教程,请直接关掉。
关于"密码模式"的三个错误认识

① "前后端分离用密码模式最简单":这是最危险的偷懒。你的登录接口自己发令牌,完全不需要 OAuth2——那叫"自建表单登录 + JWT",跟 OAuth2 没关系。别为了用 OAuth2 而用 OAuth2。

② "密码模式加 HTTPS 就安全了":HTTPS 保护的是传输过程,攻击面在"客户端持有了用户密码"这一步——客户端的日志、内存 dump、第三方 SDK 都可能把密码泄露出去。HTTPS 解决不了这个问题。

③ "我们内部系统,客户端是自己人":内部系统最容易出事,因为"自己人"写的客户端一旦被渗透,攻击者直接拿到全量用户密码。内部系统更应该用客户端凭证模式或授权码模式。

授权码 + PKCE 的完整流程:每一步在防什么

不要只背"跳过去再跳回来"。理解每一步的防护目的,你才能在出问题时定位到是哪一步断了。

步动作防的是什么
1客户端生成 code_verifier(随机串)和它的 code_challenge = SHA256(verifier),code_verifier 只留在本地。防止授权码被截获后被人拿去换令牌。
2浏览器重定向到授权服务器,带上 client_id、redirect_uri、scope、state、code_challenge。state 防 CSRF——回调时比对不上就拒绝。
3用户在授权页登录并同意授权。—
4授权服务器重定向回 redirect_uri?code=xxx&state=yyy。code 短命(通常 30~60 秒)且一次性。redirect_uri 必须精确匹配注册值,防开放重定向。
5客户端(后端)拿 code + code_verifier(或 client_secret)去换令牌。这一步在服务端之间完成,浏览器看不到。code_verifier 与第 1 步的 code_challenge 对不上就拒绝,所以光偷到 code 没用。
6授权服务器返回 access_token、refresh_token、id_token、expires_in。OIDC 场景才返回 id_token,必须校验它的签名和 nonce。
7客户端用 access_token 访问资源服务器。资源服务器独立验签,不信任客户端自称的身份。

Spring Security 6:用 oauth2Login 接入第三方授权服务器

spring: security: oauth2: client: registration: my-sso: client-id: your-client-id client-secret: your-client-secret # 机密客户端;公开客户端留空并靠 PKCE authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" scope: openid, profile, email provider: my-sso: issuer-uri: https://sso.example.com # 指定 issuer-uri 即可自动发现端点
@Configuration @EnableWebSecurity public class OAuth2LoginConfig { @Bean public SecurityFilterChain chain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(a -> a .requestMatchers("/", "/login/**").permitAll() .anyRequest().authenticated()) // 授权码模式登录:Spring 会自动处理 4 个回调端点 .oauth2Login(login -> login .userInfoEndpoint(ui -> ui.userService(new MyOidcUserService())) .defaultSuccessUrl("/home", true)) // 客户端凭证模式:没有用户,直接在代码里拿令牌调别的服务 .oauth2Client(c -> {}); return http.build(); } @Bean public OAuth2AuthorizedClientManager authorizedClientManager( ClientRegistrationRepository registrations, OAuth2AuthorizedClientRepository clients) { var provider = OAuth2AuthorizedClientProviderBuilder.builder() .clientCredentials() // 服务调服务 .refreshToken() // 过期的自动用 refresh_token 续 .build(); var manager = new DefaultOAuth2AuthorizedClientManager(registrations, clients); manager.setAuthorizedClientProvider(provider); return manager; } }
// 客户端凭证模式实战:拿令牌去调下游服务 OAuth2AuthorizeRequest req = OAuth2AuthorizeRequest .withClientRegistrationId("downstream-service") .principal("scheduler-job") // 没有真实用户,给个标识即可 .build(); OAuth2AuthorizedClient client = manager.authorize(req); String token = client.getAccessToken().getTokenValue(); // 用 RestClient 带上它(Spring 6.1 推荐 RestClient 而不是 RestTemplate) String result = restClient.get() .uri("https://downstream.example.com/api/orders") .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) .retrieve() .body(String.class);

access token 与 refresh token:寿命、轮换与泄露面

这两个令牌的分工,一句话就能说清:access token 用来办事,refresh token 用来换新的 access token。为什么要两个?因为泄露风险和使用频率是矛盾的——常用的令牌必须短命,长命的令牌必须少用。

维度access tokenrefresh token
用途访问受保护资源,每个请求都带只用于换新令牌,走一次接口
建议寿命5~30 分钟(敏感系统 5 分钟)7~30 天,且要能按设备撤销
是否入数据库无状态 JWT 场景不需要存必须存(要能撤销)
轮换策略过期就换,配合刷新Refresh Token Rotation:每次刷新都发一个新的,旧的立刻作废。这是 OAuth2 安全最佳实践里的强制建议。
撤销JWT 无状态,只能等过期或加黑名单删库即可,简单直接
泄露后果最坏在寿命窗口内被使用长期接管账号,所以必须轮换 + 复用检测

论为什么"过期太长"是最普遍也最危险的坑

① 常见做法:"反正 JWT 无状态,设 7 天省事,用户不用老登录。"这个决定的直接后果是:用户改了密码、离职、被盗号,那个令牌在 7 天内依然有效。无状态带来了扩展性,代价就是失去了"立刻失效"的能力。

② 权衡的正确解法:access token 设短(15 分钟),refresh token 设长但可撤销。用户日常只感受到"后台默默刷新",而你要踢人时删掉 refresh token 记录,最坏 15 分钟后对方就失效了。

③ 前端要注意的细节:刷新不能并发触发。页面上 6 个请求同时 401,如果都去刷 token,就会出现 6 次刷新、5 次失败(因为轮换让旧 token 作废了)。必须在前端做"单飞"(single-flight):只允许一个刷新请求在跑,其他请求排队等结果。这是刷新令牌方案里最常见的线上 bug。

④ 别忘了 expires_in 不是绝对时间:它是个相对秒数。前端算绝对过期时刻时,必须用响应到达的本地时间去加,别用请求发出前的时间,否则网络慢的时候会算短。

JWT 三段结构:签名校验到底在验什么

JWT 是一个用 . 分成三段的 Base64URL 字符串:header.payload.signature。注意,Base64 是编码不是加密,任何人拿到它都能解出内容——所以 payload 里绝对不能放密码、身份证号这类敏感信息。

一个真实的 JWT 长什么样

eyJhbGciOiJSUzI1NiIsImtpZCI6ImtleS0yMDI2LTAxIiwidHlwIjoiSldUIn0 . eyJpc3MiOiJodHRwczovL3Nzby5leGFtcGxlLmNvbSIsInN1YiI6InUxMDAxIiwiYXVkIjoi b3JkZXItYXBpIiwiZXhwIjoxNzc0MDAwMDAwLCJpYXQiOjE3NzM5OTY0MDAsInNjb3BlIjoi cmVhZDpvcmRlcnMiLCJyb2xlcyI6WyJVU0VSIl19 . Qh7f...(RSA 签名,256 字节 Base64URL 后约 342 个字符)

解出来的 header 与 payload

// header:说明"用什么算法签的、用哪把密钥" { "alg": "RS256", "kid": "key-2026-01", // 密钥 ID,用于在 JWKS 里找到对应的公钥 "typ": "JWT" } // payload:标准声明(Registered Claims) + 自定义声明 { "iss": "https://sso.example.com", // 谁签发的 "sub": "u1001", // 主体,通常是用户 ID "aud": "order-api", // 给谁用的 "exp": 1774000000, // 过期时间(Unix 秒) "iat": 1773996400, // 签发时间 "nbf": 1773996400, // 生效时间(not before) "jti": "a1b2c3d4", // 令牌唯一 ID,做黑名单时用它 "scope": "read:orders", // 自定义:作用域 "roles": ["USER"] // 自定义:角色 }

资源服务器校验一个 JWT,必须按顺序做完整套——少任何一步都有对应的攻击手法:

校验项怎么做不校验会怎样
签名用 issuer 的公钥(或共享密钥)验证 signature 段任何人都能伪造令牌。这是唯一的"防伪"手段。
alg 白名单只接受预期的算法(如 RS256),明确拒绝 none算法混淆攻击:把 alg 改成 none 后去掉签名即可通过老库。
iss必须等于预期签发者别人拿另一个系统的合法令牌来访问你的接口(跨系统令牌重放)。
aud必须包含当前服务的标识给 A 服务的令牌被拿去调 B 服务。多服务共用授权服务器时尤其重要。
exp / nbf检查过期与生效时间,允许少量时钟偏移(如 60 秒)过期令牌永远可用。
jti 黑名单在 Redis 里查是否被主动注销无法主动踢人,只能等自然过期。

HS256 还是 RS256:对称与非对称的选择

这是架构层面必须先定的事,因为它决定了"谁能签、谁能验"。

算法密钥关系适用与风险
HS256对称:同一把密钥既签又验适合单体应用或只有一个服务的场景,性能好、配置简单。致命问题是:任何能验证令牌的服务,都同时拿到签名能力,也就能伪造令牌。微服务里一旦某个下游服务被攻破,整个认证体系沦陷。
RS256非对称:私钥签、公钥验授权服务器持有私钥,资源服务只拿到公钥(通过 /.well-known/jwks.json 获取)。资源服务即使被攻破也无法伪造令牌。微服务、多团队协作的标准选择。
ES256非对称:椭圆曲线签名长度比 RSA 短很多,性能更好,安全强度相当。新项目推荐,但要确认你的客户端库支持。

资源服务器:对称与非对称两种 JwtDecoder 配置

@Configuration public class JwtDecoderConfig { // 方案 A:HMAC(HS256)。密钥必须足够长——HS256 要求至少 256 bit(32 字节) @Bean public JwtDecoder hmacDecoder() { // 别把密钥硬编码在代码里!从配置中心/环境变量注入 byte[] secret = Base64.getDecoder() .decode("这里放 32 字节以上的 Base64 密钥"); return NimbusJwtDecoder.withSecretKey(new SecretKeySpec(secret, "HmacSHA256")) .build(); } // 方案 B:RSA / EC(RS256 / ES256)。只配 issuer-uri 就能自动发现 JWKS @Bean public JwtDecoder jwtDecoder() { OAuth2TokenValidator<Jwt> validators = JwtValidators.createDefaultWithIssuer( "https://sso.example.com"); // 校验 iss + exp + nbf // 再叠加 audience 校验(默认那套不校验 aud,必须自己加) validators = new DelegatingOAuth2TokenValidator<>( validators, new JwtClaimValidator<List<String>>("aud", aud -> aud != null && aud.contains("order-api"))); NimbusJwtDecoder decoder = JwtDecoders .fromIssuerLocation("https://sso.example.com"); decoder.setJwtValidator(validators); return decoder; } }
# 也可以纯靠配置,省掉 Java 代码 spring: security: oauth2: resourceserver: jwt: issuer-uri: https://sso.example.com # 关键:显式限定算法,等于拒绝 none 与算法降级 jws-algorithms: RS256 # 允许的时钟偏移,避免集群间时间不同步导致误判 audiences: order-api
JWT 校验的五个致命坑

① 算法混淆攻击(algorithm confusion):经典手法是把 header 里的 alg 改成 none,或者把 RS256 换成 HS256 并用公钥内容当作 HMAC 密钥去签名——因为公钥是公开的,攻击者也能拿到。防御只有一条:服务端固定白名单算法,绝不信任 header 里的 alg。jws-algorithms: RS256 就是干这个的。

② 把 JWT 当 Session 用:把用户昵称、头像、权限列表全塞进 payload,令牌膨胀到 4KB;改一次昵称就要重发令牌;权限变了老令牌还带着旧权限。payload 只放 ID 和必要的粗粒度声明,其余每次查缓存。

③ 忘了校验 aud:你可能觉得"都是自己家的令牌",但如果公司里有多个系统共用一个 SSO,A 系统的令牌能直接调 B 系统的接口。这是内网渗透里最容易被利用的横向移动路径。

④ 用 Authorization 头之外的地方传令牌:放 URL 参数会被 Nginx 日志、浏览器历史、Referer 头记录;放 Cookie 又要防 CSRF。标准做法就是 Authorization: Bearer <token>,并且只在 HTTPS 下传输。

⑤ 时钟不同步:多实例部署时如果某台机器时间快了 5 分钟,它签发的 exp 会偏,其他节点校验时可能因 nbf 未到而拒绝。生产环境必须上 NTP,并给校验留 30~60 秒的偏移容忍。

网关前置校验 vs 资源服务器自校验:怎么选

微服务里有个反复出现的争论:令牌到底在网关统一验一次,还是每个服务自己验?两者不是对错问题,是权衡。

方案优点缺点与风险
仅网关校验一处配置、业务服务零侵入;JWKS 只需拉一份,性能压力集中可控。内网就成了信任区——任何绕过网关的直连(运维端口、内网扫漏、开发调试)都不受保护。必须配合网络策略,否则等于没鉴权。
仅资源服务器校验每个服务都自证身份,内网直连也安全;服务可独立部署、独立演进。每个服务都要拉 JWKS(要做好缓存,否则授权服务器会被打爆);令牌校验的 CPU 开销分散在业务节点上。
两者都做(推荐)网关做粗粒度拦截(有没有令牌、是不是合法签名),服务做细粒度授权(scope、角色、数据归属)。网关统一注入可信的用户头(如 X-User-Id),服务只信来自网关的请求。配置量稍大;要把"可信头"的重写规则做严——网关必须剥掉外部传入的同名头,否则客户端自己伪造一个 X-User-Id 就拿到他人身份了。

论令牌注销:无状态带来的那道必答题

① 问题本质:JWT 一旦签发就自包含,服务端没有它的记录,所以"用户点退出登录"时,令牌在有效期内仍然可用。这是无状态设计的固有代价,不是 bug。

② 三种主流解法及其代价:

 黑名单:退出时把 jti 写进 Redis,TTL 设为令牌剩余寿命(自动清理)。校验时查一次 Redis。代价:引入了一次 Redis 往返,"无状态"打了折扣,但这是最实用的方案。

 版本号:用户表存一个 token_version,签令牌时写进 payload;改密码/踢人时版本号 +1。校验时比对用户当前版本号。代价:每个请求都要查一次用户版本号——除非缓存在内存里,那又变成"多久失效"的问题。

 短寿命 + 撤销 refresh token:最简单也最推荐。access token 15 分钟,退出时删掉 refresh token 记录,最坏 15 分钟后彻底失效。大多数业务场景下这个方案已经足够。

③ 选型建议:普通业务用方案三;金融、后台管理等需要"立刻踢人"的场景,用方案一(黑名单)叠加方案三。不要为了追求纯粹的"无状态"而放弃主动失效能力——那是在为架构洁癖牺牲安全。

记
OAuth2 / OIDC / JWT 一句话总结

① OAuth2 管授权,OIDC 管认证,JWT 只是个格式。"用 OAuth2 做登录"严格说是"用 OIDC 做登录,跑在 OAuth2 之上"。

② 活着的模式只有三种:授权码 + PKCE(有用户)、客户端凭证(服务对服务)。密码模式和隐式模式已经进坟墓,看到就跳过。

③ access token 要短(分钟级),refresh token 要能撤销并轮换。前端刷新必须做单飞,否则轮换会把并发刷新全打挂。

④ 校验 JWT 是六件套:签名、算法白名单、iss、aud、exp/nbf、jti 黑名单。少一件就对应一类攻击。

⑤ 微服务用 RS256 不用 HS256:验签方不该有签名能力。内网直连也要能自证身份,别把安全押在"没人绕过网关"上。

⑥ payload 里不放敏感信息,Base64 不是加密,谁都能解。

章末面试 · OAuth2 与 JWT(5 题)

1.(概念题)OAuth2 和 OIDC 的区别是什么?为什么单靠 OAuth2 做不了登录?

查看答案

答案:OAuth2 是授权框架,只解决"这个应用能代表用户访问哪些资源",它签发的 access token 对客户端来说是个不透明字符串,不保证包含也不保证描述用户身份。OIDC 在 OAuth2 之上补了一层认证:规定返回 id_token(一定是 JWT,含 sub、iss、aud、nonce)并提供 /userinfo 端点,从而让客户端能确认"用户是谁"。解析:面试标准表述是"OAuth2 授权,OIDC 认证,OIDC = OAuth2 + 身份层"。

2.(安全题)攻击者截获了授权码 code,能直接拿去换 access token 吗?

查看答案

答案:用了 PKCE 就不能。换令牌时必须带上第 1 步生成的 code_verifier,授权服务器会用 SHA256(verifier) 与之前收到的 code_challenge 比对,对不上直接拒绝。而且 code 是一次性的、寿命通常 30~60 秒。机密客户端另有 client_secret 保护。解析:这正是 RFC 9700 要求所有客户端默认启用 PKCE 的原因。

3.(排错题)资源服务器配置了 issuer-uri,令牌也能验签通过,但接口一直 401。日志显示 JwtValidationException: An error occurred while attempting to decode the Jwt: Jwt expired。可能是什么原因?

查看答案

答案:直接原因是令牌 exp 已过,但要往深处查三点:① 授权服务器签发的寿命是不是太短,而客户端没有刷新;② 服务器之间时钟不同步(没上 NTP),校验方时间比签发方快;③ 时区处理错误,把 exp 当本地时间算了。修法:对时钟偏移设容忍(默认 JwtTimestampValidator 允许 60 秒),并检查 NTP 与令牌寿命配置。解析:报错信息永远是第一现场,先看异常类名再看自己的配置。

4.(安全题)为什么微服务架构推荐 RS256 而不是 HS256?

查看答案

答案:HS256 是对称的,验签和签名用同一把密钥。这意味着任何需要验证令牌的服务都同时具备了签发令牌的能力——一个下游服务被攻破,攻击者就能伪造任意用户的令牌访问全公司系统。RS256 用私钥签发、公钥验签,公钥是公开的,资源服务无法伪造令牌,权限边界清晰。代价是 RSA 签名更长、验签 CPU 开销略高于 HMAC,但在微服务规模下完全可接受。解析:核心是"验签方不应该有签名能力",这是最小权限原则的体现。

5.(设计题)用户要求"点退出登录后立刻失效,不能等 15 分钟"。请给出方案并说明代价。

查看答案

方案:引入黑名单。令牌签发时保证带 jti,退出时把 jti 写入 Redis,值随便,TTL 设为该令牌的剩余寿命(到期自动清理,不会无限膨胀);资源服务器在 JwtDecoder 后加一层自定义 OAuth2TokenValidator,或用一个轻量 Filter 查 Redis。代价:① 打破了纯无状态,每个请求多一次 Redis 往返(本地缓存 + 布隆过滤器可缓解);② 需要保证 Redis 高可用,Redis 挂了要么拒绝所有请求(安全但不可用),要么放行(可用但不安全)——这个取舍必须提前和业务方确认。解析:面试官想听的是"你知道无状态的代价,并且能在可用性和安全性之间做出明确选择"。