OAuth 2.0 / OIDC 完整指南:认证协议的工业实践
大约 9 分钟
前言
在金融、支付等涉及用户隐私和资金安全的系统中,认证和授权是最关键的防线。
常见问题:
1. 用户密码存在服务器,黑客盗库 → 用户密码泄露
2. 应用直接持有用户密码,密码重置困难
3. 多个应用都要求用户输入密码 → 钓鱼风险
4. 第三方应用需要访问用户数据,但不能共享密码
OAuth 2.0 和 OIDC(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 Clients | SPA、移动应用 | Code攻击 |
最佳实践:
- ✓ 使用Authorization Code Flow(最安全)
- ✓ 配合PKCE(用于前端应用)
- ✓ HTTPS Only(防中间人)
- ✓ 设置合理的令牌过期时间
- ✓ 最小权限原则(scope最少化)
- ✓ 定期更新依赖库
- ✓ 验证签名和发行者