☰
OAuth2与OpenID Connect协议详解:授权码模式、ID Token与PKCE实战
2026/10/15 3:40:02 网站建设 项目流程

这次我准备把 OAuth2 和 OpenID Connect 这套协议体系彻底啃一遍,于是整理了一份学习笔记,这篇是第一篇。很多后端开发、前端开发者对接第三方登录时,都接触过 OAuth2、OpenID 这几个词,但真正问起"它们之间到底是什么关系""授权码模式为什么是主流推荐""ID Token 和 Access Token 到底有什么区别"时,能讲清楚的人并不多。这篇笔记就是围绕这些基础概念展开的,把我自己梳理过的思路、动手跑通的流程,以及踩过的坑都记下来,适合刚接触认证授权体系、或者对接过第三方登录但没系统学过协议细节的同学参考。

1. 为什么会有这两个协议:授权与认证的本质区别

1.1 授权和认证,差的不是一星半点

很多初学者把 OAuth2 误当成"登录协议",这是第一个认知误区。OAuth2 的核心是授权,OpenID Connect(简称 OIDC)才是真正解决了认证问题的协议。授权解决的是"你能不能做这件事"的问题,认证解决的是"你是谁"的问题。

我习惯用小区门禁和房卡来类比。认证相当于你进小区门时出示身份证,保安核验你的身份,确认"你是你"。授权则是你到某栋楼的某套房门前,用房卡开门,房卡决定了你能开哪扇门、能进哪个房间,它不关心你是谁,只关心你有没有对应的权限。OAuth2 就是这张"房卡"的分发机制,OIDC 则是在发房卡之前,先帮你完成身份核验,然后附带给你一张"身份证复印件"。

如果不区分这两个概念,后面看 OIDC 的文档会越看越乱,尤其是很多流程里既有 Access Token 又有 ID Token,搞不清楚各自用途的话,一旦写错就会留下安全隐患。

1.2 OAuth2 诞生背后的真实痛点

早些年的应用对接第三方资源时,最常见的做法是"直接把密码交出去"。比如某个网站要读取你的通讯录,最简单粗暴的方式就是你把自己的账号密码给它,它直接拿你的密码去登录、去拉数据。这种做法问题很大:密码被第三方应用保存,泄露风险极高;而且你没法控制它只读取通讯录,万一它把你的邮件、相册、好友关系全部拉走了呢?更可怕的是,如果你想收回它的访问权,只能修改密码,代价极大。

OAuth2 的诞生就是为了解决这种场景。它提出了一种"委托授权"模型:资源所有者(也就是用户自己)不用交出密码,而是向授权服务器确认"我同意这个应用代表我去获取某一部分数据",授权服务器签发一个令牌,第三方应用拿着这个令牌去访问资源服务器。资源服务器只认令牌,不认密码。

这就像你去酒店办理入住,前台给你一张房卡,这张房卡只能打开你房间的门,不能打开别人的房间,也不能打开酒店库房。你退房时房卡自动失效,不需要换锁。OAuth2 的令牌设计就借鉴了这个思路,细粒度授权、可撤销、有时效性,从机制上替代了"交密码"这种极端方案。

1.3 OIDC 补上的那块拼图

OAuth2 框架本身没有定义"用户身份信息"的标准格式,不同厂商给出的用户信息接口五花八门,返回的字段也千差万别。这就导致纯 OAuth2 的授权服务器无法统一解决"用户登录"的需求。OIDC 在 OAuth2 的基础上增加了一层身份层,它以 OAuth2 授权流程为载体,额外签发给客户端一个 ID Token,这个令牌是一个经过签名的 JSON 结构,里面包含用户的标识符、签发时间、过期时间以及发行者信息,客户端可以通过标准化的方式来校验用户身份。

可以这么理解:OAuth2 只解决了"把你的数据委托给第三方应用"这种授权问题,OIDC 则在同一个流程里解决了"第三方应用怎么确认当前用户是谁"的认证问题。很多平台提供"第三方登录"时,背后真正跑的就是 OIDC 协议。我们常说的"使用某账号登录某网站",这个场景下,认证身份靠的是 OIDC,授权访问用户资料的能力靠的是 OAuth2,两者恰好互补。

2. OAuth2 核心角色与授权流程

2.1 四个角色,先把它背下来

OAuth2 规范里定义了四个角色,所有流程都是围绕这四个角色展开的。

资源所有者(Resource Owner),指拥有受保护资源的实体,也就是普通用户。客户端(Client),指请求访问用户资源的第三方应用,比如某个待办事项 App 需要同步你的日历数据。授权服务器(Authorization Server),负责验证资源所有者身份并向客户端签发令牌。资源服务器(Resource Server),托管受保护资源的服务,说白了就是 API 服务端,负责校验访问令牌并返回数据。

这四个角色缺一不可。授权服务器和资源服务器在物理上可以是同一套系统,也可以分开部署,比如同一个平台内部用一套授权服务统一发令牌,业务 API 作为资源服务器各自接收令牌并校验。

实际对接中最容易被忽略的是"授权服务器和资源服务器是不同主体"的场景,比如客户端拿到的令牌是下游服务签发的,但业务 API 是另一个团队维护的,这时候令牌校验方式如果不一致,就会出现"拿了好令牌却调不通接口"的问题。

2.2 授权码模式:最正统的完整流程

OAuth2 一共定义了多种授权模式,其中最经典、最常用、也是所有规范文档里重点推荐的是授权码模式(Authorization Code Flow)。它的完整流程可以梳理成这样:

首先,客户端把用户引导到授权服务器的登录页,URL 上会带上客户端标识(client_id)、重定向地址(redirect_uri)、请求的权限范围(scope)以及一个随机生成的 state 参数。接着,用户在这个页面上登录并确认授权。授权服务器验证通过后,通过重定向把用户带回客户端指定的回调地址,问号后面带着一个授权码(code)和之前那个 state 值。

客户端在拿到授权码之后,在后端直接发起一次 HTTPS 请求,把授权码、client_id、client_secret(客户端密钥)发送给授权服务器的令牌端点(token endpoint)。授权服务器核验无误后,返回两种令牌:访问令牌(Access Token)和刷新令牌(Refresh Token)。最后客户端带着访问令牌去请求资源服务器的 API,拿到用户数据。

为什么要绕这么大的圈子,不直接返回访问令牌呢?关键在于安全边界。授权码模式下,访问令牌是通过后端换取的,前端浏览器全程看不到令牌;授权码本身是一次性的、有效期极短,即使被截获,没有 client_secret 也无法兑换令牌。客户端密钥必须保存在服务器端,绝不能出现在浏览器端或移动端包里,这个原则是整个授权码模式的基石。

2.3 其他授权模式的取舍:哪些还在用、哪些被淘汰

除了授权码模式,OAuth2 还定义了隐式模式(Implicit Flow)、密码模式(Resource Owner Password Credentials Flow)和客户端凭证模式(Client Credentials Flow)。

隐式模式当年是为纯前端应用设计的,授权服务器直接通过重定向返回访问令牌,没有授权码这层交换过程。问题是访问令牌暴露在浏览器 URL 里,容易泄露,而且这类应用没法安全保存客户端密钥,导致令牌伪造和泄露的风险很高。现在的安全规范已经不推荐使用隐式模式了,新版 OAuth2.1 草案甚至直接把它排除在外。如果你的新项目还在用隐式模式,建议尽快迁移到授权码模式加 PKCE 的方案上。

密码模式本来是给官方客户端用的,用户直接把密码交给客户端,客户端用密码向授权服务器换令牌。这和 OAuth2 最初想解决的"不交密码"问题背道而驰,只有授权服务器和客户端完全信任、且其他流程不可行时才能考虑。OAuth2.1 同样将它废弃了。

客户端凭证模式适合没有真实用户的机器对机器场景,比如服务之间互相调用。客户端用自己的凭证直接换令牌,没有用户参与。这个模式本身没有安全问题,但要注意令牌对应的权限范围要收敛、密钥要妥善保管。我见过有些后端为了省事,所有服务间的调用都用一个长期有效的令牌,一旦泄露就是全线崩溃,教训很深刻。

2.4 PKCE:把授权码模式的安全短板补上

授权码模式虽然安全,但对原生应用(手机 App、桌面客户端)来说有个适配难题:这类应用无法安全保存 client_secret。如果没有 client_secret,攻击者拿到授权码后,任何人都可以去令牌端点换取访问令牌,授权码的安全性就断了。

PKCE(Proof Key for Code Exchange)就是为了解决这个场景。它的核心思想是:客户端在发起授权请求时,先自己生成一个随机字符串 code_verifier,然后通过一定的哈希算法变换成 code_challenge 一并发送。授权服务器保存这个 challenge。拿到授权码后,客户端再带着原始的 code_verifier 去换令牌。授权服务器会验证 code_verifier 是否与之前保存的 challenge 匹配。

这样一来,即使授权码被截获,攻击者没有原始的 code_verifier,照样换不到令牌。PKCE 现在已经从"推荐使用"变成了"默认必需",即使是有 client_secret 的 Web 应用,也不妨把 PKCE 加上,等于多加了一道防线。我在实际项目中习惯所有新接入的客户端都强制开启 PKCE,省心且安全。

3. OpenID Connect:在授权之上定义标准身份层

3.1 ID Token:一张签过名、能防篡改的身份证

OIDC 对 OAuth2 最核心的扩展,就是引入了一个新的令牌类型:ID Token。它是一个 JWT(JSON Web Token),结构上分为三段:头部、载荷、签名。头部声明签名算法,载荷承载身份声明(claims),签名用于保证内容在传输过程中未被篡改。

一个典型的 ID Token 载荷大概长这样:

{ "iss": "https://auth.example.com", "sub": "user-123456", "aud": "web-client", "exp": 1713600000, "iat": 1713596400, "nonce": "a1b2c3d4" }

其中 iss 是发行者标识,sub 是用户的唯一标识符,aud 表示令牌的接收方(即客户端 ID),exp 和 iat 分别是过期时间和签发时间,nonce 是防重放攻击的随机数。客户端拿到 ID Token 后,第一件事就是校验签名,第二件事是校验 iss、aud 是否匹配,第三件事是校验 exp 是否过期,必要时还要校验 nonce 是否与发起授权请求时发送的 nonce 一致。

有个关键点必须记住:ID Token 只能用来确认"用户是谁"这个事实,不能拿它去调用 API。调用 API 用的是 Access Token。我最初学的时候经常混淆,后来统一了口径:ID Token 是证明身份的,Access Token 是拿权限的。

3.2 OIDC 三种流程与 OAuth2 的对应关系

OIDC 协议定义了三种流程,分别对应不同的客户端场景。

Authorization Code Flow,对应 OAuth2 授权码流程,适合传统的 Web 应用和服务端应用。Implicit Flow,对应 OAuth2 隐式流程,适合纯前端应用,但和 OAuth2 一样,现在已经不推荐使用。Hybrid Flow,混合流程,是 OIDC 独有的,它同时返回授权码和 ID Token,允许客户端提前获取部分身份信息,同时再用授权码换取 Access Token,适合对安全性要求较高、又需要体验优化场景。

选择流程时,我的建议很简单:凡是服务端参与的,一律用 Authorization Code Flow 加 PKCE;纯浏览器场景因为隐式流程不安全,尽量也别用,想办法引入后端做代理。混合流程虽然功能强,但涉及多种令牌组合,校验复杂度高,新手不建议从它入手。

3.3 从授权码到身份信息:OIDC 的关键端点与发现机制

OIDC 在 OAuth2 的授权端点、令牌端点之外,新增了几个关键端点。UserInfo 端点用于获取用户资料,客户端用 Access Token 请求它,返回用户的昵称、头像、邮箱等信息。Discovery 端点则通过一个标准化 URL(通常是 /.well-known/openid-configuration)返回整套配置,包括授权端点地址、令牌端点地址、支持签名算法、支持的 scope 等。

这套"发现机制"相当实用。以前对接一个平台,要到处翻文档找各种端点地址,现在只要知道它的 issuer 标识,就能通过 discovery 文件一站式拿到所有配置。很多开发框架(比如各种 oidc-client 库)都支持直接读取 discovery 文件并自动配置,大大降低了接入成本。

值得注意,请求 OIDC 授权时,scope 参数里必须显式带上 openid,否则授权服务器只会把它当成普通的 OAuth2 请求处理,不会返回 ID Token。这是新手最容易忽略的一个细节,我见过有人调了半天接口发现返回的数据里只有 Access Token,最后排查发现就是 scope 漏写了 openid。

4. 实操记录:搭一个最小可用的模拟项目X授权服务器

4.1 技术选型与准备工作

理论看了不少,不动手跑一遍等于白看。我决定搭一个最小可用的授权服务器来验证流程,项目代号就叫模拟项目X。技术选型上,我选了一个轻量的开源 OIDC 服务端库作为授权服务器,配合本地环境的密钥托管工具来模拟真实配置。为什么不用在线第三方平台?因为在线平台涉及内网回调问题,调试复杂,自建一套本地环境可以把整个协议流程完整跑通。

我采用本地回环地址作为回调地址,这样浏览器跳转和令牌交换整个链路都在本机完成,排查问题最方便。模拟项目X的代码结构分三块:授权服务器、受保护资源 API、模拟客户端页面。三者独立运行,用不同端口区分。

4.2 客户端注册与配置参数

在授权服务器上注册一个客户端,配置如下:

配置项值说明
client_iddemo-web-client客户端标识
client_secret仅在服务端保存,不暴露给浏览器用于令牌端点认证
redirect_urihttp://localhost:3000/callback授权码回调地址
scopeopenid profile emailopenid 必填,profile 和 email 是用户资料范围
授权方式Authorization Code + PKCE符合最新安全实践
token 有效期Access Token 30 分钟,Refresh Token 7 天常规配置

这里有三个配置细节值得展开说。redirect_uri 必须和授权请求里携带的 redirect_uri 逐一匹配,授权服务器会做完全相等的字符串比对,加一个斜杠都会失败。scope 里的 openid 是最关键的,它是 OIDC 流程的"扳机",缺少这个参数就拿不到 ID Token。client_secret 只在服务端使用,如果浏览器端能看到,整个客户端的信任基础就崩塌了。

4.3 授权流程完整跑通:请求、回调、换令牌

我动手构建并发送了授权请求,URL 长这样:

http://localhost:8080/authorize? response_type=code& client_id=demo-web-client& redirect_uri=http://localhost:3000/callback& scope=openid%20profile%20email& state=xyz123& code_challenge=KjQm...& code_challenge_method=S256

response_type=code 表示走授权码模式。state 参数是客户端生成的随机值,授权服务器原样带回,用来防止跨站请求伪造。code_challenge 和 code_challenge_method 是 PKCE 的核心参数,S256 表示对 code_verifier 做一次 SHA-256 哈希之后再进行 Base64 URL 编码。

流程走到授权服务器登录页,我输入模拟用户名密码之后,页面跳出授权确认框,列出客户端申请读取的权限范围。点击确认后,浏览器被重定向到回调地址,我实际观察到的回调 URL 是这样的:

http://localhost:3000/callback?code=AUTH_CODE_123&state=xyz123

注意,这个 code 就是授权码。我立即在命令行用 curl 模拟客户端后端,把授权码发给令牌端点:

curl -X POST "http://localhost:8080/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=AUTH_CODE_123" \ -d "redirect_uri=http://localhost:3000/callback" \ -d "client_id=demo-web-client" \ -d "client_secret=YOUR_SECRET" \ -d "code_verifier=ORIGINAL_VERIFIER_STRING"

授权服务器返回的响应包含 access_token、id_token、refresh_token 和 token_type。我专门打印了 id_token,并把它解析出来逐字段核对。整个过程最让我意外的点是,授权码只能用一次,第二次拿同一个 code 去请求令牌端点会直接报错,这个一次性约束是重要的安全防线。

4.4 ID Token 校验的完整步骤

拿到 ID Token 后,我按照规范文档的推荐步骤手动做了校验,这个步骤值得每个做接入的人亲手过一遍。第一步是解析 JWT 的三段结构,确认头部里的算法是否与预期一致,这里要特别提防算法混淆攻击,比如明明配置的是 RS256,结果收到一个 alg 为 none 的令牌。第二步是验证签名,用授权服务器的公钥对签名做验签。公钥可以通过 discovery 文档里的 JWKS 地址获取。第三步是校验 iss,必须与授权服务器的 issuer 标识完全一致。第四步是校验 aud,必须包含客户端的 client_id。第五步是校验 exp,并留出一定的时间偏移量,防止客户端和服务器之间的时钟偏差导致误判。第六步是校验 nonce,这个值必须和授权请求中发送的 nonce 一致,否则就可能是重放攻击。

我把这套校验逻辑写成了一段脚本,用库做 JWT 验签,再手动比对 iss、aud、exp。跑通的那一刻,我才真正意识到文档里反复强调的"信任边界"是什么意思,每个校验项都是在回答一个安全问题:这个令牌是你签发的吗?是发给我的吗?过期了吗?是本次请求产生的吗?

5. 我在学习过程中踩过的坑:常见问题排查实录

5.1 redirect_uri 不匹配,查了半天是个斜杠的锅

我在第一次配置客户端时,注册的 redirect_uri 是 http://localhost:3000/callback,但授权请求里因为拼接字符串时手滑多加了一个尾部斜杠,变成了 http://localhost:3000/callback/。授权服务器直接报错 redirect_uri_mismatch。这类问题排查时先别怀疑协议复杂,把注册值和请求值逐字符对比一遍,空格、斜杠、大小写都不能放过。

对应到日常工作中,如果用户反馈"登录跳回后页面报错",第一反应应该是去授权服务器的日志里看拒绝原因,大多数时候都是重定向地址不匹配。我建议团队在客户端注册环节就做一次强制格式化,把用户输入的 redirect_uri 统一去掉尾部斜杠,这样能从源头减少配置错误。

5.2 ID Token 校验失败,原因是对不上号

某次我用一个测试客户端收到了 ID Token,验签、日期都正常,但总是报 audience 不匹配。我对监控数据检查后发现,测试客户端注册的 client_id 是 demo-cli,但授权请求里我手动拼 URL 时误写成 demo-cli-test。授权服务器签发的 ID Token 里 aud 值就是 demo-cli-test,而我的校验逻辑里用的还是旧的 demo-cli,自然对不上。

audience 校验是 OIDC 安全模型里极重要的一环。如果客户端不校验 aud,攻击者可以用另一个客户端的 ID Token 来冒充合法用户。所以我后来都会把校验逻辑拆成独立模块,把客户端 ID 作为参数传入,而不是硬编码,减少这类低级失误。

5.3 PKCE 和 state 的典型使用误区

我在测试 PKCE 时踩过一个经典坑:先把 code_verifier 写在授权请求的前端代码里,后来为调试方便把 code_verifier 打到了日志里。结果发现日志系统会把请求参数原样记录,一旦日志泄露,攻击者同时拿到授权码和 code_verifier,PKCE 就形同虚设。正确做法是 code_verifier 只在内存中做短期保存,任何日志、上报链路里都不得出现。

state 参数的使用误区也很常见。有人把 state 当成简单的随机数,校验时不认真。state 的定位是 CSRF 防护,它绑定的是"本次授权请求与会话"的关系。校验逻辑应该是,回调里收到的 state 必须与发起请求时存放在会话里的 state 完全一致。如果发现自己写的是"只要不为空即通过",那这个防护等于没做。

6. 容易被忽略的安全实践与后续学习方向

6.1 几个值得马上改掉的习惯

每次令牌交换必须走 HTTPS 明文传输令牌,在任何环境里都不可接受。测试环境没有 HTTPS 证书,至少用本机回环地址模拟,不能拿真实的非加密 HTTP 接口对接生产平台。

Access Token 的权限范围要"最小化"。很多应用一申请就是全部权限,实际只用到其中一两个,一旦令牌泄露,攻击者能做的就太多了。每次授权请求只申请当前功能真正需要的 scope,长期来看是成本最低的安全投入。

Refresh Token 的保存位置和有效期设计也要重视。前端任何地方都不该出现 Refresh Token,它比 Access Token 权限更大、有效期更长,泄露等同整个账号授权被接管。移动端建议存在系统安全存储区,Web 端则放在服务端会话,由服务端代理续期。

6.2 OAuth2.1 与后续演进方向

OAuth2 的规范是老版本了,OAuth2.1 草案把多年来实践中总结的安全建议整合成了正式要求,它有几个明显变化:隐式流程被移除,密码模式被移除,PKCE 对所有类型的客户端都成为必选,精确的 scope 匹配、token 有效期限制也都被更严格地约束。

团队新项目如果现在开始做授权体系,与其按老 OAuth2 资料去搭,不如直接按照 OAuth2.1 的安全基线来设计,这样未来规范正式发布时迁移成本最小。另外可以关注 JWT 在访问令牌场景的使用边界,虽然很多平台用 JWT 作访问令牌,但 JWT 一旦签发在过期前无法撤销,适合配合短有效期和刷新令牌轮换机制使用,不能拿来当长期凭证。

这次学习记录先写到这。我自己最大的体会是,这类协议光靠看文档很容易产生"好像懂了"的错觉,真正动手跑一遍授权码加 PKCE 的流程、亲手验签一个 ID Token、再亲手把一个校验不过的错误改对,很多概念才真正连成线。下一篇我打算把刷新令牌的轮换机制和单点登录的实现细节单独拎出来做一组实验,到时候再继续把笔记补上。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询