Appearance
OAuth 2.0 授权 - 第三方授权标准协议
1. OAuth 2.0 简介
1.1 什么是 OAuth 2.0
OAuth 2.0 是一个开放标准的授权协议,允许用户授权第三方应用访问他们存储在另外服务提供者上的信息,而无需将用户名和密码提供给第三方应用。
核心概念:
- 资源所有者(Resource Owner): 用户
- 客户端(Client): 第三方应用
- 资源服务器(Resource Server): 存储用户资源的服务器
- 授权服务器(Authorization Server): 发放访问令牌的服务器
1.2 OAuth 2.0 vs OAuth 1.0
typescript
// OAuth 1.0
- 复杂的签名过程
- 需要客户端密钥
- 仅支持HTTP
- 令牌有效期不明确
// OAuth 2.0
- 简化的授权流程
- 多种授权方式
- 支持HTTPS
- Bearer Token机制
- 明确的令牌过期时间1.3 OAuth 2.0 的优势
安全性:
- 用户无需共享密码
- 限定授权范围
- 可撤销的访问权限
- 短期访问令牌
灵活性:
- 多种授权模式
- 适用各种场景
- 扩展性强
2. OAuth 2.0 授权流程
2.1 授权码模式(Authorization Code Flow)
最安全和最常用的模式。
┌──────┐ ┌────────────┐
│ │ │ │
│ │──(A)──授权请求──────────────────────────→│ │
│ │ │ 授权 │
│ │←─(B)──授权码────────────────────────────│ 服务器 │
│ │ │ │
│ 客户 │──(C)──授权码+客户端凭证──────────────────→│ │
│ 端 │ └────────────┘
│ │ ┌────────────┐
│ │←─(D)──访问令牌+刷新令牌──────────────────│ 令牌 │
│ │ │ 端点 │
│ │ └────────────┘
│ │ ┌────────────┐
│ │──(E)──访问令牌──────────────────────────→│ 资源 │
│ │ │ 服务器 │
│ │←─(F)──受保护资源─────────────────────────│ │
└──────┘ └────────────┘步骤详解:
typescript
// (A) 用户点击"使用XX登录"
const authUrl = `https://auth.example.com/oauth/authorize?
response_type=code&
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
scope=read_user+read_email&
state=${STATE}`;
window.location.href = authUrl;
// (B) 用户授权后重定向回来,携带授权码
// http://yourapp.com/callback?code=AUTHORIZATION_CODE&state=STATE
// (C) 后端用授权码换取访问令牌
const tokenResponse = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
})
});
// (D) 获取访问令牌和刷新令牌
const { access_token, refresh_token, expires_in } = await tokenResponse.json();
// (E, F) 使用访问令牌请求资源
const userResponse = await fetch('https://api.example.com/user', {
headers: {
'Authorization': `Bearer ${access_token}`
}
});2.2 隐式授权模式(Implicit Flow)
适用于纯前端应用(不推荐,已废弃)。
typescript
// 直接返回访问令牌(不安全)
const authUrl = `https://auth.example.com/oauth/authorize?
response_type=token&
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
scope=read_user`;
// 重定向
// http://yourapp.com/callback#access_token=TOKEN&token_type=Bearer2.3 密码模式(Resource Owner Password Credentials)
用户直接提供用户名和密码(仅限可信应用)。
typescript
const tokenResponse = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'password',
username: 'user@example.com',
password: 'userpassword',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
})
});2.4 客户端凭证模式(Client Credentials)
应用自身认证,不涉及用户。
typescript
const tokenResponse = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
})
});2.5 刷新令牌流程
typescript
const refreshResponse = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: REFRESH_TOKEN,
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET
})
});
const { access_token, refresh_token } = await refreshResponse.json();3. PKCE 增强安全性
PKCE(Proof Key for Code Exchange)用于增强授权码流程的安全性,特别适用于移动应用和SPA。
3.1 PKCE 流程
typescript
// 1. 生成code_verifier
import crypto from 'crypto';
function generateCodeVerifier(): string {
return crypto.randomBytes(32).toString('base64url');
}
// 2. 生成code_challenge
function generateCodeChallenge(verifier: string): string {
return crypto
.createHash('sha256')
.update(verifier)
.digest('base64url');
}
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
// 3. 授权请求(携带code_challenge)
const authUrl = `https://auth.example.com/oauth/authorize?
response_type=code&
client_id=${CLIENT_ID}&
redirect_uri=${REDIRECT_URI}&
code_challenge=${codeChallenge}&
code_challenge_method=S256`;
// 4. 令牌请求(携带code_verifier)
const tokenResponse = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code: authorizationCode,
redirect_uri: REDIRECT_URI,
client_id: CLIENT_ID,
code_verifier: codeVerifier // 验证
})
});3.2 React 实现 PKCE
tsx
// OAuthLogin.tsx
import { useState, useEffect } from 'react';
export function OAuthLogin() {
const handleLogin = () => {
// 生成并存储code_verifier
const codeVerifier = generateCodeVerifier();
sessionStorage.setItem('code_verifier', codeVerifier);
// 生成code_challenge
const codeChallenge = generateCodeChallenge(codeVerifier);
// 生成state(防CSRF)
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
// 构建授权URL
const params = new URLSearchParams({
response_type: 'code',
client_id: process.env.REACT_APP_CLIENT_ID!,
redirect_uri: process.env.REACT_APP_REDIRECT_URI!,
scope: 'read_user read_email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256'
});
window.location.href = `https://auth.example.com/oauth/authorize?${params}`;
};
return (
<button onClick={handleLogin}>
使用OAuth登录
</button>
);
}
// 辅助函数
function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return btoa(String.fromCharCode.apply(null, Array.from(array)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
function generateCodeChallenge(verifier: string): string {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
return crypto.subtle.digest('SHA-256', data)
.then(hash => {
const base64 = btoa(String.fromCharCode(...new Uint8Array(hash)));
return base64
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
});
}4. 实现 OAuth 服务器
4.1 授权服务器
typescript
// auth-server.ts
import express from 'express';
import crypto from 'crypto';
const app = express();
// 存储授权码和令牌(实际应使用数据库)
const authCodes = new Map();
const tokens = new Map();
// 授权端点
app.get('/oauth/authorize', (req, res) => {
const {
response_type,
client_id,
redirect_uri,
scope,
state,
code_challenge,
code_challenge_method
} = req.query;
// 验证客户端
const client = getClientById(client_id);
if (!client || client.redirect_uri !== redirect_uri) {
return res.status(400).json({ error: 'invalid_client' });
}
// 渲染授权页面
res.render('authorize', {
client_id,
scope,
state
});
});
// 用户授权
app.post('/oauth/authorize', (req, res) => {
const {
client_id,
redirect_uri,
scope,
state,
code_challenge,
user_id // 来自session
} = req.body;
// 生成授权码
const authCode = crypto.randomBytes(32).toString('hex');
// 存储授权码
authCodes.set(authCode, {
client_id,
redirect_uri,
scope,
user_id,
code_challenge,
expires_at: Date.now() + 10 * 60 * 1000 // 10分钟
});
// 重定向回客户端
const callbackUrl = new URL(redirect_uri);
callbackUrl.searchParams.set('code', authCode);
callbackUrl.searchParams.set('state', state);
res.redirect(callbackUrl.toString());
});
// 令牌端点
app.post('/oauth/token', async (req, res) => {
const {
grant_type,
code,
redirect_uri,
client_id,
client_secret,
code_verifier,
refresh_token
} = req.body;
if (grant_type === 'authorization_code') {
// 验证授权码
const authData = authCodes.get(code);
if (!authData || authData.expires_at < Date.now()) {
return res.status(400).json({ error: 'invalid_grant' });
}
// 验证客户端
if (authData.client_id !== client_id) {
return res.status(400).json({ error: 'invalid_client' });
}
// 验证PKCE
if (authData.code_challenge) {
const computedChallenge = crypto
.createHash('sha256')
.update(code_verifier)
.digest('base64url');
if (computedChallenge !== authData.code_challenge) {
return res.status(400).json({ error: 'invalid_grant' });
}
}
// 删除已使用的授权码
authCodes.delete(code);
// 生成访问令牌和刷新令牌
const accessToken = crypto.randomBytes(32).toString('hex');
const refreshToken = crypto.randomBytes(32).toString('hex');
tokens.set(accessToken, {
user_id: authData.user_id,
scope: authData.scope,
expires_at: Date.now() + 60 * 60 * 1000 // 1小时
});
tokens.set(refreshToken, {
user_id: authData.user_id,
scope: authData.scope,
type: 'refresh'
});
res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
refresh_token: refreshToken,
scope: authData.scope
});
}
else if (grant_type === 'refresh_token') {
// 刷新令牌逻辑
const tokenData = tokens.get(refresh_token);
if (!tokenData || tokenData.type !== 'refresh') {
return res.status(400).json({ error: 'invalid_grant' });
}
// 生成新的访问令牌
const newAccessToken = crypto.randomBytes(32).toString('hex');
tokens.set(newAccessToken, {
user_id: tokenData.user_id,
scope: tokenData.scope,
expires_at: Date.now() + 60 * 60 * 1000
});
res.json({
access_token: newAccessToken,
token_type: 'Bearer',
expires_in: 3600
});
}
});4.2 资源服务器
typescript
// resource-server.ts
import express from 'express';
const app = express();
// 验证访问令牌中间件
function authenticate(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'unauthorized' });
}
const token = authHeader.substring(7);
// 验证令牌(实际应调用授权服务器或查数据库)
const tokenData = tokens.get(token);
if (!tokenData || tokenData.expires_at < Date.now()) {
return res.status(401).json({ error: 'invalid_token' });
}
req.user_id = tokenData.user_id;
req.scope = tokenData.scope;
next();
}
// 受保护的资源
app.get('/api/user', authenticate, async (req, res) => {
const user = await getUserById(req.user_id);
res.json(user);
});
app.get('/api/emails', authenticate, async (req, res) => {
// 检查scope
if (!req.scope.includes('read_email')) {
return res.status(403).json({ error: 'insufficient_scope' });
}
const emails = await getUserEmails(req.user_id);
res.json(emails);
});5. 客户端实现
5.1 授权流程实现
typescript
// oauth-client.ts
export class OAuthClient {
private clientId: string;
private clientSecret: string;
private redirectUri: string;
private authorizationUrl: string;
private tokenUrl: string;
constructor(config: OAuthConfig) {
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.redirectUri = config.redirectUri;
this.authorizationUrl = config.authorizationUrl;
this.tokenUrl = config.tokenUrl;
}
// 获取授权URL
getAuthorizationUrl(scope: string[]): string {
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
const params = new URLSearchParams({
response_type: 'code',
client_id: this.clientId,
redirect_uri: this.redirectUri,
scope: scope.join(' '),
state
});
return `${this.authorizationUrl}?${params}`;
}
// 处理回调
async handleCallback(callbackUrl: string): Promise<TokenResponse> {
const url = new URL(callbackUrl);
const code = url.searchParams.get('code');
const state = url.searchParams.get('state');
// 验证state
const savedState = sessionStorage.getItem('oauth_state');
if (state !== savedState) {
throw new Error('Invalid state parameter');
}
// 交换令牌
return this.exchangeCodeForToken(code!);
}
// 用授权码换取令牌
private async exchangeCodeForToken(code: string): Promise<TokenResponse> {
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
redirect_uri: this.redirectUri,
client_id: this.clientId,
client_secret: this.clientSecret
})
});
if (!response.ok) {
throw new Error('Token exchange failed');
}
return response.json();
}
// 刷新令牌
async refreshToken(refreshToken: string): Promise<TokenResponse> {
const response = await fetch(this.tokenUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: this.clientId,
client_secret: this.clientSecret
})
});
return response.json();
}
}5.2 React 集成
tsx
// OAuthProvider.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
import { OAuthClient } from './oauth-client';
interface OAuthContextType {
isAuthenticated: boolean;
accessToken: string | null;
login: (scope: string[]) => void;
logout: () => void;
}
const OAuthContext = createContext<OAuthContextType | undefined>(undefined);
export function OAuthProvider({ children }: { children: ReactNode }) {
const [accessToken, setAccessToken] = useState<string | null>(null);
const [refreshToken, setRefreshToken] = useState<string | null>(null);
const oauthClient = new OAuthClient({
clientId: process.env.REACT_APP_CLIENT_ID!,
clientSecret: process.env.REACT_APP_CLIENT_SECRET!,
redirectUri: process.env.REACT_APP_REDIRECT_URI!,
authorizationUrl: 'https://auth.example.com/oauth/authorize',
tokenUrl: 'https://auth.example.com/oauth/token'
});
const login = (scope: string[]) => {
const authUrl = oauthClient.getAuthorizationUrl(scope);
window.location.href = authUrl;
};
const handleCallback = async () => {
try {
const tokens = await oauthClient.handleCallback(window.location.href);
setAccessToken(tokens.access_token);
setRefreshToken(tokens.refresh_token);
// 清理URL参数
window.history.replaceState({}, '', window.location.pathname);
} catch (error) {
console.error('OAuth callback failed:', error);
}
};
const logout = () => {
setAccessToken(null);
setRefreshToken(null);
};
// 检查URL是否有回调参数
useEffect(() => {
if (window.location.search.includes('code=')) {
handleCallback();
}
}, []);
return (
<OAuthContext.Provider value={{
isAuthenticated: !!accessToken,
accessToken,
login,
logout
}}>
{children}
</OAuthContext.Provider>
);
}
export function useOAuth() {
const context = useContext(OAuthContext);
if (!context) {
throw new Error('useOAuth must be used within OAuthProvider');
}
return context;
}tsx
// LoginButton.tsx
import { useOAuth } from './OAuthProvider';
export function LoginButton() {
const { isAuthenticated, login, logout, accessToken } = useOAuth();
const handleLogin = () => {
login(['read_user', 'read_email']);
};
if (isAuthenticated) {
return (
<div>
<p>已登录</p>
<button onClick={logout}>登出</button>
</div>
);
}
return (
<button onClick={handleLogin}>
使用OAuth登录
</button>
);
}6. 安全最佳实践
6.1 防止CSRF攻击
typescript
// 使用state参数
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
const authUrl = `${authorizationUrl}?
response_type=code&
client_id=${clientId}&
state=${state}`;
// 回调时验证
const callbackState = url.searchParams.get('state');
const savedState = sessionStorage.getItem('oauth_state');
if (callbackState !== savedState) {
throw new Error('CSRF attack detected');
}6.2 使用HTTPS
typescript
// 确保所有OAuth流程使用HTTPS
if (window.location.protocol !== 'https:' && process.env.NODE_ENV === 'production') {
window.location.href = window.location.href.replace('http:', 'https:');
}6.3 令牌安全存储
typescript
// ❌ 不安全
localStorage.setItem('access_token', token);
// ✅ 安全方案1: HttpOnly Cookie(服务器设置)
res.cookie('oauth_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
// ✅ 安全方案2: 内存存储
class TokenStore {
private accessToken: string | null = null;
setToken(token: string) {
this.accessToken = token;
}
getToken() {
return this.accessToken;
}
}6.4 Scope最小化
typescript
// ❌ 请求过多权限
login(['read_user', 'write_user', 'admin', 'delete_all']);
// ✅ 只请求必要权限
login(['read_user', 'read_email']);7. 错误处理
typescript
// OAuth错误类型
enum OAuthError {
InvalidRequest = 'invalid_request',
UnauthorizedClient = 'unauthorized_client',
AccessDenied = 'access_denied',
UnsupportedResponseType = 'unsupported_response_type',
InvalidScope = 'invalid_scope',
ServerError = 'server_error',
TemporarilyUnavailable = 'temporarily_unavailable'
}
// 处理OAuth错误
async function handleOAuthError(error: any) {
const errorCode = error.response?.data?.error;
switch (errorCode) {
case OAuthError.AccessDenied:
// 用户拒绝授权
showMessage('您拒绝了授权请求');
break;
case OAuthError.InvalidScope:
// 无效的权限范围
showMessage('请求的权限无效');
break;
case OAuthError.ServerError:
// 服务器错误
showMessage('授权服务器错误,请稍后重试');
break;
default:
showMessage('授权失败');
}
}8. 常见OAuth提供商集成
8.1 Google OAuth
typescript
const googleOAuthConfig = {
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectUri: 'http://localhost:3000/auth/google/callback',
authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
tokenUrl: 'https://oauth2.googleapis.com/token',
userInfoUrl: 'https://www.googleapis.com/oauth2/v2/userinfo'
};
// 请求Google用户信息
async function getGoogleUserInfo(accessToken: string) {
const response = await fetch(googleOAuthConfig.userInfoUrl, {
headers: {
'Authorization': `Bearer ${accessToken}`
}
});
return response.json();
}8.2 GitHub OAuth
typescript
const githubOAuthConfig = {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectUri: 'http://localhost:3000/auth/github/callback',
authorizationUrl: 'https://github.com/login/oauth/authorize',
tokenUrl: 'https://github.com/login/oauth/access_token',
userInfoUrl: 'https://api.github.com/user'
};8.3 Facebook OAuth
typescript
const facebookOAuthConfig = {
clientId: process.env.FACEBOOK_APP_ID!,
clientSecret: process.env.FACEBOOK_APP_SECRET!,
redirectUri: 'http://localhost:3000/auth/facebook/callback',
authorizationUrl: 'https://www.facebook.com/v12.0/dialog/oauth',
tokenUrl: 'https://graph.facebook.com/v12.0/oauth/access_token',
userInfoUrl: 'https://graph.facebook.com/me'
};9. 测试
typescript
// oauth.test.ts
describe('OAuth Flow', () => {
it('should generate correct authorization URL', () => {
const client = new OAuthClient(config);
const url = client.getAuthorizationUrl(['read_user']);
expect(url).toContain('response_type=code');
expect(url).toContain(`client_id=${config.clientId}`);
expect(url).toContain('scope=read_user');
});
it('should exchange code for token', async () => {
const client = new OAuthClient(config);
// Mock fetch
global.fetch = jest.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({
access_token: 'test_token',
refresh_token: 'refresh_token'
})
} as Response)
);
const tokens = await client.exchangeCodeForToken('test_code');
expect(tokens.access_token).toBe('test_token');
});
});10. 总结
OAuth 2.0的关键要点:
- 选择正确的授权模式: 大多数情况使用授权码模式
- 使用PKCE: 增强移动和SPA的安全性
- 状态参数: 防止CSRF攻击
- HTTPS: 所有OAuth流程必须使用HTTPS
- 最小权限: 只请求必要的scope
- 安全存储: 妥善保管令牌
- 刷新令牌: 实现无感刷新机制
通过正确实施OAuth 2.0,可以安全地集成第三方登录和授权功能。