1. 先从业务场景说起:为什么需要授权码模式
我们每天都在和各种登录打交道。最常见的例子是:某个第三方应用弹出一个界面,上面写着“使用某平台账号登录”,点下去之后会跳到该平台的登录页,登录完又自动跳回原来的应用,应用就能拿到你的头像、昵称,甚至能替你发一条动态。这个过程的专业名称叫 OAuth2 授权,而其中应用最广泛、安全性最高的流程,就是授权码模式(Authorization Code Flow)。
先说清楚一个问题:为什么不能直接让第三方应用拿账号密码来登录?很多刚接触的人会有这个疑问。答案很朴素——账号密码是身份凭证,是最高权限的东西,一旦暴露给第三方,对方就能用它做任何操作,包括改密码、删数据。更别说用户通常会在多个平台复用同一个密码,泄露一次等于全线崩溃。授权码模式的核心设计思想,就是“不要把密码给任何人,只给一个有限的、临时性的、可以撤销的权限证明”。这个思想贯穿了整个协议的设计细节。
OAuth2 本身定义了几种授权模式:授权码模式、隐式模式、密码模式、客户端凭证模式。其中授权码模式是唯一一个同时具备“前端交互 + 后端换票 + 凭证分离”三重结构的流程,也是各大开放平台默认推荐的模式。如果你要对接微信、某社交平台、某开放平台的开放登录,或者自建一个统一认证中心,授权码模式都是绕不开的核心知识点。
这篇文章会以我个人做过的几个真实项目为背景,从协议角色、完整流程、关键参数、代码实现到生产环境踩坑,把授权码模式讲透。不需要你把 RFC 6749 原文背下来,但看完之后,你能自己画清楚时序图、写出服务端配置、排查掉大多数调试中的疑难杂症,这就是这篇内容的价值。
适用读者画像:后端工程师、负责对接开放平台的研发同学、系统架构师,以及那些长期被“登录跳转闭环不起来”折磨的前端同学。不要求你已经懂 OAuth2,但如果你能区分 Cookie 和 Token 的区别,理解起来会非常顺畅。
2. 授权码模式的四把钥匙:角色、端点与授权范围
2.1 四个角色是谁,为什么必须分成四个
授权码模式里其实只有四类参与者。很多人被 OAuth2 搞晕,通常是因为把这四个角色和“前端、后端、数据库”这种架构思维强行对应,导致概念错位。
| 角色 | 通俗解释 | 在流程中干什么 |
|---|---|---|
| Resource Owner(资源所有者) | 就是你,那个拿着账号密码的普通用户 | 决定同意还是拒绝授权 |
| Client(客户端应用) | 就是你开发的第三方应用,可能是一个网站、App、小程序 | 引导用户去授权,拿到许可后请求令牌 |
| Authorization Server(授权服务器) | 管登录和授权的平台方 | 验证用户身份,发授权码,发令牌 |
| Resource Server(资源服务器) | 存用户数据并提供 API 的服务器 | 校验令牌是否有效,返回用户数据 |
但实际项目里,Authorization Server 和 Resource Server 往往是同一个系统,比如某平台既是授权中心又提供用户资料接口。大厂会拆成独立的服务,因为令牌校验的 QPS 可能极高,需要单独扩容。理解这四个角色,核心是要记住一件事:授权码模式是一条把“用户凭证”和“第三方应用”完全隔离的链路。
2.2 三个端点是什么,各自承担什么任务
授权码模式涉及三个 URL,它们分别是:
- 授权端点(Authorization Endpoint):负责处理“用户登录并同意授权”这一步,必须由资源所有者在浏览器里亲自操作,通常就是登录页。
- 令牌端点(Token Endpoint):负责用授权码换取访问令牌,必须由客户端后端服务器直接请求,浏览器不能参与。
- 重定向端点(Redirection Endpoint):也就是回调地址,授权服务器在用户同意后,把授权码通过浏览器重定向送回到客户端指定的位置。
为什么授权码模式强调这三个端点不能混用?我曾经在一个项目中看到有人图省事,把令牌请求也放在前端用 Ajax 直接调,结果授权服务器的 Cookie 根本不会带到后端,而且客户端密钥直接暴露在浏览器里,等于把保险柜钥匙放在大街上。授权码模式最核心的安全底线就在这里:授权请求走浏览器,令牌请求必须走服务端。
2.3 授权范围与作用域:授权是有限度的
除了角色和端点,还必须理解 scope。每次授权,用户不是把整个账号都交给客户端,而是只交出部分权限,比如只允许读取昵称和头像,不允许发动态。范围在授权请求中以 scope 参数声明,不同平台对具体取值有自己的约定,但套路基本一致。
我处理过一个对接案例:因为 scope 参数少传了一个范围值,导致后续调用接口一直报权限不足。排查半天发现不是令牌失效,而是授权时的 scope 就没有包含该能力。这是新手最容易忽略的点,授权范围不是在换 token 时决定的,而是在用户点击“同意”的那一刻决定的。授权码背后绑定的权限集合,在和用户交互时就已经写死了。
3. 授权码模式的完整链路:从跳转到回调的每一步
3.1 为什么流程中要出现两次重定向
我现在把授权码模式的七步流程完整走一遍,然后拆开讲每一步背后的设计意图。整个流程可以归纳为“两段式交换”:第一次是浏览器层面的“授权码交换”,第二次是服务器层面的“令牌交换”。
具体步骤是这样的:
- 客户端应用构造一个授权请求 URL,引导用户在浏览器中打开授权服务器的登录页。
- 用户输入账号密码并登录,然后授权服务器展示一个确认页,询问用户是否同意把某些数据授权给该客户端。
- 用户点击同意,浏览器被重定向到客户端的回调地址,此时 URL 上多了一个参数:code=xxx。
- 客户端后端接收到这个授权码,立即用它向令牌端点发起请求,同时附带客户端凭证。
- 令牌端点校验通过后,返回 access_token 和 refresh_token。
- 客户端使用 access_token 调用资源服务器接口,获取用户数据。
- 如果 access_token 过期,客户端用 refresh_token 重新换取,避免用户再次授权。
3.2 第一步到第三步:授权码为什么不是令牌本身
用户登录之后,授权服务器有两种选择:直接把 access_token 放在 URL 里重定向给客户端,或者先给一个临时授权码。为什么选择了后者?
第一个原因是“安全边界”。URL 会被浏览器历史记录、代理服务器日志、浏览器插件等多方记录下来。如果直接传令牌,等于把长期有效的凭证暴露在整个链路的阳光下。授权码则不同,它五分钟就过期,而且必须在后端配合客户端密钥交换一次,即使中途被劫持,攻击者拿一个五分钟内过期的码,没有密钥也换不到令牌。
第二个原因是“可控性”。授权码是一次性的,换完后立即失效。就算开发时调试手滑发起了两次交换请求,第二次必然失败。这种设计保证了“同一个码不能重复兑换”,极大地降低了令牌被重放的风险。
第三个原因是“客户端类型差异”。OAuth2 里有“机密客户端”和“公开客户端”的概念。带后端的应用是机密客户端,可以安全保存密钥;纯前端的应用没有后端,是公开客户端,无法安全保存密钥。授权码模式天然地为机密客户端设计,后来为了兼容公开客户端,才延伸出 PKCE 流程,这个后面单独讲。
3.3 第四步到第五步:令牌端点的换取细节
授权码流到客户端后端后,接下来这段请求必须包含几个关键参数:
POST /oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=这里的授权码 &redirect_uri=https://client.com/callback &client_id=这里的客户端ID &client_secret=这里的客户端密钥需要注意的重点:redirect_uri 在这里是必传项,而且必须和第一步生成授权链接时传的地址完全一致。授权服务器会校验这个匹配关系,这是防止授权码被第三方截获后拿去买单的关键屏障之一。
此外,client_secret 是整个流程中最重要的机密信息,只能存在于客户端后端环境变量或配置中心里。前端拿不到,日志里不能打印,Git 提交记录里绝对不能出现。我在代码评审中发现过有人把 client_secret 硬编码在前端 JS 里,这等于把 ATM 密码贴在取款机屏幕上。
3.4 access_token 与 refresh_token:为什么需要两种凭证
令牌端点返回的内容大致长这样:
{ "access_token": "一串很长的密文或者JWT", "token_type": "Bearer", "expires_in": 7200, "refresh_token": "另一串更长的凭证", "scope": "read_profile" }access_token 是为了调用资源接口的短期凭证,通常几十分钟到几小时有效。refresh_token 是长期凭证,用于在 access_token 过期后无声续期。为什么要拆成两个?核心原因是“暴露面最小化”。access_token 会被发送到资源服务器、可能出现在日志中、可能被第三方服务引用,暴露风险高,所以有效期必须短。refresh_token 只和授权服务器打交道,除非客户端需要续期,否则它不会离开服务器之间的安全通道。
在实际系统设计中,refresh_token 通常还会绑定设备或客户端环境,一旦检测到异常使用环境,直接拒绝续期,要求用户重新授权。这是很多平台“我明明没退出但突然要重新登录”背后的原因,多半是 refresh_token 因环境变化失效,触发了重新授权流程。
3.5 state 参数:防止跨站请求伪造的武器
授权码模式中一个容易被忽略但极其重要的参数是 state。它在第一步生成授权链接时由客户端生成一个随机字符串,暂存在会话中,然后作为参数传给授权服务器。授权服务器在重定向回来时会原样携带这个 state,客户端需要对比它和之前暂存的值是否一致。
如果不校验 state,攻击者可以构造一个合法的回调 URL,诱导用户完成授权后把授权码带到攻击者指定的地址,从而完成一次登录劫持。这种攻击在 OAuth2 里叫做 CSRF 攻击——攻击者不需要拿到授权码,只需要让用户的浏览器带着授权码访问回调接口,就能绑定自己的账号和受害者的身份。
我在模拟项目里专门复现过这个场景:两个不同的浏览会话同时进行授权,如果不校验 state,后回调的授权码可能绑定到前一个会话的用户身上。加上 state 校验之后,这种串号问题瞬间被拦住。强烈建议:只要对接授权码模式,state 参数必须自己实现一遍,不要依赖平台是否强制。
3.6 PKCE:公开客户端的安全增强方案
授权码模式最早被批评的一点是:如果客户端是纯前端应用,client_secret 无处安放,整个令牌交换环节形同虚设。于是后来出现了 PKCE(Proof Key for Code Exchange),它让客户端每次授权时自己生成一个随机的 code_verifier,再通过哈希算法生成 code_challenge 一起传给授权服务器;换取令牌时,客户端必须提供原始 code_verifier,授权服务器重新计算 challenge 并比对。
code_verifier = 随机生成的43~128位字符串 code_challenge = BASE64URL(SHA256(code_verifier))平时做单页应用(SPA)或小程序登录时,我建议即使你的平台没有强制要求 PKCE,也主动加上。成本只是多两个参数,却把授权码模式的安全等级提高了一大截。目前各大开放平台都已经支持 PKCE,部分平台甚至已经取消了对公开客户端的纯授权码支持。
4. 最小可运行实现:从零到一发真实令牌
4.1 为什么推荐用现成框架而不是自己造轮子
看原理、画流程图是一回事,真正把流程跑起来是另一回事。我自己早期踩过一个大坑:试图从零用 Servlet 手写整个授权服务器,结果连“登录态 + 授权码生成 + 一次性失效”这套组合搞了两周,还是到处是漏洞。后来改用现成框架,半小时就把完整流程串起来了。
如果你只是客户端对接方,没必要自己实现授权服务器,用框架自带的授权服务能力即可。绝大多数的业务场景是“我要让我的应用支持第三方登录”,也就是扮演客户端角色。这部分用框架的客户端模块或者直接用 HTTP 工具手动调就够。
4.2 客户端侧用现成框架实现对接
以 Java 技术栈为例,Spring Security 家族里支持 OAuth2 客户端接入,配置起来非常干净。下面这个配置是基于 Spring Boot 的常见写法。
spring: security: oauth2: client: registration: my-oauth-login: provider: my-oauth-provider client-id: your-client-id client-secret: your-client-secret authorization-grant-type: authorization_code redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" scope: read_profile provider: my-oauth-provider: authorization-uri: https://auth.example.com/oauth/authorize token-uri: https://auth.example.com/oauth/token user-info-uri: https://api.example.com/userinfo user-name-attribute: id这段配置要理解三个关键点:
- redirect-uri 里的
{baseUrl}和{registrationId}是占位符,框架会自动替换成当前部署地址和注册ID。这意味着换环境部署时,回调地址会自动适配,不用手工改。 - authorization-grant-type 必须是
authorization_code,这决定了框架走完整授权码链路。 - user-info-uri 是换到令牌之后获取用户信息的接口,框架会用它拿到用户的唯一标识去建立本地会话。
只用这段配置,Spring Security 会自动帮你做完“生成授权链接 → 处理回调 → 换取令牌 → 获取用户信息 → 建立登录态”的全部工作。对于大多数业务方而言,这就是授权码模式落地的最高效形态。
4.3 如果不想用框架,手动跑一遍也无妨
有时候对接一个内部自研的认证中心,框架里的自动流程反而不好排查问题。此时手动用 HTTP 客户端把两个请求打一遍,反而能更直观地理解协议。我以一个基于 HTTP 工具的命令行实现为例。
第一步,在浏览器里打开授权页,注意这里的参数:
https://auth.example.com/oauth/authorize?response_type=code&client_id=your-client-id&redirect_uri=https%3A%2F%2Fclient.example.com%2Fcallback&scope=read_profile&state=xyz123第二步,用户同意后浏览器会跳转到类似地址:
https://client.example.com/callback?code=临时授权码&state=xyz123第三步,用curl在服务器上发起令牌请求:
curl -X POST "https://auth.example.com/oauth/token" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=临时授权码" \ -d "redirect_uri=https://client.example.com/callback" \ -d "client_id=your-client-id" \ -d "client_secret=your-client-secret"第四步,拿着返回的 access_token 请求用户信息接口:
curl "https://api.example.com/userinfo" \ -H "Authorization: Bearer 返回的访问令牌"这套手动流程的价值在于:当你面对一个对接不上的开放平台时,可以先通过 curl 确认授权服务器本身是否正常,再回头看框架配置,问题定位速度会快很多。我每次接手新的第三方登录对接,第一件事都是先用 curl 把链路裸调通,再做代码集成。
4.4 授权服务器的实现思路与最小配置
如果你需要自己搭建一个授权服务器,我建议直接用框架的授权服务器模块,而不是自己手写协议层。授权服务器最核心的部分有三块:客户端注册信息管理、授权码生成与一次性校验、令牌生成与校验。以 Spring Authorization Server 为例,核心配置如下。
@Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { http.securityMatcher("/oauth/**") .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2.jwt()); return http.build(); }这段配置表达的含义是:围绕/oauth/**路径暴露授权和令牌端点,所有访问这些端点的请求都必须先通过安全校验。更具体的客户端注册信息、令牌存储方式、JWT 签名密钥等,都需要根据业务场景单独设置。
但必须提醒一句:授权服务器的安全要求远高于普通业务接口,令牌签名算法、刷新策略、客户端凭证加密存储、审计日志等环节,任何一处薄弱都可能成为系统性漏洞。如果公司没有专门的认证团队,优先使用成熟云服务或开源框架,而不是从零手写。
5. 关键参数与决策点:授权码到底该设多长生命周期
5.1 授权码、访问令牌、刷新令牌的生命周期设计
授权码模式里的三类凭证各有生命周期,很多人在设计自己的认证系统时,把三者的有效期混为一谈。
| 凭证 | 建议有效期 | 可以刷新吗 | 设计原因 |
|---|---|---|---|
| 授权码 | 1~5分钟 | 否,一次性 | URL可被日志捕获,生命周期越短越安全 |
| access_token | 30分钟~2小时 | 否,靠refresh续 | 会被发送到外部服务,短生命周期控制泄露影响 |
| refresh_token | 7天~30天 | 可以,一次性旋转 | 长期凭证不能无限期有效,需要定期回到用户侧授权 |
关于 access_token 的有效期,业界存在两种取向。一种偏向短令牌(15分钟~30分钟),配合 refresh_token 自动续期,用户体验几乎无感,安全性最高。另一种偏向长令牌(24小时甚至更长),适合内部系统间调用,减少刷新次数。个人经验是:对外提供 API 的令牌尽量短,因为外部系统的日志、缓存、第三方组件都会“接触”这个令牌,暴露面不可控;内部服务使用的令牌可以适当放宽。
5.2 refresh_token 旋转策略:为什么不能一个 refresh 用到天荒地老
有些系统为了省事,refresh_token 设计成永久有效,这会导致两个问题。一是凭证泄露后无法收场,二是用户权限变更(比如用户被禁用、改了密码)后,旧的 refresh_token 可能还能继续换取新的 access_token,形成安全漏洞。
正确的做法是 refresh_token 旋转:每次使用 refresh_token 换取新令牌时,旧的 refresh_token 立即失效,并且返回一个新的 refresh_token。如果检测到同一个 refresh_token 被使用两次,通常意味着凭证已经被复制,此时应撤销该用户的所有令牌并要求重新授权。
在实现上还有一个细节:refresh_token 只能通过 HTTPS 传输,存储时必须加密,数据库中不能明文保存。很多安全审计会专门检查这一点。
5.3 令牌中该放什么,不该放什么
access_token 的编码方式主要有两种:不透明字符串和 JWT。不透明字符串就是随机生成的一长串,授权服务器需要查库或查缓存才能确认有效性;JWT 自带用户信息和过期时间,资源服务器可以直接解码校验,无需回源授权服务器。
如果你用的是 JWT,记住一个边界:JWT 只是“可验证的凭证”,不是“数据的保险箱”。不要在 JWT 里放敏感字段,比如身份证号、手机号、家庭住址。JWT 默认只是 Base64 编码,不是加密,任何人都能解码看到 payload。有次代码评审看到有人往 JWT 里塞了用户的完整手机号,这种数据等于明文暴露在每次请求的 Header 中。真要放敏感数据,必须使用 JWE 加密方案,但绝大多数场景没必要。
5.4 并发场景下的授权码校验
授权码的存储也是很多人忽略的环节。最早期有人把授权码存在内存 Map 里,单机没问题,一旦授权服务器多实例部署,用户请求落在 A 实例,回调落在 B 实例,B 根本查不到授权码,直接报错。
生产环境建议把授权码存储在 Redis 等共享存储中,且必须设置过期时间,换成功立刻删除。Redis 的原子操作天然适合这种一次性凭证的消费场景:
SET code:xxx random_value EX 300 NX DEL code:xxx用一个随机值配合 NX 参数,可以保证授权码只能被消费一次。即使两个请求同时到达,也只有其中一个能成功删除并拿到令牌,另一个返回无效授权码错误。
6. 实战中的安全边界与常见误区
6.1 redirect_uri 校验不严,平台被当跳板
redirect_uri 是授权码模式里最容易被忽视又最危险的地方。如果授权服务器在校验回调地址时只做“前缀匹配”或者干脆不校验,攻击者可以构造一个恶意回调地址,诱导用户完成授权后把授权码送到攻击者服务器。
我曾经在某内部系统的授权服务器代码里看到这样的校验逻辑:只要回调地址包含客户端注册的域名就通过。这相当于https://evil.com/?domain=client.com也能通过校验,因为字符串里确实包含 client.com。正确的原则是:必须做完整 URL 精确匹配,包括协议、域名、路径,甚至 query 参数的次序都不能带偏。
6.2 把令牌放在 URL 里的习惯要改掉
很多系统为了图方便,会在前端把 access_token 直接拼到 URL 上调用资源接口。比如https://api.example.com/user?token=xxx。这种做法的风险在于 URL 会被完整记录在网关日志、浏览器历史、反向代理日志中。只要日志系统稍有疏漏,令牌就等于公开了。
正确的传递方式是把 access_token 放在 HTTP Header 的 Authorization 字段中,这也是多数平台的硬性要求:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...Bearer 前缀是 RFC 6750 约定的令牌类型标记,资源服务器看到它就知道后面跟的是访问令牌。不要自己在旁边发明新的 Header,也不要放在 Cookie 里。放在 Cookie 会引入 CSRF 风险,还需要额外处理跨域问题,纯属给自己找麻烦。
6.3 记住一点:授权码模式不等于单点登录
有些人把 OAuth2 授权码模式和单点登录(SSO)混为一谈,觉得支持了授权码模式就等于做了统一登录。其实两者关注点不同。OAuth2 解决的是“授权”问题——第三方应用能否访问用户的某些数据;SSO 解决的是“认证”问题——用户在一处登录后,访问其他系统无需重复登录。
真正成熟的方案通常是两者结合:授权服务器同时充当身份提供商,用户登录成功后建立会话,再通过 OAuth2 协议向各接入系统颁发令牌。如果在设计初期就把两者区分开,后续扩展接入方时会顺利很多。
7. 高频报错与排查方法实录
7.1 invalid_grant: 授权码无效或已过期
这个报错在调试中出现的频率最高。原因可能有几个:授权码一次性使用后未及时刷新页面导致重复提交;授权码确实超过有效期;redirect_uri 与授权请求时不一致。
排查思路:先确认是不是重复提交。多数回调页面刷新导致的报错都是这个原因。再看时间,确认授权码生成和使用的间隔是否在有效期内。最后逐一核对 redirect_uri,一个字符都不能差。
7.2 redirect_uri_mismatch: 回调地址不匹配
大多数情况是授权服务器中注册的回调地址与请求参数不一致。最容易踩的坑是:注册时写了https://client.com/callback,但实际授权请求里传的是http://client.com/callback,协议不同也算不匹配。还有的注册时末尾有斜杠,实际请求没有,也会失败。
建议把回调地址的注册和填写规范化,在授权服务器管理后台把地址全部以“协议 + 域名 + 路径”完整形式维护,并在测试环境时就固定下来。开发环境、预发环境、生产环境建议使用不同的 client_id 注册,不要混用一个。
7.3 unauthorized_client: 客户端没有权限使用该模式
这个报错说明客户端注册信息中未开启授权码模式的授权类型。有些平台管理后台需要单独勾选授权方式,默认只开了部分模式。检查授权服务器里给该客户端配置的 authorized_grant_types 是否包含 authorization_code。
7.4 回调后浏览器一直停在空白页
回调接口返回了但前端没有处理 code 参数就去请求后端了。最常见的原因是前端拿着 URL 里的 code 直接发起 Ajax 请求,但没有把必要的 state 一起传回来,后端校验 state 失败后,返回了错误而没有重定向到成功页面。
正确做法是:前端拿到完整回调 URL 参数后,原样把 code 和 state 传给后端,所有后续逻辑由后端完成,前端只负责展示结果。还有一次性提醒:回调 URL 中的 code 不要在前端进行编码或解码操作,直接字符串拼接传参即可。
7.5 access_token 拿到了,但调用接口返回 401
授权成功但访问资源接口失败,大多数是 scope 的问题。在授权时声明的 scope 与实际访问需要的权限不匹配,授权服务器签发的 access_token 没有包含目标接口的访问权限。需要检查授权请求中的 scope 参数,必要时查阅开放平台文档,确认目标接口需要的权限项。
另一个常见原因是 Resource Server 与 Authorization Server 之间的密钥协商不正确。如果使用 JWT 签名方式,资源服务器需要用授权服务器的公钥验签或者配置相同的对称密钥。我遇到过配置了相同密钥但算法不一致的情况,HS256 和 RS256 混用,排查了很久。
8. 一些值得记住的工程习惯
这些年做授权相关项目,我总结了一些值得长期坚持的习惯。
第一,授权码模式的调试环境尽量真实化。不要在本地随便用一个回环地址,而是配一个内网可访问的域名并添加映射。很多问题在 localhost 下不暴露,部署到测试环境就出现回调地址不匹配或 Session 丢失的问题。提前用真实域名跑通全链路,能省一半调试时间。
第二,日志不要打印完整令牌。即使是为了排查问题,也只用打印令牌的前几位和末四位,比如token: abcd****xyz0。完整令牌一旦出现在日志系统里,就是一个长期潜伏的泄露风险点。
第三,授权码模式的测试用例要覆盖并发、过期、重放三类场景。具体来说:同一授权码并发兑换是否只有一个成功;过期授权码是否被拒绝;同一授权码是否无法兑换两次。这三类用例是面试官喜欢问的,也是生产环境最容易出问题的真实场景。
第四,给前端提供的登录初始化接口,建议由后端生成授权 URL 并下发。这样可以把 state 的生成、会话绑定、client_id 的注入集中收敛到后端代码中,前端只负责“跳转”。我见过前端自己拼授权 URL 的项目,state 没有存下来,回调校验全是空的,这个口子等于是给攻击者留了门。
关于授权码模式,我想说的大致就是这些。协议本身不算复杂,但真正落地的时候,每一层都有需要讲究的细节。如果你正在负责一个开放平台的接入,或者正在搭建统一认证中心,把这些细节都过一遍,至少能避开 80% 的坑。