200字
OAuth 2.0 / 单点登录:互联网的“信任借条”系统
2025-11-30
2025-12-03

OAuth 2.0 / 单点登录:互联网的“信任借条”系统

当你用微信登录一个小程序时,背后发生了什么?
—— 一次关于权限、边界与信任的深度拆解


目录:我们将走过这条路


序幕:一个尴尬的早晨

场景还原

想象这个早晨:

  1. 你发现一个新出的“猫咪健康管理”小程序,想试试。
  2. 小程序让你注册。“又要填邮箱、密码、验证码…”你皱眉。
  3. 突然看到:“使用微信登录”按钮。
  4. 点击 → 跳转到微信 → 确认授权 → 回到小程序,已自动登录

你舒了口气,继续撸猫。但作为开发者,我心里想的是:

“微信凭什么相信这个小程序?小程序又怎么知道我是谁?我的微信密码有没有泄露?如果我不想让它访问我的微信好友列表怎么办?”

这些问题的答案,就是OAuth 2.0和单点登录(SSO)。

核心比喻:图书馆与访客卡

让我们建立一个贯穿全文的比喻:

  • = 图书馆读者(用户)
  • 微信 = 图书馆总馆(身份提供商,Identity Provider,IdP)
  • 猫咪健康小程序 = 分馆或合作书店(服务提供商,Service Provider,SP)
  • 你的微信账号密码 = 总馆借书证+密码(主凭证)
  • OAuth Token = 总馆开的“访客推荐信”(临时授权)
  • 单点登录 = 一张借书证在所有分馆通用

关键洞察
OAuth解决的是**“分馆如何在不拿到你借书证密码的情况下,确认你是总馆的合法读者”
SSO解决的是
“为什么我用一张借书证就能进出所有分馆”**。

它们经常一起出现,但本质上是两个不同的问题


第一章:OAuth 2.0——不是登录协议

这是最大的误解!我见过无数文章说“OAuth是一种登录方式”,这不够准确。

OAuth 2.0的真实身份

OAuth 2.0是一个授权框架(Authorization Framework),核心解决:
“如何让一个应用(第三方)在有限范围、有限时间内,代表用户访问另一个应用(资源服务器)的资源,而无需拿到用户的密码。”

用图书馆比喻:
总馆(微信)给分馆(小程序)开一张纸条:“持此纸条者可借阅3本养生类书籍,有效期7天”。
分馆凭纸条借书给你,但纸条上没写你的借书证密码

OAuth 2.0的核心角色

角色 官方称呼 图书馆比喻 微信登录实例
资源所有者 Resource Owner 读者(你) 微信用户
客户端 Client 分馆/合作书店 猫咪健康小程序
授权服务器 Authorization Server 总馆服务台 微信OAuth服务器
资源服务器 Resource Server 总馆藏书库 微信API(获取用户信息)

为什么需要OAuth?三个血泪教训

  1. 密码泛滥问题2008年以前,很多网站会让你直接输入Gmail密码来“导入联系人”。结果:你的Gmail密码存在几十个小网站数据库里,任何一个被黑,全部遭殃。
  2. 权限失控问题一个小游戏要求“访问你的全部Gmail邮件”——它真的需要吗?但当时没有“部分授权”概念,要么全给,要么不用。
  3. 难以撤销问题
    给了权限后,想收回怎么办?只能改密码——但这样所有其他服务也失效了。

OAuth 2.0(2012年成为标准)就是为了解决这些问题诞生的。它的前身OAuth 1.0太复杂,就像第一代借书系统要盖五个章,OAuth 2.0简化为“扫码授权”。


第二章:单点登录(SSO)——一把钥匙开所有门

SSO要解决的问题

想象公司内部:

  • 邮箱系统:mail.company.com(要登录)
  • 报销系统:expense.company.com(要登录)
  • 文档系统:docs.company.com(要登录)
  • 人力系统:hr.company.com(要登录)

员工疯了:“我每天要登录四次?密码还要求不一样!”

SSO(Single Sign-On) 就是:一次登录,到处通行

SSO的核心机制

sequenceDiagram participant U as 用户 participant A as 应用A participant C as 认证中心(SSO服务器) participant B as 应用B U->>A: 访问应用A A->>U: 发现未登录,重定向到认证中心 U->>C: 在认证中心登录(第一次) C->>U: 登录成功,带回Token U->>A: 带着Token访问应用A A->>C: 验证Token有效性 C->>A: Token有效 A->>U: 进入应用A U->>B: 访问应用B B->>U: 发现未登录,重定向到认证中心 U->>C: 认证中心发现已登录 C->>U: 直接带回新Token(无感) U->>B: 带着新Token访问应用B B->>C: 验证Token C->>B: 有效 B->>U: 进入应用B

SSO的实现方式

常见的有三种:

1. 基于Cookie的共享Session

最简单的内网SSO:

# 所有子域名共享顶级域名Cookie
server {
    server_name mail.company.com;
    location / {
        # 设置Cookie domain为 .company.com
        add_header Set-Cookie "session_id=abc123; Domain=.company.com; Path=/";
    }
}

# expense.company.com 能读到同一个Cookie

2. 中央认证服务(CAS协议)

经典的企业SSO方案:

  1. 用户访问应用 → 重定向到CAS服务器
  2. 登录后,CAS发Ticket(临时票证)
  3. 应用用Ticket向CAS验证
  4. CAS返回用户信息

3. 基于SAML(安全断言标记语言)

企业级标准,XML-based,配置复杂但功能强大。

关键点:SSO关注的是认证(Authentication),即“你是谁”。
OAuth关注的是授权(Authorization),即“你能做什么”。


第三章:当OAuth遇到SSO

这是最精彩的部分!现实世界中,它们经常联手。

现代SSO的常见实现:OAuth 2.0 + OpenID Connect

OpenID Connect(OIDC) 是建立在OAuth 2.0之上的认证层。你可以理解为:

  • OAuth 2.0 = 授权框架(我能做什么)
  • OIDC = OAuth 2.0 + 用户身份信息(我是谁)

OIDC的核心扩展

OAuth 2.0原本不关心用户是谁,只关心权限。OIDC加了:

  1. ID Token:一个JWT,包含用户身份信息(姓名、ID等)
  2. UserInfo Endpoint:专门获取用户信息的API
  3. 标准声明字段sub(用户ID)、emailname
// OIDC的ID Token示例(JWT格式)
{
  "iss": "https://accounts.google.com",  // 签发者
  "sub": "110169484474386276334",       // 用户唯一ID(关键!)
  "aud": "1234987819200.apps.googleusercontent.com", // 受众(客户端ID)
  "iat": 1516239022,
  "exp": 1516242622,
  "email": "user@gmail.com",
  "name": "张三",
  "picture": "https://lh3.googleusercontent.com/..."
}

为什么这个组合赢了?

  1. OAuth 2.0成熟:大家都已实现
  2. JWT标准化:ID Token用JWT,易解析验证
  3. 移动端友好:比SAML的XML简单太多
  4. 生态系统:所有大厂(Google、微信、GitHub)都支持

今天你说“用OAuth做SSO”,通常指的是OAuth 2.0 + OIDC方案。


第四章:四种“借钥匙”的姿势

OAuth 2.0定义了四种授权流程(Grant Type),对应不同场景。这是理解OAuth的关键。

1. 授权码模式(Authorization Code)——最安全、最常用

场景:有后端的Web应用(如猫咪健康小程序的后台服务)

步骤:
1. 用户点击“微信登录”
2. 跳转到微信授权页面(带client_id、redirect_uri等参数)
3. 用户确认授权
4. 微信重定向回小程序的redirect_uri,带上**code**(授权码)
5. 小程序后端用code + client_secret向微信换access_token
6. 微信返回access_token
7. 小程序用access_token调用微信API获取用户信息

为什么安全

  • client_secret存在后端,不暴露给浏览器
  • code是短期凭证,即使被拦截,没有 client_secret也换不了token
  • 适合保密的客户端(有后端服务器)

2. 隐式授权模式(Implicit)——给纯前端应用

场景:单页面应用(SPA),没有后端或后端不参与认证

步骤:
1. 用户点击登录
2. 跳转到授权页面
3. 用户确认
4. 重定向回应用,**access_token直接出现在URL片段#后面**
5. JavaScript从URL提取token使用

为什么设计成这样
SPA没有后端,无法安全存储 client_secret,所以跳过“用code换token”的步骤,直接给token。

但有问题:Token在URL中可能被泄露(浏览器历史、Referer头等)。
现代推荐:用授权码模式+PKCE(下面讲)代替隐式模式。

3. 密码模式(Resource Owner Password Credentials)——高度信任时用

场景:自家公司的移动端App登录自家服务

用户直接提供用户名密码给客户端
客户端用这些凭证直接换token

看起来简单,但有风险:客户端能拿到用户明文密码!只适用于绝对信任的客户端,比如:

  • 微信官方App登录微信服务
  • 公司官方App登录公司内部系统

绝不用于:第三方应用。

4. 客户端凭证模式(Client Credentials)——机器对机器

场景:后台服务访问API,没有用户参与

客户端用client_id + client_secret直接换token
这个token代表客户端自身,不是任何用户

典型用途

  • 定时任务调用API
  • 微服务间通信
  • 数据分析后台拉取数据

特别嘉宾:PKCE(Proof Key for Code Exchange)

解决的问题:纯前端应用(移动App、SPA)用授权码模式时,如何防止授权码被拦截冒用。

机制(比喻版):

  1. 你去借书前,先自己想一个暗号(code_verifier),并计算它的“指纹”(code_challenge)。
  2. 你告诉图书馆:“我要借书,这是我的指纹”。
  3. 图书馆给你一张借书券(授权码)。
  4. 你回来时说:“我要用这张券借书,暗号是XXX”。
  5. 图书馆计算暗号的指纹,比对之前记录的指纹,一致才给书。

技术实现

// 前端生成
const codeVerifier = generateRandomString(); // 原始暗号
const codeChallenge = sha256(codeVerifier); // 指纹

// 跳转授权时带上challenge
redirectToAuth(`?code_challenge=${codeChallenge}&...`);

// 换token时带上verifier
exchangeCode(`?code=授权码&code_verifier=${codeVerifier}`);

现代最佳实践

  • 原生App → 授权码 + PKCE
  • 单页面应用 → 授权码 + PKCE
  • 传统Web应用 → 授权码

第五章:一次完整的微信登录之旅

让我们追踪一次真实的“微信登录”流程,把前面所有概念串联起来。

角色设定

  • 用户:想用“猫咪健康”小程序
  • 微信:身份提供商(IdP)
  • 猫咪健康小程序:第三方应用(RP,Relying Party)

前置准备:小程序注册

  1. 开发者去微信开放平台注册“猫咪健康”小程序。
  2. 获得 AppID(OAuth中的client_id)和 AppSecret(client_secret)。
  3. 配置授权回调域名(redirect_uri),如 https://cat-health.com/oauth/callback

第一幕:前端发起授权

用户在小程序点击“微信登录”:

// 前端构造授权URL
const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?
  appid=APPID&
  redirect_uri=${encodeURIComponent('https://cat-health.com/oauth/callback')}&
  response_type=code&
  scope=snsapi_userinfo&  // 请求获取用户信息权限
  state=random_string`;   // 防CSRF

// 跳转(微信内嵌浏览器直接跳,外部浏览器显示二维码)
window.location.href = authUrl;

关键参数

  • scope=snsapi_userinfo:请求获取用户头像、昵称等
  • state:随机字符串,防止CSRF攻击(回调时校验)

第二幕:用户授权

  1. 跳转到微信授权页面(可能在微信内打开,也可能显示二维码)。
  2. 页面显示:“猫咪健康申请获得你的以下信息:昵称、头像、地区”。
  3. 用户点击“允许”。
  4. 关键心理:用户知道没在输微信密码,授权的是有限信息

第三幕:微信回调

微信重定向到小程序的回调地址:

https://cat-health.com/oauth/callback?
  code=CODE_HERE&
  state=SAME_STATE_AS_BEFORE

第四幕:后端换Token

前端把code传给后端,后端处理:

def exchange_code_for_token(code):
    # 安全提醒:这段代码在后端运行!
    params = {
        'appid': APP_ID,
        'secret': APP_SECRET,  # 关键!前端不知道这个
        'code': code,
        'grant_type': 'authorization_code'
    }
  
    # 请求微信服务器
    resp = requests.post(
        'https://api.weixin.qq.com/sns/oauth2/access_token',
        params=params
    )
  
    data = resp.json()
    # 返回:{"access_token": "...", "expires_in": 7200, 
    #        "refresh_token": "...", "openid": "USER_UNIQUE_ID"}
  
    return data

获得两个关键东西

  1. access_token:访问用户资源的凭证(2小时过期)
  2. openid:用户在此小程序内的唯一标识(不变)

第五幕:获取用户信息

def get_user_info(access_token, openid):
    resp = requests.get(
        'https://api.weixin.qq.com/sns/userinfo',
        params={
            'access_token': access_token,
            'openid': openid,
            'lang': 'zh_CN'
        }
    )
  
    user_data = resp.json()
    # {"openid": "...", "nickname": "猫奴张三", 
    #  "sex": 1, "province": "北京", 
    #  "city": "北京", "country": "中国",
    #  "headimgurl": "https://..."}
  
    return user_data

第六幕:建立自己的会话

重要:小程序不会一直用微信的access_token,而是:

  1. 用openid在自己数据库创建/查找用户记录
  2. 生成自己的session或JWT token
  3. 返回给前端,后续用这个token访问自己的API
def create_local_session(wechat_user_data):
    # 1. 查找或创建本地用户
    user = User.find_or_create(
        openid=wechat_user_data['openid'],
        defaults={
            'nickname': wechat_user_data['nickname'],
            'avatar': wechat_user_data['headimgurl']
        }
    )
  
    # 2. 创建本地会话(比如JWT)
    local_token = jwt.encode({
        'user_id': user.id,
        'exp': datetime.now() + timedelta(days=7)
    }, LOCAL_SECRET_KEY)
  
    # 3. 返回给前端
    return {
        'token': local_token,
        'user': {
            'id': user.id,
            'nickname': user.nickname
        }
    }

全程总结

用户 → 小程序 → 微信授权页 → 用户同意 → 微信返回code
→ 小程序后端用code+secret换token → 用token获取用户信息
→ 用openid关联本地用户 → 创建本地会话 → 用户登录完成

用户感知:点一下“允许”,就登录了。
实际发生:6个步骤,3次服务器间通信,全程没碰微信密码。


第六章:那些让人头秃的安全细节

OAuth/SSO设计精妙,但实现时容易踩坑。我踩过,希望你避开。

安全威胁1:CSRF攻击(授权阶段)

场景

  1. 攻击者构造恶意链接:https://idp.com/authorize?client_id=attacker&redirect_uri=attack.com
  2. 诱骗已登录用户点击
  3. 用户不知不觉为攻击者的应用授权

防御:用 state参数

  • 发起授权时生成随机state,存session
  • 回调时验证state是否匹配
  • 必须做!我第一个OAuth实现忘了这个,被安全测试打爆。

安全威胁2:授权码拦截攻击

场景:纯前端应用(SPA),授权码在URL中,可能被恶意JS读取。

防御:PKCE(前面讲了)

  • 即使code被偷,没有code_verifier也换不了token
  • 现代SPA/移动App强制要求

安全威胁3:重定向URI验证不严

场景

  1. 攻击者注册合法应用,redirect_uri设为 https://victim.com/callback
  2. 诱导用户授权
  3. 授权码送到victim.com,攻击者可能通过其他漏洞获取

防御

  • IdP必须严格验证redirect_uri与注册时完全匹配
  • 可以注册多个URI,但不能接受任意URI

安全威胁4:Token泄露

场景:access_token被泄露(日志、错误信息、客户端存储不当)。

防御

  • 短期token(如2小时)
  • refresh_token机制(可撤销)
  • token绑定客户端(jti声明、client_id验证)

安全威胁5:IdP钓鱼

场景:恶意应用模仿真实IdP界面,骗取用户授权。

防御

  • 用户教育(检查域名、证书)
  • OAuth 2.1草案要求PKCE对所有客户端
  • 使用知名IdP(用户熟悉其界面)

最佳实践清单

必须做的:
  - 所有流量HTTPS
  - 验证state参数(防CSRF)
  - 严格验证redirect_uri
  - 使用PKCE(对公开客户端)
  - token短期有效 + 可刷新

应该做的:
  - 记录所有授权日志
  - 提供授权撤销界面
  - 支持细粒度scope(不要总是请求全部权限)
  - 定期轮换client_secret

不要做的:
  - 把token存在localStorage(除非是短期且SPA无其他选择)
  - 将access_token传给不可信的前端代码
  - 使用隐式授权模式(除非遗留系统)
  - 信任未经验证的redirect_uri参数

第七章:实战中的设计抉择

当你要为自己的系统设计认证方案时,问这几个问题:

问题1:我是资源提供方(IdP)还是资源消费方(RP)?

情况A:我要让用户用微信/Google登录我的应用
→ 实现OAuth Client,选择合适的授权流程。

情况B:我要提供API让第三方接入
→ 实现OAuth Authorization Server,决定支持哪些grant_type。

问题2:我的客户端类型?

// 决策树
if (有后端服务器 && 可安全存储secret) {
    return "授权码模式";
} else if (纯前端SPA || 移动App) {
    return "授权码模式 + PKCE";
} else if (完全信任的自家客户端) {
    return "密码模式(谨慎)";
} else if (后台服务无用户) {
    return "客户端凭证模式";
}

问题3:需要用户身份信息吗?

  • 只需要授权API访问 → 纯OAuth 2.0
  • 需要知道用户是谁 → OAuth 2.0 + OIDC

问题4:内部系统还是面向公众?

  • 内部系统SSO:考虑SAML(企业集成)或OIDC
  • 面向消费者:支持社交登录(微信、Google、GitHub OAuth)
  • B2B场景:可能两者都需要

终章:如何为你的系统选择

简化决策表

你的场景 推荐方案 理由
企业内部多个系统 OIDC-based SSO 一次登录,标准现代
消费者Web应用 社交登录(微信/Google OAuth) 降低注册门槛
移动应用 OAuth 2.0 + PKCE 移动端安全最佳实践
微服务架构 OAuth 2.0客户端凭证 + JWT 服务间认证
对外提供API OAuth 2.0授权码模式 第三方接入标准
快速原型 直接用Auth0/Cognito等身份云 别重复造轮子

我的亲身建议

  1. 不要从零实现OAuth服务器除非你是大厂或有特殊需求,用现成的:

    • 开源:Keycloak、ORY Hydra
    • 云服务:Auth0、AWS Cognito、Okta
      我第一个月自己实现OAuth服务器,第二个月发现边界情况巨多,第三个月换Keycloak。
  2. 理解原理,但使用库

    # 好
    from authlib.integrations.flask_client import OAuth
    oauth = OAuth(app)
    oauth.register('wechat', client_id=..., client_secret=...)
    
    # 不好
    def parse_oauth_response(request):
        # 自己解析所有参数、验证state、处理错误...
        # 容易遗漏安全细节
    
  3. 设计时考虑退出策略如果未来要换IdP(比如从自建换到Auth0),抽象认证层:

    # 抽象接口
    class AuthProvider:
        def get_login_url(self) -> str: ...
        def exchange_code(self, code: str) -> UserInfo: ...
        def logout(self, token: str) -> bool: ...
    
    # 具体实现
    class WeChatAuth(AuthProvider): ...
    class GoogleAuth(AuthProvider): ...
    class CustomOIDC(AuthProvider): ...
    
  4. 永远站在用户角度思考

    • 权限请求要合理(scope最小化)
    • 提供清晰的“为什么需要这个权限”解释
    • 让用户容易查看和管理已授权应用
    • 一键撤销所有授权

最后的核心洞察

OAuth和SSO的精髓,其实在技术之外:

它们建立了一种“可控的信任”关系

在现实世界,你不会把家门钥匙给快递员,但会允许他进小区(门禁卡)、放快递在驿站(代收点)。OAuth就是数字世界的“临时通行证”系统。

而SSO,就像公司的工牌——一张卡能打开你有权限的所有门,但一旦丢失,安保中心能立即冻结它。

好的身份系统,应该是:

  • 对用户透明:流程顺畅,权限清晰
  • 对开发者友好:文档完整,SDK好用
  • 对安全严谨:防御深度,监控完备

下一步探索方向

如果你已经理解这些,可以继续深入:

  1. OAuth 2.1:当前草案,合并了安全最佳实践(PKCE必须、隐式模式移除等)
  2. OIDC进阶:动态客户端注册、会话管理、前端信道注销
  3. 企业场景:SAML与OIDC的桥接、SCIM用户同步协议
  4. 隐私增强:OAuth 2.0 for Browser-Based Apps(BRA)规范
  5. 硬件与物联网:设备流(Device Flow)授权

这就是OAuth和SSO的故事。从“为什么需要”到“如何工作”再到“如何用好”,我希望这篇文章让你看到了技术设计背后的人性思考。

毕竟,所有技术最终服务的都是人。

OAuth 2.0 / 单点登录:互联网的“信任借条”系统
作者
YeiJ
发表于
2025-11-30
License
CC BY-NC-SA 4.0

评论