OAuth2 认证原理

标签:网络安全首次发布:2026-07-22最近修改:2026-07-24
摘要

在很多系统里,“登录”“认证”“授权”“单点登录”“第三方账号登录”经常被放在一起讨论。OAuth2 也常被称为一种“认证方案”,但严格来说,OAuth2 本身是一个授权框架。它解决的核心问题是:一个应用如何在不拿到用户密码的情况下,安全地代表用户访问另一个系统里的受保护资源。

虽然很多人第一次学习 OAuth2 时,能很快背下流程:先跳转授权,拿到 code,再用 codeaccess_token,最后拿 access_token 访问资源服务器。但一放到真实项目里,就容易卡住:前端为什么只负责跳转?后端为什么还要参与?GitHub、Google 这种第三方平台到底扮演什么角色?stateredirect_uri、PKCE、OIDC 又分别解决什么问题?

这篇文章不只讲流程,而是从 OAuth2 背后的设计哲学讲起:它到底想保护什么、为什么要拆成这些角色、为什么要有这些字段,然后再落到“第三方登录后发表评论”这种真实业务场景。

OAuth2 解决的问题

先从一个普通问题开始:如果一个应用想访问另一个系统里的用户数据,应该怎么做?

在 OAuth2 出现之前,最直接但也最危险的做法,是让用户把账号密码交给这个应用。例如,一个图片打印服务想读取你的云盘照片,它可能要求你输入云盘账号和密码,然后自己登录云盘下载照片。

这个方案看起来简单,但风险非常大:

  1. 第三方应用拿到了用户密码,风险边界失控。
  2. 用户无法精细控制权限,比如只允许读取照片,不允许删除文件。
  3. 用户想撤销授权时,往往只能修改密码,影响所有正常登录。
  4. 第三方应用一旦泄露密码,攻击者可以完全接管用户账号。
  5. 被访问系统无法可靠区分“用户本人操作”和“第三方应用代操作”。

OAuth2 的设计思路是:用户不要把密码交给第三方应用,而是在可信的授权服务器上完成登录和授权。授权服务器给客户端发一张有限权限、有限时间、可撤销的访问令牌。客户端拿令牌访问资源,而不是拿用户密码访问资源。

你可以把 OAuth2 理解成一套“发临时通行证”的机制。密码仍然只交给原始服务或统一身份平台,客户端拿到的只是通行证。这张通行证可以限制权限、可以过期、可以撤销,泄露后的影响也比密码小得多。

所以 OAuth2 的核心不是“怎么登录”,而是“怎么安全地委托访问权限”。

OAuth2 的核心设计

OAuth2 看起来复杂,是因为它把一件危险的事情拆成了几个边界清楚的职责。理解这些边界,比死记流程更重要。

四个角色

OAuth2 有四个核心角色:

角色 负责什么 举例
资源拥有者 拥有资源,并决定是否授权 用户本人
客户端 想访问资源的应用 网站前端、移动 App、后端服务、命令行工具
授权服务器 认证用户、处理同意、发放令牌 统一登录平台、GitHub OAuth Apps、Google Identity
资源服务器 保存资源,并校验令牌后提供资源 用户资料接口、云盘文件接口、评论接口、订单接口

这四个角色是逻辑职责,不一定是四套独立系统。一个后端系统里,登录鉴权模块可以扮演授权服务器,评论接口可以扮演资源服务器。关键不是部署形式,而是职责边界:谁负责发令牌,谁负责校验令牌,谁负责提供资源。

sequenceDiagram
    autonumber
    participant U as 用户<br/>资源拥有者
    participant C as 业务前端<br/>客户端
    participant A as 登录鉴权服务<br/>授权服务器
    participant R as 业务资源接口<br/>资源服务器

    U->>C: 发起需要权限的操作
    C->>A: 请求授权
    A->>U: 展示登录与授权页面
    U->>A: 登录并同意授权
    A-->>C: 返回授权码或令牌
    C->>R: 携带令牌访问资源
    R->>R: 校验令牌、权限和资源归属
    R-->>C: 返回资源或写入结果

令牌的设计哲学

OAuth2 的核心是令牌机制。令牌不是密码,而是一张有边界的访问凭证。

一个合格的访问令牌通常具备这些特征:

  1. 有有效期,过期后不能继续访问。
  2. 有权限范围,也就是 scope,例如 read:userrepo:readcomment.write
  3. 可以被撤销,用户或平台可以主动取消授权。
  4. 可以绑定到某个客户端,避免被无限制复用。
  5. 可以被资源服务器校验,确认请求是否可信。

这背后的设计哲学可以概括为三句话。

第一,最小权限。客户端只应该拿到完成当前业务所需的权限,而不是拿到用户账号的全部能力。

第二,短期有效。访问令牌应该有过期时间,不能像密码一样长期有效。

第三,职责分离。授权服务器负责发令牌,资源服务器负责校验令牌和业务权限,客户端只负责按规则使用令牌。

OAuth2 不强制访问令牌必须是什么格式。它可以是不透明字符串,由资源服务器回查授权服务器确认有效性;也可以是 JWT,资源服务器通过签名、公钥和声明字段完成本地校验。

一个简化后的 JWT 访问令牌可能长这样:

json
{    "iss": "https://auth.example.com",    // 签发方    "sub": "user_123",                    // 用户标识    "aud": "comment-api",                 // 目标受众    "scope": "comment.write",             // 权限范围    "exp": 1784630000,                    // 过期时间    "iat": 1784626400                     // 签发时间}

资源服务器必须校验这些字段,而不只是判断“这个字符串能不能解码”。

授权码模式怎么工作

OAuth2 有多种授权方式,但实际开发中最常用、也最推荐的是授权码模式。现代前端和移动端还应该配合 PKCE 使用。

下面先用“用户在网站文章下发表评论”的抽象模型说明。这里暂时不引入 GitHub 或 Google,只看一个业务系统内部如何完成“登录授权、换取令牌、访问评论接口”的闭环。

sequenceDiagram
    autonumber
    participant U as 用户
    participant W as 网站前端
    participant A as 登录鉴权服务
    participant R as 评论接口

    U->>W: 点击“发表评论”
    W->>A: 跳转授权,申请 comment.write
    A->>U: 展示登录页和授权确认
    U->>A: 登录并同意发布评论权限
    A-->>W: 重定向回 callback,携带 code 和 state
    W->>A: 使用 code 和 code_verifier 换取 access_token
    A-->>W: 返回 access_token 和过期时间
    U->>W: 输入评论内容并提交
    W->>R: POST /comments,携带访问令牌或站点 Cookie
    R->>R: 校验登录态、用户身份和 comment.write
    R-->>W: 写入评论并返回结果
    W-->>U: 展示提交结果

第一步:发起授权

用户打开文章详情页,输入评论并点击“发表评论”。如果当前没有有效登录态,网站前端不应该直接让用户把账号密码交给评论页面处理,而是把浏览器跳转到授权服务器:

bash
GET https://auth.example.com/oauth/authorize?    response_type=code&    client_id=blog_frontend&    redirect_uri=https%3A%2F%2Fblog.example.com%2Foauth%2Fcallback&    scope=comment.write&    state=RANDOM_STATE&    code_challenge=PKCE_CHALLENGE&    code_challenge_method=S256

这串参数看起来长,其实每个字段都在回答一个安全问题:谁在申请、申请什么、授权后回到哪里、怎么防止回调被伪造、授权码被截获后还能不能被滥用。

参数 谁提供 作用 如果缺失或错误
response_type=code 客户端固定填写 告诉授权服务器“我要走授权码模式”,先返回 code,不要直接返回 access_token 授权服务器不知道客户端想走哪种流程,通常会拒绝请求;如果错误地使用旧式隐式流程,令牌可能暴露在浏览器地址栏或前端环境里
client_id 授权服务器在客户端注册时分配,客户端发起请求时携带 标识“哪个客户端正在申请授权”,例如网站前端、移动 App 或某个后端应用 授权服务器无法识别客户端,也无法校验它是否被允许使用当前回调地址和权限范围
redirect_uri 客户端注册时提前登记,发起请求时再次携带 告诉授权服务器授权完成后把浏览器重定向到哪里;授权服务器后台的记录值必须和前端提交的登记值严格匹配 如果不校验或校验过宽,攻击者可能把授权码引导到自己的站点,造成授权码泄露
scope 客户端按业务需要申请,授权服务器决定是否允许 表示申请的权限范围,例如 comment.write 只代表“发表评论” 如果缺失,可能只能拿到默认权限;如果设计过大,例如 all,会扩大泄露后的影响范围,也会让用户无法理解授权内容
state 客户端生成随机值,并在本地 session、Cookie 或缓存中保存 防止登录 CSRF;授权服务器回调时原样带回,客户端用它确认“这个回调确实对应我刚才发起的授权请求” 攻击者可能伪造或混淆授权回调,把错误的第三方账号绑定到当前用户会话上
code_challenge 客户端基于 code_verifier 计算生成 PKCE 的公开挑战值,用来把“发起授权的客户端”和“换令牌的客户端”绑定起来 如果授权码被中途截获,攻击者可能直接拿授权码去换取访问令牌
code_challenge_method=S256 客户端声明,授权服务器校验 表示 code_challenge 是用 SHA-256 从 code_verifier 计算出来的 授权服务器不知道如何校验 PKCE;如果降级为不安全算法,抗截获能力会变弱

这里最容易混淆的是 codestate 和 PKCE。code 是授权服务器稍后返回的一次性授权码,用来换取访问令牌。state 不是用来换令牌的,它只是客户端用来校验回调来源的随机值。code_challenge 则是提前交给授权服务器的“校验题”,后面换令牌时客户端必须拿出对应的 code_verifier,证明自己就是刚才发起授权的那个客户端。

第二步:用户登录并同意

授权服务器展示登录页,用户输入账号密码、扫码或使用其他登录方式。登录完成后,授权服务器可以展示授权页面,告诉用户当前客户端正在申请“发布评论”的权限。

用户同意后,授权服务器不会直接把访问令牌放到浏览器地址栏里,而是生成一个短期有效的一次性授权码,并把浏览器重定向回客户端:

bash
HTTP/1.1 302 FoundLocation: https://blog.example.com/oauth/callback?    code=AUTHORIZATION_CODE&    state=RANDOM_STATE

客户端收到回调后,必须先校验 state 是否和发起请求时保存的一致。如果不一致,应拒绝这次回调。注意,state 不参与换取 access_token,它的作用是在换令牌之前先判断这次回调能不能被信任。

第三步:用授权码换令牌

客户端校验 state 通过后,才拿授权码向授权服务器换取访问令牌:

bash
POST https://auth.example.com/oauth/tokenContent-Type: application/x-www-form-urlencodedgrant_type=authorization_code&code=AUTHORIZATION_CODE&redirect_uri=https%3A%2F%2Fblog.example.com%2Foauth%2Fcallback&client_id=blog_frontend&code_verifier=PKCE_VERIFIER

换令牌这一步真正使用的是 code,并配合 redirect_uriclient_idcode_verifier 等参数一起校验。授权服务器会检查:这个 code 是否存在、是否过期、是否已经使用过、是否属于这个 client_id、回调地址是否一致、code_verifier 是否能对应第一步提交的 code_challenge。这些条件都通过后,才会返回 access_token

如果是传统服务端 Web 应用,客户端还可能需要提交 client_secret。如果是浏览器前端、移动 App、桌面客户端这类无法安全保存密钥的公开客户端,就不应依赖 client_secret,而应使用 PKCE。

授权服务器校验授权码、回调地址、客户端身份和 PKCE 参数后,返回令牌:

json
{    "access_token": "ACCESS_TOKEN",    "token_type": "Bearer",    "expires_in": 3600,    "refresh_token": "REFRESH_TOKEN",    "scope": "comment.write"}

access_token 用于访问资源接口。refresh_token 用于在访问令牌过期后换取新的访问令牌,它的生命周期通常更长,因此必须更谨慎地保存和轮换。

第四步:访问受保护资源

用户真正提交评论时,客户端调用评论接口。常见做法是把访问令牌放在 Authorization 请求头里:

bash
POST https://api.example.com/commentsAuthorization: Bearer ACCESS_TOKENContent-Type: application/json{    "articleId": 1001,    "content": "这篇文章把授权码流程讲清楚了。"}

评论接口收到请求后,会校验令牌是否有效、是否过期、是否面向当前 API、是否包含 comment.write 权限,并确认当前用户是否有权操作目标资源。只有校验通过,接口才会写入评论;否则应返回 401 Unauthorized403 Forbidden

如果系统使用 HttpOnly Cookie 保存本站登录态,前端通常不需要手动拼 Authorization 头。浏览器会自动携带站点登录 Cookie,后端中间件解析 Cookie 里的会话或 JWT,再得到当前评论用户。这种做法更接近很多网站评论模块的真实实现。

PKCE 到底是什么

PKCE 的全称是 Proof Key for Code Exchange,可以理解为“授权码换令牌时的二次证明”。

为什么需要它?因为授权码会经过浏览器跳转回到客户端。浏览器地址栏、系统回调、移动端深链、代理日志,都可能成为授权码被截获的地方。传统服务端 Web 应用可以用 client_secret 保护换令牌过程:即使攻击者拿到了 code,没有 client_secret 也换不到令牌。

但浏览器前端、移动 App、桌面客户端没有办法安全保存 client_secret。这些客户端的代码和配置最终都在用户设备上,攻击者有机会逆向或读取。所以 OAuth2 需要一种不依赖固定密钥的保护方式,这就是 PKCE。

PKCE 的思路很简单:

  1. 客户端先生成一个随机字符串 code_verifier,自己保存起来。
  2. 客户端把 code_verifier 做 SHA-256 摘要,再做 Base64URL 编码,得到 code_challenge
  3. 发起授权请求时,客户端只把 code_challenge 发给授权服务器。
  4. 授权服务器返回 code
  5. 换令牌时,客户端把之前保存的 code_verifier 发给授权服务器。
  6. 授权服务器重新计算 code_challenge,看它是否和第一步保存的值一致。

如果攻击者只截获了授权码 code,但不知道 code_verifier,就无法通过换令牌校验。code_challenge 就像提前交给授权服务器的一道题,code_verifier 是之后交卷时必须拿出的原始答案。

所以 PKCE 保护的不是“用户有没有登录”,而是“拿着授权码来换令牌的人,是否真的是刚才发起授权请求的客户端”。

OAuth2、OIDC 和第三方登录

OAuth2 本身解决的是授权:允许客户端访问某些资源。它并不标准化“当前登录用户是谁、邮箱是什么、头像是什么、身份是否可信”这些认证语义。

这就是很多人混淆的地方:业务上我们经常说“使用 Google 登录”,但 OAuth2 原始语义并不是登录协议。为了让 OAuth2 能更标准地用于登录认证,OpenID Connect 出现了。

OIDC 是什么

OIDC 全称是 OpenID Connect。它建立在 OAuth2 之上,可以简单理解为:

OAuth2 负责发访问资源的通行证,OIDC 在此基础上增加了一张“身份证明”。

OAuth2 里的 access_token 主要回答这个问题:客户端能访问哪些资源?

OIDC 里的 id_token 主要回答这个问题:当前登录用户是谁?

一个典型的 OIDC 令牌响应可能是:

json
{    "access_token": "ACCESS_TOKEN",    "id_token": "ID_TOKEN",    "token_type": "Bearer",    "expires_in": 3600}

access_token 用来访问资源接口。id_token 用来告诉客户端“当前登录用户是谁”。客户端或后端应该校验 id_token 的签名、签发方、受众、过期时间、nonce 等字段,再建立自己的登录会话。

二者的区别可以这样理解:

概念 主要回答的问题 典型用途
OAuth2 这个客户端能不能访问某个资源? 授权访问 API、开放平台授权、服务间调用
OIDC 当前登录用户是谁? 第三方登录、单点登录、统一身份认证
access_token 能访问什么资源 调用资源服务器 API
id_token 用户身份是什么 建立登录态、读取用户身份声明

GitHub 登录常见做法是 OAuth2 + 用户信息接口:后端先拿 access_token,再调用 GitHub 的用户资料接口确认用户身份。Google 登录更常见的是 OAuth2 + OIDC:除了 access_token,还可以拿到 id_token 来确认用户身份。

不管是哪种方式,业务系统都不应该把第三方 access_token 直接当成本系统登录态。更稳妥的做法是:后端确认第三方身份后,创建或更新本地用户,再签发本站自己的登录态。

第三方登录后发表评论

现在把上面的概念放到真实业务里:一个网站允许用户使用 GitHub 或 Google 登录,登录后才能发表评论。

这时链路要拆成两段。

第一段是“业务后端和第三方平台之间的 OAuth2/OIDC 流程”。这时 GitHub 或 Google 是授权服务器,业务后端是 OAuth2 客户端。用户在第三方平台完成登录并同意授权后,第三方平台把 code 回调给业务后端。业务后端用 codeclient_secret 换取第三方令牌,再通过用户信息接口或 id_token 确认用户身份。

第二段是“业务系统自己的登录态”。业务后端确认第三方用户身份后,会在自己的数据库里创建或更新一个本地用户,然后签发属于本站自己的登录凭证,例如 HttpOnly Cookie 或服务端 session。之后评论接口校验的是本站自己的登录态,而不是 GitHub 或 Google 的 access_token

sequenceDiagram
    autonumber
    participant U as 用户
    participant W as 网站前端
    participant B as 业务后端
    participant P as 第三方平台<br/>GitHub / Google
    participant D as 业务数据库

    U->>W: 点击“使用第三方账号登录”
    W->>B: 跳转到后端登录入口,并携带登录后回跳地址
    B->>B: 生成 state,保存回跳地址
    B-->>U: 302 跳转到第三方授权页
    U->>P: 登录并同意授权
    P-->>B: 回调业务后端,携带 code 和 state
    B->>B: 校验 state
    B->>P: 使用 code + client_secret 换取第三方令牌
    P-->>B: 返回 access_token,必要时返回 id_token
    B->>P: 拉取或校验第三方用户信息
    P-->>B: 返回用户 ID、昵称、头像、邮箱
    B->>D: 创建或更新本站用户
    B-->>U: 写入本站登录 Cookie,并跳回文章页
    W->>B: 查询当前登录用户
    B-->>W: 返回本站用户信息
    U->>W: 输入评论并提交
    W->>B: 提交评论,浏览器自动携带本站 Cookie
    B->>B: 校验本站登录态,得到本地用户 ID
    B->>D: 写入待审核评论
    B-->>W: 返回提交成功

这个场景里有一个非常关键的边界:第三方令牌通常只在业务后端内部短暂使用,用来确认“这个第三方账号是谁”。它不应该直接交给网站前端长期保存,也不应该被评论接口当成本站登录态使用。

对评论模块来说,发表评论不是去访问 GitHub 或 Google 的资源,而是访问本站自己的评论资源。所以最终必须落到本站自己的用户表、会话和权限模型上。

前端、后端和第三方分别做什么

网站前端负责的是用户交互。它展示登录按钮,触发跳转,在登录回跳后查询当前用户,再把评论内容提交给业务后端。它不应该接触第三方平台的 client_secret,也不应该长期保存第三方 access_token

业务后端负责的是安全边界。它生成 state,拼接第三方授权地址,处理回调,校验 state,用 code 换取第三方令牌,拉取或校验用户身份,创建或更新本站用户,签发本站登录态,并在评论提交时校验本站登录态。真实项目里,OAuth2 登录最核心、最敏感的逻辑通常都在后端。

第三方平台负责的是外部身份和授权。它认证用户,展示授权页面,返回授权码,签发第三方访问令牌,并提供用户资料接口或 id_token。它能证明“这个人是某个 GitHub 或 Google 用户”,但它不负责判断这个用户在你的网站里能不能发表评论、评论是否需要审核、是否被风控拦截。

这样拆开后,评论模块的登录链路就清晰了:第三方平台负责证明外部身份,业务后端负责把外部身份映射成本系统用户,评论接口只信任本系统自己的登录态和权限判断。

实践要点与常见误区

OAuth2 的流程跑通只是第一步。真正决定系统是否可靠的,是边界有没有守住。

客户端需要先在授权服务器注册,获得 client_id,并配置允许的 redirect_uri。生产环境不要使用通配符回调地址,也不要允许任意跳转地址。回调地址校验过宽,会让攻击者有机会截获授权码。

发起授权前,客户端应生成随机 state 并保存到服务端 session、加密 Cookie 或其他可信存储中。回调时必须校验这个值。没有 state 校验,可能出现登录 CSRF 或账号绑定混乱。

公开客户端应使用授权码 + PKCE。浏览器前端、移动 App、桌面客户端都无法安全保存 client_secret,因此不能把 client_secret 当作唯一保护手段。PKCE 的意义就是防止授权码被截获后直接换取令牌。

令牌保存要谨慎。传统 Web 应用可以把第三方 access token 和 refresh token 保存在后端,再向浏览器发放自己的业务 session Cookie。这样浏览器不直接接触长期有效的刷新令牌,风险更可控。单页应用如果必须在浏览器处理令牌,应尽量缩短令牌生命周期,并避免把敏感令牌长期放在 localStorage 里。

资源服务器不能只检查请求里有没有 Authorization 头。它需要完整校验令牌签名、签发方、受众、过期时间、权限范围和必要的业务声明。如果令牌是不透明字符串,则应通过 introspection 接口或统一鉴权中间件查询令牌状态。

权限判断不能只看 scope。comment.write 只能说明客户端拥有发表评论的权限,不代表它可以冒充任意用户、修改任意文章或绕过评论审核。资源服务器仍然要确认令牌对应的用户或主体是否有权访问目标资源。

OAuth2 流程里有很多用户可感知的错误:用户拒绝授权、授权码过期、回调地址不匹配、scope 不被允许、令牌过期、刷新令牌失效等。用户看到的应该是可理解的操作提示,系统日志里则应保留完整错误码、请求 ID、客户端 ID、用户 ID 和时间线。

常见误区可以总结为五个:

  1. 把 access token 当成登录凭证到处传。访问令牌的语义是访问某个资源服务器,不一定适合作为当前系统的用户会话。
  2. 把第三方 access token 直接交给前端长期保存。多数第三方登录场景里,第三方 token 应由后端短暂使用,然后转换成本系统登录态。
  3. 忽略 stateredirect_uri 和 PKCE。这几个点是 OAuth2 跳转链路里最常见的安全边界。
  4. scope 设计过粗。只提供一个 all 权限虽然开发方便,但用户无法理解授权范围,平台也无法最小化风险。
  5. 只在网关校验 token,业务服务不做资源级判断。网关能确认请求来自合法客户端,但未必知道目标订单、文件、文章是否属于当前用户。

OAuth2 的优缺点

优点

  1. OAuth2 最大的优点是降低凭据暴露风险。用户密码不再交给第三方应用,客户端只拿到有限权限的访问令牌。

  2. 它也支持精细化授权。通过 scope、资源归属和授权记录,平台可以让用户知道自己授权了什么,并允许用户撤销某个应用的访问能力。

  3. OAuth2 还适合生态开放。一个平台想开放 API 给合作伙伴、插件、自动化工具和内部系统,就需要一种标准化的授权方式。OAuth2 提供了相对成熟的协议基础和广泛的生态支持。

缺点

  1. 不过,OAuth2 并不简单。它涉及跳转、回调、授权码、令牌换取、刷新、撤销、资源校验、客户端注册等多个环节。任何一个环节实现粗糙,都可能造成漏洞。

  2. 它也不是完整的身份认证协议。如果业务需要标准化登录身份,应使用 OIDC 或平台明确提供的身份接口,而不是把 access token 当成用户身份本身。

  3. 此外,令牌一旦泄露,在有效期内可能被滥用。因此 OAuth2 不能替代 HTTPS、XSS 防护、CSRF 防护、密钥管理、日志审计和风控。

总结

OAuth2 的本质,不是让第三方应用替用户保管密码,而是让授权服务器给客户端发一张受限制的、可回收的通行证。它的设计哲学是最小权限、短期有效、职责分离。

授权码模式解决的是“如何安全地把用户同意转换成访问令牌”。state 解决回调可信问题,redirect_uri 解决授权码回到哪里的问题,PKCE 解决授权码被截获后不能被滥用的问题。理解这些字段背后的安全目的,比记住字段名更重要。

OIDC 解决的是 OAuth2 在登录认证上的语义缺口。OAuth2 的 access_token 主要表达“能访问什么资源”,OIDC 的 id_token 才表达“当前用户是谁”。所以第三方登录场景里,业务系统通常要在确认第三方身份后,建立自己的用户、会话和权限模型。

放到真实业务里,可以记住这条主线:第三方平台负责证明外部身份,业务后端负责把外部身份转换成本系统用户,资源接口只信任本系统自己的登录态和权限判断。这样既能利用第三方账号登录的便利,也能把业务资源的访问控制牢牢收在自己的系统边界内。

Comments

评论区将在滚动到这里时加载。