跳至主要內容

OAuth 2.0 / OIDC 完整指南:认证协议的工业实践

郑天祺大约 9 分钟安全OAuth 2.0OIDC认证协议的工业实践

前言

在金融、支付等涉及用户隐私和资金安全的系统中,认证和授权是最关键的防线。

常见问题:
1. 用户密码存在服务器,黑客盗库 → 用户密码泄露
2. 应用直接持有用户密码,密码重置困难
3. 多个应用都要求用户输入密码 → 钓鱼风险
4. 第三方应用需要访问用户数据,但不能共享密码

OAuth 2.0OIDC(OpenID Connect) 解决了这些问题,已成为行业标准。

本文从原理出发,讲解两大协议的各个流程,并给出银行级别的落地方案。


一、为什么需要OAuth 2.0

1.1 传统方式的问题

场景:用户想用第三方应用(如图片编辑器)来访问云存储中的照片

传统做法:
  用户 → 图片编辑器: "我要编辑我的照片"
  图片编辑器 → 用户: "请输入云存储的用户名和密码"
  用户 → 图片编辑器: "username: alice, password: secret123"
  图片编辑器 → 云存储: "alice 要访问她的照片"
  云存储: "OK,这是alice的所有照片"

问题:
  ❌ 用户密码暴露给第三方应用
  ❌ 用户无法撤销权限(除非改密码,但这会影响所有应用)
  ❌ 第三方应用有权访问用户的全部数据
  ❌ 用户修改密码 → 第三方应用失效

1.2 OAuth 2.0解决方案

OAuth 2.0核心思想:用户不需要共享密码
  └─ 用户使用密码登录授权服务器(很安全)
  └─ 授权服务器发放临时令牌(Token)给第三方应用
  └─ 第三方应用用令牌访问资源
  └─ 令牌可以随时撤销,无需改密码

流程:
  用户 → 图片编辑器: "我要编辑照片"
  图片编辑器 → 授权服务器: "请用户授权"
  用户 → 授权服务器: "我允许图片编辑器访问我的照片(但不能删除)"
  授权服务器 → 图片编辑器: "这是访问令牌(Token): xxx"
  图片编辑器 → 云存储: "我要访问alice的照片,令牌: xxx"
  云存储 → 授权服务器: "这个令牌有效吗?权限是什么?"
  授权服务器 → 云存储: "有效,只能读取,不能删除"
  云存储 → 图片编辑器: "这是alice的照片列表"

优势:
  ✓ 用户密码只输入给授权服务器(可信)
  ✓ 第三方应用没有密码,只有限时令牌
  ✓ 权限细粒度控制(读、写、删除分别控制)
  ✓ 令牌可随时撤销

二、OAuth 2.0 四大授权流程

2.1 授权码流程(Authorization Code Flow)

最安全的流程,适合Web应用和移动应用

参与者:
  Resource Owner: 用户(Alice)
  Client: 第三方应用(图片编辑器)
  Authorization Server: 授权服务器(OAuth提供商)
  Resource Server: 资源服务器(云存储)

流程:
┌─────────────────────────────────────────────────────┐
│ 1. 用户点击"用Google账号登录"                       │
│    Alice → Client: 点击按钮                         │
└──────────┬────────────────────────────────────────┘
           │
┌──────────▼────────────────────────────────────────┐
│ 2. 重定向到授权服务器                              │
│    Client → Alice (redirect): 跳转到Google登录   │
│    URL: https://google.com/oauth/authorize       │
│    ?client_id=xxx                               │
│    &redirect_uri=https://editor.com/callback   │
│    &scope=photos.read                          │
│    &state=random_string                        │
└──────────┬────────────────────────────────────────┘
           │
┌──────────▼────────────────────────────────────────┐
│ 3. 用户在授权服务器上登录并同意权限                 │
│    Alice → AuthServer: 输入Google密码             │
│    AuthServer: 请问是否允许图片编辑器访问你的照片? │
│    Alice: 允许(仅照片,不允许修改)              │
└──────────┬────────────────────────────────────────┘
           │
┌──────────▼────────────────────────────────────────┐
│ 4. 授权服务器返回授权码                            │
│    AuthServer → Alice (redirect):               │
│    https://editor.com/callback?                │
│    code=auth_code_xyz                          │
│    &state=random_string                        │
└──────────┬────────────────────────────────────────┘
           │
┌──────────▼────────────────────────────────────────┐
│ 5. 客户端用授权码换取访问令牌(后端完成,用户看不见)│
│    Client (后端) → AuthServer:                   │
│    POST /token                                  │
│    {                                            │
│      code: auth_code_xyz,                       │
│      client_id: client_id_xxx,                  │
│      client_secret: secret_key_xxx  ← 秘钥     │
│    }                                            │
└──────────┬────────────────────────────────────────┘
           │
┌──────────▼────────────────────────────────────────┐
│ 6. 授权服务器返回访问令牌                          │
│    AuthServer → Client:                         │
│    {                                            │
│      access_token: token_xyz,                   │
│      token_type: Bearer,                        │
│      expires_in: 3600,                          │
│      refresh_token: refresh_xyz                 │
│    }                                            │
└──────────┬────────────────────────────────────────┘
           │
┌──────────▼────────────────────────────────────────┐
│ 7. 客户端用令牌访问资源                            │
│    Client → ResourceServer:                     │
│    GET /photos                                  │
│    Authorization: Bearer token_xyz              │
│                                                 │
│    ResourceServer → AuthServer: 验证token       │
│    AuthServer → ResourceServer: 有效,权限:read │
│                                                 │
│    ResourceServer → Client: 返回照片列表         │
└─────────────────────────────────────────────────┘

关键安全特性:

1. client_secret 从不暴露给浏览器(只在后端存储)
2. 授权码只能用一次,有效期短(通常10分钟)
3. state 参数防止CSRF攻击
4. 访问令牌有过期时间(通常1小时)
5. refresh_token 可在令牌过期后重新获取新令牌

2.2 隐式流程(Implicit Flow)

不安全,已废弃。不建议使用。

问题:没有后端或后端不可信的纯前端应用
原方案:直接在前端获取访问令牌
缺点:
  ❌ 令牌暴露在URL中(浏览器历史记录)
  ❌ 无法存储client_secret(所有js都能看到)
  ❌ 令牌容易被窃取
  
现代替代:Authorization Code Flow with PKCE

2.3 密码流程(Resource Owner Password Credentials Flow)

仅当用户完全信任应用时使用(如公司内部应用)

场景:用户密码直接交给第三方应用(违反OAuth精神,但某些场景不得已)

流程:
  用户 → 应用: 输入用户名和密码
  应用 → 授权服务器:
    POST /token
    {
      grant_type: password,
      username: alice,
      password: secret123,
      client_id: xxx,
      client_secret: xxx
    }
  授权服务器 → 应用: { access_token: xxx }

仅适用场景:
  ✓ 公司内部应用(用户相信公司)
  ✓ 原生移动应用(开发者完全控制)
  ✗ 第三方Web应用(危险!)
  ✗ 不知名应用(用户不应该输入密码)

2.4 客户端凭证流程(Client Credentials Flow)

应用间通信,没有用户参与

场景:服务A需要访问服务B的API

流程:
  ServiceA → AuthServer:
    POST /token
    {
      grant_type: client_credentials,
      client_id: service_a_id,
      client_secret: service_a_secret
    }
  
  AuthServer → ServiceA: { access_token: xxx }
  
  ServiceA → ServiceB:
    GET /api/data
    Authorization: Bearer xxx

适用:
  ✓ 服务间通信(没有最终用户)
  ✓ 定时任务(后台作业)
  ✗ 涉及用户数据(需要用户授权)

三、OIDC(OpenID Connect):OAuth的身份认证层

3.1 OAuth vs OIDC

OAuth 2.0:授权(Authorization)
  问题:只解决了"你有权访问资源",没解决"你是谁"
  
  例子:
    AuthServer: "这个令牌可以访问照片"
    ResourceServer: "好的,但我怎么知道这个人是谁?"

OIDC:在OAuth基础上加了身份认证
  解决方案:返回ID令牌(包含用户身份信息)
  
  AuthServer返回:
    {
      access_token: xxx,      ← 用于访问资源
      id_token: yyy,          ← 用于确认用户身份
      token_type: Bearer,
      expires_in: 3600
    }

3.2 ID Token的内容

ID Token 是一个JWT (JSON Web Token)

Header:
  {
    alg: RS256,  ← 签名算法
    kid: 12345   ← 密钥ID
  }

Payload:
  {
    iss: https://google.com,      ← 发行者
    sub: 1234567890,              ← 用户ID(Subject)
    aud: client_id_xxx,           ← 受众(谁可以用这个令牌)
    exp: 1234567890,              ← 过期时间
    iat: 1234567890,              ← 发行时间
    nonce: random_xyz,            ← 防重放
    email: alice@example.com,     ← 用户邮箱
    email_verified: true,
    name: Alice Smith,
    picture: https://...
  }

Signature:
  HMAC-SHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload))

3.3 OIDC流程

相比OAuth,多返回了ID Token

1. 用户点击"用Google账号登录"
2. 重定向到Google授权页面(scope中加上openid)
3. 用户同意
4. Google返回 authorization code
5. 客户端用code交换:
   ├─ access_token(用于API调用)
   ├─ id_token(用于身份确认)  ← OIDC新增
   └─ refresh_token

6. 客户端验证ID Token的签名
7. 从ID Token提取用户信息(不需再调用userinfo API)
8. 登录成功

四、实现示例:使用Google OAuth登录

4.1 前端(使用Google登录按钮)

<!-- HTML -->
<script src="https://accounts.google.com/gsi/client" async defer></script>

<div id="g_id_onload"
     data-client_id="YOUR_CLIENT_ID.apps.googleusercontent.com"
     data-callback="handleCredentialResponse">
</div>

<div class="g_id_signin" data-type="standard"></div>

<script>
function handleCredentialResponse(response) {
  // response.credential 是 ID Token (JWT)
  console.log("Encoded JWT ID token: " + response.credential);
  
  // 将ID Token发送到后端验证
  fetch('/api/auth/google', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ idToken: response.credential })
  })
  .then(res => res.json())
  .then(data => {
    // 后端返回session token,用于后续API调用
    localStorage.setItem('token', data.sessionToken);
    window.location.href = '/dashboard';
  });
}
</script>

4.2 后端(验证ID Token并创建Session)

# Python with Flask

from google.auth.transport import requests
from google.oauth2 import id_token
import jwt

CLIENT_ID = "YOUR_CLIENT_ID.apps.googleusercontent.com"

@app.route('/api/auth/google', methods=['POST'])
def google_login():
    """处理Google登录回调"""
    
    id_token_str = request.json.get('idToken')
    
    try:
        # 1. 验证ID Token签名
        # Google公钥列表:https://www.googleapis.com/oauth2/v1/certs
        idinfo = id_token.verify_oauth2_token(
            id_token_str,
            requests.Request(),
            CLIENT_ID
        )
        
        # 2. 检查发行者和受众
        if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']:
            raise ValueError('Wrong issuer.')
        
        if idinfo['aud'] != CLIENT_ID:
            raise ValueError('Wrong audience.')
        
        # 3. 提取用户信息
        user_id = idinfo['sub']  # 唯一用户ID
        email = idinfo['email']
        name = idinfo.get('name', '')
        picture = idinfo.get('picture', '')
        email_verified = idinfo.get('email_verified', False)
        
        # 4. 检查邮箱是否验证(安全考虑)
        if not email_verified:
            return {'error': 'Email not verified'}, 400
        
        # 5. 在本地数据库中查找或创建用户
        user = User.query.filter_by(google_id=user_id).first()
        
        if not user:
            # 新用户,创建账户
            user = User(
                google_id=user_id,
                email=email,
                name=name,
                picture=picture
            )
            db.session.add(user)
            db.session.commit()
        
        # 6. 创建Session令牌(有效期24小时)
        session_token = jwt.encode(
            {
                'user_id': user.id,
                'email': user.email,
                'exp': datetime.utcnow() + timedelta(days=1)
            },
            app.config['SECRET_KEY'],
            algorithm='HS256'
        )
        
        return {
            'sessionToken': session_token,
            'user': {
                'id': user.id,
                'email': user.email,
                'name': user.name
            }
        }
    
    except ValueError as e:
        # Token验证失败
        return {'error': str(e)}, 401

# 保护其他API的中间件
def require_auth(f):
    @wraps(f)
    def decorated_function(*args, **kwargs):
        token = request.headers.get('Authorization', '').replace('Bearer ', '')
        
        if not token:
            return {'error': 'No token'}, 401
        
        try:
            payload = jwt.decode(
                token,
                app.config['SECRET_KEY'],
                algorithms=['HS256']
            )
            # 将用户ID存在request context中
            g.user_id = payload['user_id']
        except jwt.ExpiredSignatureError:
            return {'error': 'Token expired'}, 401
        except jwt.InvalidTokenError:
            return {'error': 'Invalid token'}, 401
        
        return f(*args, **kwargs)
    
    return decorated_function

@app.route('/api/me')
@require_auth
def get_current_user():
    """获取当前用户信息"""
    user = User.query.get(g.user_id)
    return {'user': user.to_dict()}

五、OAuth 2.0安全最佳实践

5.1 常见攻击与防护

# 1. CSRF攻击防护
# ❌ 错误:没有检查state参数
if authorization_code == request.args.get('code'):
    # 直接交换,容易被CSRF

# ✓ 正确:验证state参数
stored_state = session.get('oauth_state')
returned_state = request.args.get('state')

if stored_state != returned_state:
    raise ValueError('State mismatch - possible CSRF')

# 2. 令牌泄露防护
# ❌ 错误:在URL中传输令牌
# https://api.example.com/data?access_token=xxx

# ✓ 正确:在Authorization头中传输
# Authorization: Bearer xxx

# 3. 中间人攻击防护
# ❌ 使用HTTP
# ✓ 必须使用HTTPS

# 4. 令牌过期防护
# ❌ 令牌永不过期
access_token_lifetime = 3600  # ✓ 1小时

# ❌ 令牌过期直接让用户重新授权
# ✓ 使用refresh_token自动续期
def refresh_access_token(refresh_token):
    response = requests.post(
        'https://oauth-provider.com/token',
        data={
            'grant_type': 'refresh_token',
            'refresh_token': refresh_token,
            'client_id': CLIENT_ID,
            'client_secret': CLIENT_SECRET
        }
    )
    return response.json()['access_token']

# 5. 权限过度授予防护
# ❌ scope=user.*,admin.*  (权限太大)
# ✓ scope=user.read,profile.read  (最小必要权限)

5.2 PKCE(用于SPA应用)

问题:SPA(Single Page App)没有后端,无法安全存储client_secret

PKCE解决方案(Proof Key for Public Clients):

1. 前端生成随机的code_verifier
2. 计算code_challenge = SHA256(code_verifier)
3. 发送code_challenge到授权服务器
4. 用户授权后,获得authorization code
5. 用code + code_verifier交换access_token
   (无需client_secret)

代码示例:
import hashlib
import base64
import secrets

# 生成code_verifier
code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode('utf-8').rstrip('=')

# 计算code_challenge
code_challenge = base64.urlsafe_b64encode(
    hashlib.sha256(code_verifier.encode('utf-8')).digest()
).decode('utf-8').rstrip('=')

# 发送到授权服务器
auth_url = f"""
https://oauth-provider.com/authorize?
client_id={CLIENT_ID}&
redirect_uri={REDIRECT_URI}&
scope=openid profile email&
state={random_state}&
code_challenge={code_challenge}&
code_challenge_method=S256
"""

# 授权后,用code_verifier交换令牌
response = requests.post(
    'https://oauth-provider.com/token',
    data={
        'grant_type': 'authorization_code',
        'client_id': CLIENT_ID,
        'code': authorization_code,
        'redirect_uri': REDIRECT_URI,
        'code_verifier': code_verifier  ← 关键!
    }
)

总结

协议用途适用场景风险
OAuth 2.0授权(访问权限)第三方应用访问用户资源令牌泄露、CSRF
OIDC认证(身份确认)用户登录、身份验证ID Token伪造
PKCE保护Public ClientsSPA、移动应用Code攻击

最佳实践:

  1. ✓ 使用Authorization Code Flow(最安全)
  2. ✓ 配合PKCE(用于前端应用)
  3. ✓ HTTPS Only(防中间人)
  4. ✓ 设置合理的令牌过期时间
  5. ✓ 最小权限原则(scope最少化)
  6. ✓ 定期更新依赖库
  7. ✓ 验证签名和发行者
上次编辑于:
贡献者: zhengtianqi