OAuth 客户端凭证模式 #
概述 #
客户端凭证模式(Client Credentials Grant)是 OAuth 2.0 中用于服务间通信的授权方式。它允许客户端使用自己的凭证(client_id 和 client_secret)直接获取访问令牌,无需用户参与。
text
┌─────────────────────────────────────────────────────────────┐
│ 客户端凭证模式特点 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ✅ 无需用户参与 │
│ - 适用于机器对机器通信 │
│ - 无需用户授权流程 │
│ │
│ ✅ 简单高效 │
│ - 直接获取令牌 │
│ - 流程简洁 │
│ │
│ ✅ 适合后台服务 │
│ - 定时任务 │
│ - 服务间调用 │
│ - 批处理作业 │
│ │
│ ⚠️ 不代表特定用户 │
│ - 令牌代表客户端本身 │
│ - 无法访问用户私有资源 │
│ │
└─────────────────────────────────────────────────────────────┘
适用场景 #
典型应用场景 #
text
┌─────────────────────────────────────────────────────────────┐
│ 适用场景 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 微服务间通信 │
│ ┌─────────┐ ┌─────────┐ │
│ │ 服务 A │────>│ 服务 B │ │
│ │(客户端) │ │(资源服务)│ │
│ └─────────┘ └─────────┘ │
│ │
│ 2. 后台定时任务 │
│ ┌─────────┐ ┌─────────┐ │
│ │ 定时任务 │────>│ API服务 │ │
│ │ (Cron) │ │ │ │
│ └─────────┘ └─────────┘ │
│ │
│ 3. CLI 工具 │
│ ┌─────────┐ ┌─────────┐ │
│ │ CLI工具 │────>│ 云服务API│ │
│ └─────────┘ └─────────┘ │
│ │
│ 4. 后台管理操作 │
│ ┌─────────┐ ┌─────────┐ │
│ │ 管理后台 │────>│ 系统API │ │
│ └─────────┘ └─────────┘ │
│ │
│ 5. 数据同步 │
│ ┌─────────┐ ┌─────────┐ │
│ │ 数据同步 │────>│ 目标系统│ │
│ │ 服务 │ │ │ │
│ └─────────┘ └─────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
不适用场景 #
text
❌ 需要访问用户私有资源
- 用户个人数据
- 用户授权的操作
❌ 需要识别特定用户
- 用户行为追踪
- 用户权限控制
❌ 需要用户授权
- 第三方应用访问用户数据
- 需要用户同意的操作
授权流程 #
流程图 #
text
┌─────────────────────────────────────────────────────────────┐
│ 客户端凭证模式流程 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────┐ ┌─────────┐ │
│ │ 客户端 │ │ 授权服务│ │
│ │ Client │ │ Server │ │
│ └────┬────┘ └────┬────┘ │
│ │ │ │
│ │ (A) 请求访问令牌 │ │
│ │ grant_type=client_credentials│ │
│ │─────────────────────────────────>│ │
│ │ │ │
│ │ │ 验证客户端 │
│ │ │ 检查权限 │
│ │ │ │
│ │ (B) 返回访问令牌 │ │
│ │<─────────────────────────────────│ │
│ │ │ │
│ │ (C) 使用令牌访问资源 │ │
│ │──────────────────────────────────────────────>│ │
│ │ │ │ │
│ │ │ 资源服务器│ │
│ │ (D) 返回受保护资源 │ │ │
│ │<──────────────────────────────────────────────│ │
│ │ │ │
│ │
└─────────────────────────────────────────────────────────────┘
步骤详解 #
步骤 A:请求访问令牌 #
http
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&scope=api:read api:write
步骤 B:返回访问令牌 #
json
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "api:read api:write"
}
注意:客户端凭证模式通常不返回刷新令牌
步骤 C-D:访问资源 #
http
GET /api/data HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
令牌请求详解 #
请求参数 #
| 参数 | 必需 | 描述 |
|---|---|---|
| grant_type | 是 | 固定值 client_credentials |
| scope | 否 | 请求的权限范围 |
| client_id | 条件 | 客户端标识(如不在 Authorization 头中) |
| client_secret | 条件 | 客户端密钥(如不在 Authorization 头中) |
客户端认证方式 #
方式一:HTTP Basic 认证(推荐) #
http
POST /token HTTP/1.1
Host: auth.example.com
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&scope=api:read
方式二:请求体参数 #
http
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_id=s6BhdRkqt3
&client_secret=7Fjfp0ZBr1KtDRbnfVdmIw
&scope=api:read
方式三:JWT 断言认证 #
http
POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
&scope=api:read
实现示例 #
Node.js 实现 #
javascript
const axios = require('axios');
class OAuthClient {
constructor(config) {
this.tokenEndpoint = config.tokenEndpoint;
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.scope = config.scope;
this.accessToken = null;
this.tokenExpiry = 0;
}
async getAccessToken() {
if (this.accessToken && Date.now() < this.tokenExpiry) {
return this.accessToken;
}
const credentials = Buffer.from(
`${this.clientId}:${this.clientSecret}`
).toString('base64');
const response = await axios.post(
this.tokenEndpoint,
new URLSearchParams({
grant_type: 'client_credentials',
scope: this.scope
}),
{
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded'
}
}
);
this.accessToken = response.data.access_token;
this.tokenExpiry = Date.now() + (response.data.expires_in - 60) * 1000;
return this.accessToken;
}
async fetch(url, options = {}) {
const token = await this.getAccessToken();
return axios({
url,
...options,
headers: {
...options.headers,
'Authorization': `Bearer ${token}`
}
});
}
}
const client = new OAuthClient({
tokenEndpoint: 'https://auth.example.com/token',
clientId: 'your-client-id',
clientSecret: 'your-client-secret',
scope: 'api:read api:write'
});
async function main() {
const response = await client.fetch('https://api.example.com/data');
console.log(response.data);
}
main();
Python 实现 #
python
import requests
import base64
import time
class OAuthClient:
def __init__(self, config):
self.token_endpoint = config['token_endpoint']
self.client_id = config['client_id']
self.client_secret = config['client_secret']
self.scope = config.get('scope', '')
self.access_token = None
self.token_expiry = 0
def get_access_token(self):
if self.access_token and time.time() < self.token_expiry:
return self.access_token
credentials = base64.b64encode(
f"{self.client_id}:{self.client_secret}".encode()
).decode()
response = requests.post(
self.token_endpoint,
data={
'grant_type': 'client_credentials',
'scope': self.scope
},
headers={
'Authorization': f'Basic {credentials}',
'Content-Type': 'application/x-www-form-urlencoded'
}
)
response.raise_for_status()
token_data = response.json()
self.access_token = token_data['access_token']
self.token_expiry = time.time() + token_data['expires_in'] - 60
return self.access_token
def request(self, method, url, **kwargs):
token = self.get_access_token()
headers = kwargs.pop('headers', {})
headers['Authorization'] = f'Bearer {token}'
return requests.request(method, url, headers=headers, **kwargs)
client = OAuthClient({
'token_endpoint': 'https://auth.example.com/token',
'client_id': 'your-client-id',
'client_secret': 'your-client-secret',
'scope': 'api:read api:write'
})
response = client.request('GET', 'https://api.example.com/data')
print(response.json())
Java 实现 #
java
import java.util.Base64;
import java.net.http.*;
import java.net.URI;
import com.fasterxml.jackson.databind.ObjectMapper;
public class OAuthClient {
private final String tokenEndpoint;
private final String clientId;
private final String clientSecret;
private final String scope;
private String accessToken;
private long tokenExpiry;
private final HttpClient httpClient = HttpClient.newHttpClient();
private final ObjectMapper mapper = new ObjectMapper();
public OAuthClient(String tokenEndpoint, String clientId,
String clientSecret, String scope) {
this.tokenEndpoint = tokenEndpoint;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.scope = scope;
}
public synchronized String getAccessToken() throws Exception {
if (accessToken != null && System.currentTimeMillis() < tokenExpiry) {
return accessToken;
}
String credentials = Base64.getEncoder().encodeToString(
(clientId + ":" + clientSecret).getBytes()
);
String body = "grant_type=client_credentials&scope=" +
java.net.URLEncoder.encode(scope, "UTF-8");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(tokenEndpoint))
.header("Authorization", "Basic " + credentials)
.header("Content-Type", "application/x-www-form-urlencoded")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
HttpResponse<String> response = httpClient.send(request,
HttpResponse.BodyHandlers.ofString());
TokenResponse tokenResponse = mapper.readValue(
response.body(), TokenResponse.class);
this.accessToken = tokenResponse.access_token;
this.tokenExpiry = System.currentTimeMillis() +
(tokenResponse.expires_in - 60) * 1000;
return accessToken;
}
public HttpResponse<String> fetch(String url) throws Exception {
String token = getAccessToken();
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Authorization", "Bearer " + token)
.GET()
.build();
return httpClient.send(request, HttpResponse.BodyHandlers.ofString());
}
static class TokenResponse {
public String access_token;
public int expires_in;
public String token_type;
}
}
令牌管理 #
令牌缓存 #
text
┌─────────────────────────────────────────────────────────────┐
│ 令牌缓存策略 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 为什么需要缓存? │
│ - 减少授权服务器负载 │
│ - 提高响应速度 │
│ - 避免不必要的令牌请求 │
│ │
│ 缓存策略: │
│ 1. 内存缓存 │
│ - 简单实现 │
│ - 适合单实例应用 │
│ │
│ 2. 分布式缓存(Redis) │
│ - 多实例共享 │
│ - 需要考虑并发 │
│ │
│ 3. 缓存过期时间 │
│ - 提前 1-5 分钟过期 │
│ - 避免使用过期令牌 │
│ │
└─────────────────────────────────────────────────────────────┘
Redis 缓存实现 #
javascript
const Redis = require('ioredis');
const redis = new Redis();
class CachedOAuthClient {
constructor(config) {
this.tokenEndpoint = config.tokenEndpoint;
this.clientId = config.clientId;
this.clientSecret = config.clientSecret;
this.scope = config.scope;
this.cacheKey = `oauth:token:${this.clientId}:${this.scope}`;
}
async getAccessToken() {
const cached = await redis.get(this.cacheKey);
if (cached) {
return cached;
}
const token = await this.fetchNewToken();
await redis.setex(
this.cacheKey,
token.expires_in - 60,
token.access_token
);
return token.access_token;
}
async fetchNewToken() {
const credentials = Buffer.from(
`${this.clientId}:${this.clientSecret}`
).toString('base64');
const response = await fetch(this.tokenEndpoint, {
method: 'POST',
headers: {
'Authorization': `Basic ${credentials}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
grant_type: 'client_credentials',
scope: this.scope
})
});
return await response.json();
}
}
令牌刷新策略 #
text
客户端凭证模式通常不提供刷新令牌,原因:
1. 客户端已有凭证
- 可以随时获取新令牌
- 无需用户参与
2. 简化流程
- 减少复杂性
- 降低安全风险
刷新策略:
- 在令牌过期前主动获取新令牌
- 使用缓存避免频繁请求
- 处理 401 错误时重新获取
Scope 与权限控制 #
Scope 设计 #
text
┌─────────────────────────────────────────────────────────────┐
│ Scope 设计原则 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 按资源类型划分 │
│ - api:users:read │
│ - api:users:write │
│ - api:orders:read │
│ - api:orders:write │
│ │
│ 2. 按操作类型划分 │
│ - read │
│ - write │
│ - admin │
│ │
│ 3. 按业务领域划分 │
│ - billing │
│ - inventory │
│ - reports │
│ │
│ 4. 最小权限原则 │
│ - 只请求必要的权限 │
│ - 避免过度授权 │
│ │
└─────────────────────────────────────────────────────────────┘
权限验证 #
javascript
function verifyScope(requiredScopes, tokenScopes) {
const tokenScopeSet = new Set(tokenScopes.split(' '));
return requiredScopes.every(scope => tokenScopeSet.has(scope));
}
function scopeMiddleware(requiredScopes) {
return async (req, res, next) => {
const tokenScope = req.token.scope;
if (!verifyScope(requiredScopes, tokenScope)) {
return res.status(403).json({
error: 'insufficient_scope',
error_description: 'Token does not have required scope'
});
}
next();
};
}
app.get('/api/admin/users',
scopeMiddleware(['admin:users:read']),
(req, res) => {
});
安全最佳实践 #
客户端凭证安全 #
text
┌─────────────────────────────────────────────────────────────┐
│ 客户端凭证安全存储 │
├─────────────────────────────────────────────────────────────┤
│ │
│ ❌ 不要做的事: │
│ - 不要将 client_secret 提交到代码仓库 │
│ - 不要在前端代码中使用 │
│ - 不要在日志中记录 │
│ - 不要硬编码在代码中 │
│ │
│ ✅ 应该做的事: │
│ - 使用环境变量 │
│ - 使用密钥管理服务 │
│ - 使用配置加密 │
│ - 定期轮换密钥 │
│ │
└─────────────────────────────────────────────────────────────┘
环境变量配置 #
bash
export OAUTH_CLIENT_ID="your-client-id"
export OAUTH_CLIENT_SECRET="your-client-secret"
export OAUTH_TOKEN_ENDPOINT="https://auth.example.com/token"
export OAUTH_SCOPE="api:read api:write"
javascript
const client = new OAuthClient({
clientId: process.env.OAUTH_CLIENT_ID,
clientSecret: process.env.OAUTH_CLIENT_SECRET,
tokenEndpoint: process.env.OAUTH_TOKEN_ENDPOINT,
scope: process.env.OAUTH_SCOPE
});
AWS Secrets Manager #
javascript
const { SecretsManager } = require('@aws-sdk/client-secrets-manager');
async function getOAuthCredentials() {
const client = new SecretsManager();
const response = await client.getSecretValue({
SecretId: 'oauth-client-credentials'
});
return JSON.parse(response.SecretString);
}
const credentials = await getOAuthCredentials();
令牌传输安全 #
text
┌─────────────────────────────────────────────────────────────┐
│ 令牌传输安全 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 始终使用 HTTPS │
│ - 防止中间人攻击 │
│ - 保护令牌不被窃取 │
│ │
│ 2. 使用安全的请求头 │
│ Authorization: Bearer <token> │
│ - 不要在 URL 中传递令牌 │
│ - 不要在请求体中传递令牌 │
│ │
│ 3. 验证服务器证书 │
│ - 防止证书伪造 │
│ - 确保连接到正确的服务器 │
│ │
└─────────────────────────────────────────────────────────────┘
常见问题 #
问题一:令牌过期处理 #
javascript
async function fetchWithRetry(url, options = {}, retries = 1) {
try {
return await client.fetch(url, options);
} catch (error) {
if (error.response?.status === 401 && retries > 0) {
client.accessToken = null;
return await fetchWithRetry(url, options, retries - 1);
}
throw error;
}
}
问题二:并发请求处理 #
javascript
class OAuthClient {
constructor(config) {
this.tokenPromise = null;
}
async getAccessToken() {
if (this.accessToken && Date.now() < this.tokenExpiry) {
return this.accessToken;
}
if (this.tokenPromise) {
return this.tokenPromise;
}
this.tokenPromise = this.fetchNewToken()
.finally(() => {
this.tokenPromise = null;
});
return this.tokenPromise;
}
}
问题三:多租户场景 #
javascript
class MultiTenantOAuthClient {
constructor() {
this.clients = new Map();
}
getClient(tenantId, config) {
if (!this.clients.has(tenantId)) {
this.clients.set(tenantId, new OAuthClient(config));
}
return this.clients.get(tenantId);
}
async fetch(tenantId, config, url) {
const client = this.getClient(tenantId, config);
return client.fetch(url);
}
}
客户端凭证模式 vs 其他模式 #
| 特性 | 客户端凭证 | 授权码 | 密码模式 |
|---|---|---|---|
| 用户参与 | 无 | 需要 | 需要 |
| 刷新令牌 | 通常不提供 | 支持 | 支持 |
| 代表用户 | 否 | 是 | 是 |
| 安全性 | 高 | 最高 | 中 |
| 适用场景 | 服务间通信 | Web 应用 | 第一方应用 |
| 客户端认证 | 需要 | 需要 | 需要 |
下一步 #
现在你已经掌握了客户端凭证模式,接下来学习 密码模式,了解另一种授权方式!
最后更新:2026-03-28