本文档详细说明如何管理 user.blym.top OAuth 2.0服务的访问令牌和刷新令牌,包括令牌刷新、验证和撤销。
| 令牌类型 | 用途 | 有效期 | 安全性 |
|---|---|---|---|
| 访问令牌 (access_token) | 调用受保护API | 1小时(可配置) | 可暴露在请求中 |
| 刷新令牌 (refresh_token) | 获取新的访问令牌 | 30天(可配置) | 必须安全存储 |
| ID令牌 (id_token) | OpenID Connect身份验证 | 1小时 | 包含用户信息 |
at_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0
at_ 表示访问令牌rt_q1r2s3t4u5v6w7x8y9z0a1b2c3d4e5f6g7h8i9j0
rt_ 表示刷新令牌eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwczovL3VzZXIuYmx5bS50b3AiLCJzdWIiOiIxMjMiLCJhdWQiOiJjbGllbnRfYWJjMTIzIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDAsIm5vbmNlIjoieHl6Nzg5In0.signature
ID令牌是标准的JWT,包含以下声明:
| 声明 | 说明 |
|---|---|
| iss | 签发者:https://user.blym.top |
| sub | 用户唯一标识 |
| aud | 接收者(客户端ID) |
| iat | 签发时间(Unix时间戳) |
| exp | 过期时间(Unix时间戳) |
| nonce | 授权请求中的nonce值 |
访问令牌过期时
invalid_token 或 expired_token访问令牌即将过期时(推荐)
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 |
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
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;
}
管理员可以在后台撤销令牌:
用户可以在个人中心解除第三方应用授权:
┌─────────────────────────────────────────────────────────────────────────┐
│ 令牌生命周期 │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ 用户授权 │
│ │ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ 生成授权码 │ 有效期: 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');
}
}
不要等到令牌过期才刷新,应在即将过期时主动刷新:
// 在每次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);
}
多个请求同时发现令牌过期时,应只刷新一次:
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();
}
}
async function handleApiError(error, retryCount = 0) {
if (error.status === 401 && retryCount < 1) {
// 令牌可能过期,尝试刷新
try {
await tokenManager.refreshAccessToken();
return retryApiCall();
} catch (refreshError) {
// 刷新失败,需要重新授权
redirectToLogin();
}
}
throw error;
}
async function logout() {
try {
// 清除本地令牌
tokenManager.clearTokens();
// 清除Session
sessionStorage.clear();
// 重定向到首页
window.location.href = '/';
} catch (e) {
console.error('Logout error:', e);
}
}