令牌管理

本文档详细说明如何管理 user.blym.top OAuth 2.0服务的访问令牌和刷新令牌,包括令牌刷新、验证和撤销。

令牌概述

令牌类型

令牌类型 用途 有效期 安全性
访问令牌 (access_token) 调用受保护API 1小时(可配置) 可暴露在请求中
刷新令牌 (refresh_token) 获取新的访问令牌 30天(可配置) 必须安全存储
ID令牌 (id_token) OpenID Connect身份验证 1小时 包含用户信息

令牌结构

访问令牌

at_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0
  • 前缀:at_ 表示访问令牌
  • 长度:64个字符
  • 格式:URL安全的随机字符串

刷新令牌

rt_q1r2s3t4u5v6w7x8y9z0a1b2c3d4e5f6g7h8i9j0
  • 前缀:rt_ 表示刷新令牌
  • 长度:64个字符
  • 格式:URL安全的随机字符串

ID令牌 (JWT格式)

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3VzZXIuYmx5bS50b3AiLCJzdWIiOiIxMjMiLCJhdWQiOiJjbGllbnRfYWJjMTIzIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDAsIm5vbmNlIjoieHl6Nzg5In0.signature

ID令牌是标准的JWT,包含以下声明:

声明 说明
iss 签发者:https://user.blym.top
sub 用户唯一标识
aud 接收者(客户端ID)
iat 签发时间(Unix时间戳)
exp 过期时间(Unix时间戳)
nonce 授权请求中的nonce值

令牌刷新

何时刷新令牌

  1. 访问令牌过期时

    • API返回401错误
    • 错误信息:invalid_tokenexpired_token
  2. 访问令牌即将过期时(推荐)

    • 在过期前5分钟主动刷新
    • 避免API调用失败

刷新令牌端点

POST https://user.blym.top/oauth/token.php
Content-Type: application/x-www-form-urlencoded

请求参数

参数 必填 说明
grant_type 固定值 refresh_token
refresh_token 刷新令牌
client_id 客户端ID
client_secret 客户端密钥(机密客户端)
scope 新的作用域(只能缩小,不能扩大)

刷新令牌请求示例

POST /oauth/token.php HTTP/1.1
Host: user.blym.top
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token
&refresh_token=rt_q1r2s3t4u5v6w7x8y9z0a1b2c3d4e5f6
&client_id=client_abc123
&client_secret=secret_xyz789

成功响应

HTTP/1.1 200 OK
Content-Type: application/json

{
    "access_token": "at_new_access_token_here",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "rt_new_refresh_token_here",
    "scope": "openid profile email"
}

重要: 每次刷新都会返回新的访问令牌和刷新令牌,旧的刷新令牌将失效。

错误响应

HTTP/1.1 400 Bad Request
Content-Type: application/json

{
    "error": "invalid_grant",
    "error_description": "Refresh token has expired"
}

错误代码:

错误代码 说明 处理方式
invalid_grant 刷新令牌无效或过期 重新授权
invalid_client 客户端认证失败 检查凭据
invalid_scope 请求的作用域超出原授权范围 使用原作用域或不传scope

令牌刷新实现

JavaScript实现

class TokenManager {
    constructor(config) {
        this.clientId = config.clientId;
        this.clientSecret = config.clientSecret;
        this.baseUrl = 'https://user.blym.top';
        this.storage = config.storage || sessionStorage;
    }
    
    // 保存令牌
    saveTokens(tokens) {
        this.storage.setItem('access_token', tokens.access_token);
        this.storage.setItem('refresh_token', tokens.refresh_token);
        this.storage.setItem('token_expires_at', Date.now() + tokens.expires_in * 1000);
    }
    
    // 获取访问令牌
    getAccessToken() {
        return this.storage.getItem('access_token');
    }
    
    // 检查令牌是否过期
    isTokenExpired() {
        const expiresAt = parseInt(this.storage.getItem('token_expires_at') || '0');
        return Date.now() >= expiresAt;
    }
    
    // 检查令牌是否即将过期(5分钟内)
    isTokenExpiringSoon(threshold = 300) {
        const expiresAt = parseInt(this.storage.getItem('token_expires_at') || '0');
        return (expiresAt - Date.now()) < threshold * 1000;
    }
    
    // 刷新访问令牌
    async refreshAccessToken() {
        const refreshToken = this.storage.getItem('refresh_token');
        
        if (!refreshToken) {
            throw new Error('No refresh token available');
        }
        
        const params = new URLSearchParams({
            grant_type: 'refresh_token',
            refresh_token: refreshToken,
            client_id: this.clientId
        });
        
        // 机密客户端添加client_secret
        if (this.clientSecret) {
            params.append('client_secret', this.clientSecret);
        }
        
        const response = await fetch(`${this.baseUrl}/oauth/token.php`, {
            method: 'POST',
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
            body: params
        });
        
        if (!response.ok) {
            const error = await response.json();
            
            // 刷新令牌过期,需要重新授权
            if (error.error === 'invalid_grant') {
                this.clearTokens();
                throw new Error('REFRESH_TOKEN_EXPIRED');
            }
            
            throw new Error(error.error_description || error.error);
        }
        
        const tokens = await response.json();
        this.saveTokens(tokens);
        return tokens;
    }
    
    // 获取有效的访问令牌(自动刷新)
    async getValidAccessToken() {
        // 没有令牌
        if (!this.getAccessToken()) {
            throw new Error('NO_TOKEN');
        }
        
        // 令牌已过期
        if (this.isTokenExpired()) {
            return await this.refreshAccessToken();
        }
        
        // 令牌即将过期,主动刷新
        if (this.isTokenExpiringSoon()) {
            try {
                return await this.refreshAccessToken();
            } catch (e) {
                // 刷新失败,但当前令牌还有效,继续使用
                console.warn('Token refresh failed, using current token:', e);
            }
        }
        
        return this.getAccessToken();
    }
    
    // 清除令牌
    clearTokens() {
        this.storage.removeItem('access_token');
        this.storage.removeItem('refresh_token');
        this.storage.removeItem('token_expires_at');
    }
}

// 使用示例
const tokenManager = new TokenManager({
    clientId: 'your_client_id',
    clientSecret: 'your_client_secret', // 公开客户端不传
    storage: sessionStorage
});

// 获取有效令牌并调用API
async function callApi() {
    try {
        const accessToken = await tokenManager.getValidAccessToken();
        const response = await fetch('https://user.blym.top/api/userinfo.php', {
            headers: { 'Authorization': `Bearer ${accessToken}` }
        });
        return await response.json();
    } catch (e) {
        if (e.message === 'NO_TOKEN' || e.message === 'REFRESH_TOKEN_EXPIRED') {
            // 需要重新授权
            window.location.href = getAuthorizationUrl();
        }
        throw e;
    }
}

PHP实现

<?php
class TokenManager {
    private $clientId;
    private $clientSecret;
    private $baseUrl = 'https://user.blym.top';
    
    public function __construct($clientId, $clientSecret = null) {
        $this->clientId = $clientId;
        $this->clientSecret = $clientSecret;
    }
    
    public function saveTokens($tokens) {
        $_SESSION['access_token'] = $tokens['access_token'];
        $_SESSION['refresh_token'] = $tokens['refresh_token'];
        $_SESSION['token_expires_at'] = time() + $tokens['expires_in'];
    }
    
    public function getAccessToken() {
        return $_SESSION['access_token'] ?? null;
    }
    
    public function isTokenExpired() {
        $expiresAt = $_SESSION['token_expires_at'] ?? 0;
        return time() >= $expiresAt;
    }
    
    public function isTokenExpiringSoon($threshold = 300) {
        $expiresAt = $_SESSION['token_expires_at'] ?? 0;
        return ($expiresAt - time()) < $threshold;
    }
    
    public function refreshAccessToken() {
        $refreshToken = $_SESSION['refresh_token'] ?? null;
        
        if (!$refreshToken) {
            throw new Exception('No refresh token available');
        }
        
        $params = [
            'grant_type' => 'refresh_token',
            'refresh_token' => $refreshToken,
            'client_id' => $this->clientId
        ];
        
        if ($this->clientSecret) {
            $params['client_secret'] = $this->clientSecret;
        }
        
        $ch = curl_init($this->baseUrl . '/oauth/token.php');
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Content-Type: application/x-www-form-urlencoded'
        ]);
        
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        
        $data = json_decode($response, true);
        
        if ($httpCode !== 200) {
            if ($data['error'] === 'invalid_grant') {
                $this->clearTokens();
                throw new Exception('REFRESH_TOKEN_EXPIRED');
            }
            throw new Exception($data['error_description'] ?? $data['error']);
        }
        
        $this->saveTokens($data);
        return $data;
    }
    
    public function getValidAccessToken() {
        if (!$this->getAccessToken()) {
            throw new Exception('NO_TOKEN');
        }
        
        if ($this->isTokenExpired()) {
            return $this->refreshAccessToken()['access_token'];
        }
        
        if ($this->isTokenExpiringSoon()) {
            try {
                return $this->refreshAccessToken()['access_token'];
            } catch (Exception $e) {
                error_log('Token refresh failed: ' . $e->getMessage());
            }
        }
        
        return $this->getAccessToken();
    }
    
    public function clearTokens() {
        unset($_SESSION['access_token']);
        unset($_SESSION['refresh_token']);
        unset($_SESSION['token_expires_at']);
    }
}

// 使用示例
session_start();

$tokenManager = new TokenManager(
    'your_client_id',
    'your_client_secret'
);

function callApi($tokenManager) {
    try {
        $accessToken = $tokenManager->getValidAccessToken();
        
        $ch = curl_init('https://user.blym.top/api/userinfo.php');
        curl_setopt($ch, CURLOPT_HTTPHEADER, [
            'Authorization: Bearer ' . $accessToken
        ]);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        
        $response = curl_exec($ch);
        $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
        curl_close($ch);
        
        if ($httpCode === 401) {
            // 令牌无效,尝试刷新
            $tokenManager->refreshAccessToken();
            return callApi($tokenManager);
        }
        
        return json_decode($response, true);
        
    } catch (Exception $e) {
        if (in_array($e->getMessage(), ['NO_TOKEN', 'REFRESH_TOKEN_EXPIRED'])) {
            header('Location: ' . getAuthorizationUrl());
            exit;
        }
        throw $e;
    }
}
?>

令牌验证

验证端点

GET https://user.blym.top/api/tokeninfo.php
Authorization: Bearer {access_token}

请求示例

GET /api/tokeninfo.php HTTP/1.1
Host: user.blym.top
Authorization: Bearer at_a1b2c3d4e5f6g7h8i9j0

成功响应(有效令牌)

HTTP/1.1 200 OK
Content-Type: application/json

{
    "active": true,
    "client_id": "client_abc123",
    "user_id": "123",
    "username": "zhangsan",
    "email": "zhangsan@example.com",
    "scope": "openid profile email",
    "exp": 1700003600,
    "iat": 1700000000,
    "token_type": "Bearer"
}

无效令牌响应

HTTP/1.1 200 OK
Content-Type: application/json

{
    "active": false
}

验证实现

async function validateToken(accessToken) {
    const response = await fetch('https://user.blym.top/api/tokeninfo.php', {
        headers: { 'Authorization': `Bearer ${accessToken}` }
    });
    
    const data = await response.json();
    
    if (!data.active) {
        throw new Error('Token is invalid or expired');
    }
    
    return data;
}

令牌撤销

管理员撤销

管理员可以在后台撤销令牌:

  1. 访问 https://user.blym.top/admin/oauth_server.php
  2. 点击"令牌管理"标签页
  3. 找到目标令牌,点击"撤销"

用户撤销

用户可以在个人中心解除第三方应用授权:

  1. 登录 https://user.blym.top
  2. 进入"账号设置" > "授权管理"
  3. 找到应用,点击"解除授权"

撤销后的影响

  • 访问令牌立即失效
  • 刷新令牌立即失效
  • 所有使用该令牌的API调用将返回401错误
  • 用户需要重新授权

令牌生命周期

┌─────────────────────────────────────────────────────────────────────────┐
│                          令牌生命周期                                    │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  用户授权                                                               │
│     │                                                                   │
│     ▼                                                                   │
│  ┌─────────────────┐                                                   │
│  │  生成授权码      │  有效期: 10分钟                                    │
│  │  (code)         │  只能使用一次                                      │
│  └────────┬────────┘                                                   │
│           │                                                             │
│           ▼                                                             │
│  ┌─────────────────┐                                                   │
│  │  换取令牌        │                                                   │
│  └────────┬────────┘                                                   │
│           │                                                             │
│     ┌─────┴─────┐                                                      │
│     │           │                                                      │
│     ▼           ▼                                                      │
│  ┌──────────┐  ┌──────────────┐                                        │
│  │访问令牌   │  │ 刷新令牌      │                                        │
│  │1小时有效  │  │ 30天有效      │                                        │
│  └─────┬────┘  └──────┬───────┘                                        │
│        │              │                                                │
│        │              │                                                │
│        │    ┌─────────┴─────────┐                                      │
│        │    │                   │                                      │
│        │    ▼                   ▼                                      │
│        │  ┌──────────┐    ┌──────────────┐                             │
│        │  │刷新成功   │    │ 刷新令牌过期  │                             │
│        │  │获取新令牌 │    │ 需重新授权    │                             │
│        │  └─────┬────┘    └──────────────┘                             │
│        │        │                                                        │
│        └────────┤                                                        │
│                 ▼                                                        │
│           ┌───────────┐                                                 │
│           │  调用API   │                                                 │
│           └───────────┘                                                 │
│                                                                         │
│  撤销场景:                                                              │
│  - 管理员手动撤销                                                       │
│  - 用户解除授权                                                         │
│  - 客户端被禁用                                                         │
│  - 安全事件响应                                                         │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

令牌存储安全

存储方式对比

存储方式 安全性 适用场景 推荐度
HttpOnly Cookie ⭐⭐⭐⭐⭐ 服务器端Web应用 推荐
Session Storage ⭐⭐⭐⭐ SPA 推荐
服务器Session ⭐⭐⭐⭐⭐ 服务器端Web应用 推荐
Local Storage ⭐⭐ 不推荐 不推荐
URL参数 永远不要使用 禁止

服务器端存储

<?php
// 加密存储令牌
function encryptToken($token, $key) {
    $iv = random_bytes(16);
    $encrypted = openssl_encrypt($token, 'AES-256-CBC', $key, 0, $iv);
    return base64_encode($iv . $encrypted);
}

function decryptToken($encryptedToken, $key) {
    $data = base64_decode($encryptedToken);
    $iv = substr($data, 0, 16);
    $encrypted = substr($data, 16);
    return openssl_decrypt($encrypted, 'AES-256-CBC', $key, 0, $iv);
}

// 存储到数据库
function saveTokenToDatabase($userId, $tokens) {
    $encryptionKey = getenv('TOKEN_ENCRYPTION_KEY');
    
    $stmt = $pdo->prepare('
        INSERT INTO user_tokens (user_id, access_token, refresh_token, expires_at)
        VALUES (?, ?, ?, ?)
    ');
    
    $stmt->execute([
        $userId,
        encryptToken($tokens['access_token'], $encryptionKey),
        encryptToken($tokens['refresh_token'], $encryptionKey),
        date('Y-m-d H:i:s', time() + $tokens['expires_in'])
    ]);
}
?>

客户端存储

// 使用Session Storage
class SecureTokenStorage {
    constructor() {
        this.storage = sessionStorage;
    }
    
    save(tokens) {
        this.storage.setItem('access_token', tokens.access_token);
        this.storage.setItem('refresh_token', tokens.refresh_token);
        this.storage.setItem('expires_at', Date.now() + tokens.expires_in * 1000);
    }
    
    getAccessToken() {
        return this.storage.getItem('access_token');
    }
    
    getRefreshToken() {
        return this.storage.getItem('refresh_token');
    }
    
    clear() {
        this.storage.removeItem('access_token');
        this.storage.removeItem('refresh_token');
        this.storage.removeItem('expires_at');
    }
}

最佳实践

1. 主动刷新令牌

不要等到令牌过期才刷新,应在即将过期时主动刷新:

// 在每次API调用前检查
async function apiCall(url, options = {}) {
    if (tokenManager.isTokenExpiringSoon(300)) { // 5分钟内过期
        await tokenManager.refreshAccessToken();
    }
    
    options.headers = {
        ...options.headers,
        'Authorization': `Bearer ${tokenManager.getAccessToken()}`
    };
    
    return fetch(url, options);
}

2. 处理并发刷新

多个请求同时发现令牌过期时,应只刷新一次:

class TokenManager {
    constructor() {
        this.refreshPromise = null;
    }
    
    async getValidAccessToken() {
        if (this.refreshPromise) {
            return this.refreshPromise;
        }
        
        if (this.isTokenExpired() || this.isTokenExpiringSoon()) {
            this.refreshPromise = this.refreshAccessToken();
            try {
                return await this.refreshPromise;
            } finally {
                this.refreshPromise = null;
            }
        }
        
        return this.getAccessToken();
    }
}

3. 错误处理

async function handleApiError(error, retryCount = 0) {
    if (error.status === 401 && retryCount < 1) {
        // 令牌可能过期,尝试刷新
        try {
            await tokenManager.refreshAccessToken();
            return retryApiCall();
        } catch (refreshError) {
            // 刷新失败,需要重新授权
            redirectToLogin();
        }
    }
    
    throw error;
}

4. 安全退出

async function logout() {
    try {
        // 清除本地令牌
        tokenManager.clearTokens();
        
        // 清除Session
        sessionStorage.clear();
        
        // 重定向到首页
        window.location.href = '/';
    } catch (e) {
        console.error('Logout error:', e);
    }
}

下一步