OAuth2 / OIDC / JWT 实战:把"第三方登录"讲成一条能跑的链路
上一章讲了 Spring Security 的骨架,但真实项目里的认证往往不是"表单登录",而是"用户从企业微信/钉钉/Google 点进来"或者"服务 A 拿令牌去调服务 B"。这一章把 OAuth2、OIDC、JWT 这三个总被混着说的东西拆开,讲清楚它们各自解决什么问题、令牌长什么样、服务端每一步要校验什么。
先把四个概念摆正:别再混着说
很多人张口就是"我们用 OAuth2 登录",其实这句话本身就不严谨——OAuth2 是授权协议,不是认证协议。混用会导致你在设计权限模型时走偏。
| 概念 | 它是什么 | 解决什么问题 |
|---|---|---|
| OAuth2 | 授权(Authorization)框架,RFC 6749 | 让第三方拿到有限的访问权限,而不必知道用户密码。回答的是"这个应用能代表用户访问哪些资源"。 |
| OIDC | OpenID 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 接入第三方授权服务器
access token 与 refresh token:寿命、轮换与泄露面
这两个令牌的分工,一句话就能说清:access token 用来办事,refresh token 用来换新的 access token。为什么要两个?因为泄露风险和使用频率是矛盾的——常用的令牌必须短命,长命的令牌必须少用。
| 维度 | access token | refresh 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 长什么样
解出来的 header 与 payload
资源服务器校验一个 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 配置
① 算法混淆攻击(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 做登录,跑在 OAuth2 之上"。
② 活着的模式只有三种:授权码 + PKCE(有用户)、客户端凭证(服务对服务)。密码模式和隐式模式已经进坟墓,看到就跳过。
③ access token 要短(分钟级),refresh token 要能撤销并轮换。前端刷新必须做单飞,否则轮换会把并发刷新全打挂。
④ 校验 JWT 是六件套:签名、算法白名单、iss、aud、exp/nbf、jti 黑名单。少一件就对应一类攻击。
⑤ 微服务用 RS256 不用 HS256:验签方不该有签名能力。内网直连也要能自证身份,别把安全押在"没人绕过网关"上。
⑥ payload 里不放敏感信息,Base64 不是加密,谁都能解。
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 挂了要么拒绝所有请求(安全但不可用),要么放行(可用但不安全)——这个取舍必须提前和业务方确认。解析:面试官想听的是"你知道无状态的代价,并且能在可用性和安全性之间做出明确选择"。