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