本文目录导读:

我来详细讲解PHP中JWT(JSON Web Token)的过期刷新机制实现。
JWT基础概念
1 JWT结构
// JWT由三部分组成 header.payload.signature // 示例 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
完整实现方案
1 基础JWT工具类
<?php
namespace App\Utils;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
class JwtHandler
{
private string $privateKey;
private string $publicKey;
private string $algorithm;
public function __construct()
{
$this->privateKey = file_get_contents('/path/to/private_key.pem');
$this->publicKey = file_get_contents('/path/to/public_key.pem');
$this->algorithm = 'RS256';
}
/**
* 生成访问令牌
*/
public function generateAccessToken(array $payload): string
{
$now = time();
$tokenData = array_merge($payload, [
'iat' => $now, // 签发时间
'nbf' => $now, // 生效时间
'exp' => $now + 3600, // 过期时间(1小时)
'jti' => bin2hex(random_bytes(16)), // 唯一标识
'type' => 'access', // 令牌类型
]);
return JWT::encode($tokenData, $this->privateKey, $this->algorithm);
}
/**
* 生成刷新令牌(长期有效)
*/
public function generateRefreshToken(array $payload): string
{
$now = time();
$tokenData = array_merge($payload, [
'iat' => $now,
'nbf' => $now,
'exp' => $now + (7 * 24 * 3600), // 7天过期
'jti' => bin2hex(random_bytes(16)),
'type' => 'refresh',
'scope' => 'refresh_token',
]);
return JWT::encode($tokenData, $this->privateKey, $this->algorithm);
}
/**
* 验证令牌
*/
public function validateToken(string $token, string $expectedType = 'access'): ?array
{
try {
$decoded = JWT::decode($token, new Key($this->publicKey, $this->algorithm));
// 验证令牌类型
if ($decoded->type !== $expectedType) {
return null;
}
// 检查是否被撤销(可选)
if ($this->isBlacklisted($decoded->jti)) {
return null;
}
return (array) $decoded;
} catch (ExpiredException $e) {
// 令牌过期
return ['error' => 'token_expired'];
} catch (\Exception $e) {
// 其他错误
return null;
}
}
/**
* 解析令牌(不验证过期时间)
*/
public function decodeToken(string $token): array
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return [];
}
$payload = base64_decode(strtr($parts[1], '-_', '+/'));
return json_decode($payload, true);
}
private function isBlacklisted(string $jti): bool
{
// 检查Redis或数据库中是否有撤销记录
return false;
}
}
2 令牌管理服务
<?php
namespace App\Services;
use App\Utils\JwtHandler;
use App\Models\RefreshToken;
use Predis\Client;
use Carbon\Carbon;
class TokenService
{
private JwtHandler $jwtHandler;
private Client $redis;
public function __construct(JwtHandler $jwtHandler, Client $redis)
{
$this->jwtHandler = $jwtHandler;
$this->redis = $redis;
}
/**
* 生成令牌对
*/
public function generateTokenPair(array $userData): array
{
// 基础用户信息
$payload = [
'user_id' => $userData['id'],
'username' => $userData['username'],
'role' => $userData['role'] ?? 'user'
];
$accessToken = $this->jwtHandler->generateAccessToken($payload);
$refreshToken = $this->jwtHandler->generateRefreshToken($payload);
// 存储刷新令牌
$this->storeRefreshToken([
'token' => $refreshToken,
'user_id' => $userData['id'],
'expires_at' => time() + (7 * 24 * 3600),
'device_info' => $_SERVER['HTTP_USER_AGENT'] ?? 'unknown'
]);
return [
'access_token' => $accessToken,
'refresh_token' => $refreshToken,
'expires_in' => 3600,
'token_type' => 'Bearer'
];
}
/**
* 刷新访问令牌
*/
public function refreshAccessToken(string $refreshToken): ?array
{
// 验证刷新令牌
$decoded = $this->jwtHandler->validateToken($refreshToken, 'refresh');
if (!$decoded || isset($decoded['error'])) {
return null;
}
// 检查是否已被使用(防止重放攻击)
if (!$this->isRefreshTokenValid($decoded['jti'])) {
return null;
}
// 获取用户数据
$userId = $decoded['user_id'];
// 生成新的访问令牌(保留刷新令牌)
$payload = [
'user_id' => $userId,
'username' => $decoded['username'],
'role' => $decoded['role']
];
$newAccessToken = $this->jwtHandler->generateAccessToken($payload);
// 可选的:轮换刷新令牌
$newRefreshToken = $this->rotateRefreshToken($refreshToken, $decoded['jti'], $userId);
return [
'access_token' => $newAccessToken,
'refresh_token' => $newRefreshToken ?? $refreshToken,
'expires_in' => 3600,
'token_type' => 'Bearer'
];
}
/**
* 轮换刷新令牌
*/
private function rotateRefreshToken(string $oldToken, string $oldJti, int $userId): ?string
{
// 撤销旧令牌
$this->revokeRefreshToken($oldJti);
// 生成新刷新令牌
$newRefreshToken = $this->jwtHandler->generateRefreshToken([
'user_id' => $userId
]);
// 存储新令牌
$this->storeRefreshToken([
'token' => $newRefreshToken,
'user_id' => $userId,
'expires_at' => time() + (7 * 24 * 3600)
]);
return $newRefreshToken;
}
/**
* 存储刷新令牌
*/
private function storeRefreshToken(array $data): void
{
$decoded = $this->jwtHandler->decodeToken($data['token']);
$tokenRecord = new RefreshToken([
'token_hash' => hash('sha256', $data['token']),
'user_id' => $data['user_id'],
'client_id' => $decoded['jti'],
'expires_at' => Carbon::createFromTimestamp($data['expires_at']),
'revoked' => false,
'device_info' => $data['device_info'] ?? null
]);
$tokenRecord->save();
// 同时存储到Redis用于快速访问
$this->redis->setex(
"refresh_token:{$decoded['jti']}",
7 * 24 * 3600,
json_encode([
'user_id' => $data['user_id'],
'revoked' => false
])
);
}
/**
* 检查刷新令牌是否有效
*/
private function isRefreshTokenValid(string $jti): bool
{
$tokenData = json_decode($this->redis->get("refresh_token:{$jti}"), true);
if (!$tokenData || $tokenData['revoked']) {
return false;
}
return true;
}
/**
* 撤销刷新令牌
*/
private function revokeRefreshToken(string $jti): void
{
$this->redis->setex(
"refresh_token:{$jti}",
7 * 24 * 3600,
json_encode(['revoked' => true])
);
// 同时更新数据库记录
RefreshToken::where('client_id', $jti)
->update(['revoked' => true, 'revoked_at' => Carbon::now()]);
}
/**
* 撤销所有用户令牌(强制退出)
*/
public function revokeUserTokens(int $userId): void
{
$tokens = RefreshToken::where('user_id', $userId)
->where('revoked', false)
->get();
foreach ($tokens as $token) {
$this->revokeRefreshToken($token->client_id);
}
// 撤销访问令牌(通过Redis黑名单)
$this->redis->sadd("user_blacklist:{$userId}", time());
$this->redis->expire("user_blacklist:{$userId}", 3600);
}
}
3 认证中间件
<?php
namespace App\Http\Middleware;
use App\Utils\JwtHandler;
use App\Services\TokenService;
use Firebase\JWT\ExpiredException;
use Exception;
class Authenticate
{
private JwtHandler $jwtHandler;
private TokenService $tokenService;
public function __construct(JwtHandler $jwtHandler, TokenService $tokenService)
{
$this->jwtHandler = $jwtHandler;
$this->tokenService = $tokenService;
}
/**
* 处理请求
*/
public function handle($request, $next)
{
$authHeader = $request->getHeader('Authorization');
if (!$authHeader || !preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) {
return $this->unauthorized('未提供有效的认证令牌');
}
$accessToken = $matches[1];
// 验证访问令牌
$decoded = $this->jwtHandler->validateToken($accessToken, 'access');
if (!$decoded) {
return $this->unauthorized('无效的访问令牌');
}
if (isset($decoded['error']) && $decoded['error'] === 'token_expired') {
// 检查是否有刷新令牌
$refreshToken = $request->getParam('refresh_token');
if (!$refreshToken) {
return $this->tokenExpired('访问令牌已过期');
}
// 尝试刷新令牌
$newTokens = $this->tokenService->refreshAccessToken($refreshToken);
if (!$newTokens) {
return $this->unauthorized('刷新令牌无效');
}
// 设置新的令牌
$request->setTokens($newTokens);
// 继续处理请求
$request->setUserData($decoded);
// 在响应中添加新的令牌
$response = $next($request);
$response->headers['X-New-Tokens'] = json_encode($newTokens);
return $response;
}
// 设置用户信息到请求
$request->setUserData($decoded);
return $next($request);
}
private function unauthorized(string $message)
{
return [
'status' => 401,
'error' => 'unauthorized',
'message' => $message
];
}
private function tokenExpired(string $message)
{
return [
'status' => 401,
'error' => 'token_expired',
'message' => $message,
'token_expired' => true
];
}
}
4 刷新端点控制器
<?php
namespace App\Controllers;
use App\Services\TokenService;
use App\Utils\JwtHandler;
class AuthController
{
private TokenService $tokenService;
private JwtHandler $jwtHandler;
public function __construct(TokenService $tokenService, JwtHandler $jwtHandler)
{
$this->tokenService = $tokenService;
$this->jwtHandler = $jwtHandler;
}
/**
* 刷新令牌端点
*/
public function refreshToken(array $request, array $server): array
{
// 从请求中获取刷新令牌
$refreshToken = $request['refresh_token'] ?? null;
if (!$refreshToken) {
return [
'status' => 400,
'body' => [
'error' => 'missing_refresh_token',
'message' => '缺少刷新令牌'
]
];
}
// 刷新访问令牌
$newTokens = $this->tokenService->refreshAccessToken($refreshToken);
if (!$newTokens) {
return [
'status' => 401,
'body' => [
'error' => 'invalid_refresh_token',
'message' => '刷新令牌无效或已过期'
]
];
}
return [
'status' => 200,
'body' => $newTokens
];
}
/**
* 登出(撤销令牌)
*/
public function logout(array $request, array $server): array
{
$authHeader = $server['HTTP_AUTHORIZATION'] ?? '';
if (preg_match('/Bearer\s+(.+)/', $authHeader, $matches)) {
$accessToken = $matches[1];
$userData = $this->jwtHandler->decodeToken($accessToken);
// 撤销用户所有令牌
if (isset($userData['user_id'])) {
$this->tokenService->revokeUserTokens($userData['user_id']);
}
}
return [
'status' => 200,
'body' => ['message' => '成功登出']
];
}
}
5 客户端集成示例
<?php
// 创建API客户端封装
class ApiClient
{
private string $accessToken;
private string $refreshToken;
private string $baseUrl;
public function __construct(string $baseUrl)
{
$this->baseUrl = $baseUrl;
$this->loadTokens(); // 从存储中加载令牌
}
/**
* 发送带认证的请求
*/
public function request(string $method, string $endpoint, array $data = []): array
{
$attempts = 0;
while ($attempts < 2) {
$response = $this->sendRequest($method, $endpoint, $data);
// 检查是否过期
if ($response['status'] === 401 && isset($response['error']) && $response['error'] === 'token_expired') {
// 尝试刷新令牌
if ($this->refreshToken) {
$refreshed = $this->refreshAccessToken();
if ($refreshed) {
$attempts++;
continue;
}
}
}
return $response;
}
return ['error' => '认证失败'];
}
/**
* 刷新访问令牌
*/
private function refreshAccessToken(): bool
{
$ch = curl_init($this->baseUrl . '/auth/refresh');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
'refresh_token' => $this->refreshToken
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json'
]);
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status === 200) {
$data = json_decode($response, true);
$this->accessToken = $data['access_token'];
$this->refreshToken = $data['refresh_token'];
$this->saveTokens(); // 存储新令牌
return true;
}
return false;
}
private function sendRequest(string $method, string $endpoint, array $data): array
{
$ch = curl_init($this->baseUrl . $endpoint);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Authorization: Bearer ' . $this->accessToken,
'Content-Type: application/json'
]);
if ($method === 'GET') {
curl_setopt($ch, CURLOPT_HTTPGET, true);
} else if ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
}
$response = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return array_merge(
json_decode($response, true) ?? [],
['status' => $status]
);
}
}
数据库结构
-- 刷新令牌表
CREATE TABLE refresh_tokens (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
client_id VARCHAR(100) NOT NULL, -- JWT的jti
token_hash VARCHAR(255) NOT NULL, -- 令牌的SHA256哈希
expires_at DATETIME NOT NULL,
revoked BOOLEAN DEFAULT FALSE,
revoked_at DATETIME NULL,
device_info VARCHAR(255) NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user_id (user_id),
INDEX idx_client_id (client_id),
INDEX idx_revoked (revoked),
UNIQUE KEY uniq_token_hash (token_hash)
);
-- 黑名单表(用于撤销访问令牌)
CREATE TABLE token_blacklist (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
jti VARCHAR(100) NOT NULL UNIQUE,
user_id BIGINT UNSIGNED NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_expires_at (expires_at)
);
安全建议
1 关键安全措施
// 1. 请求限制
class RateLimit
{
private $redis;
public function checkRefreshLimit(int $userId, int $max = 10)
{
$key = "refresh_limit:{$userId}:" . date('Y-m-d-H');
$count = $this->redis->incr($key);
$this->redis->expire($key, 3600);
if ($count > $max) {
throw new Exception('刷新次数过多');
}
}
}
// 2. 客户端检测
class DeviceFingerprint
{
public static function create(array $server): string
{
$data = [
$server['HTTP_USER_AGENT'] ?? '',
$server['REMOTE_ADDR'] ?? '',
$server['HTTP_ACCEPT_LANGUAGE'] ?? ''
];
return hash('sha256', implode('|', $data));
}
}
// 3. 安全配置
class SecurityConfig
{
public const CONFIG = [
// 访问令牌有效期
'access_token_ttl' => 900, // 15分钟
'refresh_token_ttl' => 604800, // 7天
'refresh_token_rotate' => true, // 每次刷新轮换
'max_concurrent_tokens' => 5, // 最大并发令牌
'blacklist_expired' => 60, // 黑名单过期时间(分钟)
'enable_device_fingerprint' => true, // 启用设备指纹
'min_refresh_interval' => 60, // 最小刷新间隔(秒)
];
}
2 实现最佳实践
class TokenBestPractices
{
/**
* 1. 使用短生命周期的访问令牌
*/
public function example1(): void
{
// 访问令牌:15分钟过期
$accessToken = $this->generateAccessToken($payload, 900);
// 刷新令牌:7天,可轮换
$refreshToken = $this->generateRefreshToken($payload, 604800);
}
/**
* 2. 实现令牌轮换
*/
public function example2(TokenStorage $storage): void
{
// 检测到生成过多新令牌时撤销用户所有令牌
$tokenCount = $storage->countActiveTokens($userId);
if ($tokenCount > 10) {
$storage->revokeAllUserTokens($userId);
// 要求用户重新登录
}
}
/**
* 3. 实现即时撤销
*/
public function example3(TokenStorage $storage): void
{
// 密码更改后
$storage->revokeAllUserTokens($userId);
// 添加到全局黑名单
$storage->blacklistUser($userId, 3600);
}
/**
* 4. 使用滑动过期时间
*/
public function example4(SessionManager $session): void
{
// 如果用户持续活跃,刷新过期时间
if ($session->isActive()) {
$session->refreshExpiration(30 * 60); // 续期30分钟
}
}
}
完整的中间件实现
<?php
namespace App\Http\Middleware;
use Closure;
use App\Services\TokenService;
use App\Validators\TokenValidator;
class TokenRefreshMiddleware
{
private TokenService $tokenService;
private TokenValidator $validator;
public function handle($request, Closure $next)
{
$token = $request->bearerToken();
if (!$token) {
return response()->json(['error' => 'Unauthorized'], 401);
}
try {
// 验证访问令牌
$payload = $this->validator->validateAccessToken($token);
$request->attributes->set('user', $payload);
} catch (AccessTokenExpiredException $e) {
// 访问令牌过期,尝试刷新
$refreshToken = $request->cookie('refresh_token')
?? $request->header('X-Refresh-Token');
if (!$refreshToken) {
return response()->json([
'error' => 'token_expired',
'message' => '请重新登录'
], 401);
}
try {
$newTokens = $this->tokenService->refreshTokens($refreshToken);
// 设置新令牌到响应
$response = $next($request);
// 通过响应头或Cookie发送新令牌
return $this->attachNewTokens($response, $newTokens);
} catch (RefreshTokenInvalidException $e) {
return response()->json([
'error' => 'refresh_token_invalid',
'message' => '刷新令牌无效'
], 401);
}
} catch (InvalidTokenException $e) {
return response()->json(['error' => 'Invalid token'], 401);
}
return $next($request);
}
private function attachNewTokens($response, array $tokens)
{
$response->headers->set('X-Access-Token', $tokens['access_token']);
if (isset($tokens['refresh_token'])) {
$response->headers->setCookie(
cookie('refresh_token', $tokens['refresh_token'], 604800, '/', null, true, true, false, 'Strict')
);
}
return $response;
}
}
测试用例
<?php
namespace Tests\Unit;
use PHPUnit\Framework\TestCase;
use App\Services\TokenService;
use App\Utils\JwtHandler;
class TokenRefreshTest extends TestCase
{
private TokenService $tokenService;
protected function setUp(): void
{
$this->tokenService = new TokenService(
new JwtHandler(),
$this->createMock(\Predis\Client::class)
);
}
public function testRefreshTokenRotation()
{
// 初始令牌
$userData = ['id' => 1, 'username' => 'test'];
$tokens = $this->tokenService->generateTokenPair($userData);
// 模拟请求刷新
$newTokens = $this->tokenService->refreshAccessToken($tokens['refresh_token']);
$this->assertNotEquals($tokens['refresh_token'], $newTokens['refresh_token']);
$this->assertNotEquals($tokens['access_token'], $newTokens['access_token']);
}
public function testExpiredAccessTokenRefresh()
{
// 创建过期访问令牌
$expiredToken = $this->createExpiredAccessToken();
// 验证令牌无效
$result = $this->tokenService->refreshAccessToken($expiredToken);
$this->assertNull($result);
}
public function testRevokedTokenRejection()
{
// 生成令牌
$tokens = $this->tokenService->generateTokenPair(['id' => 1, 'username' => 'test']);
// 模拟撤销
$this->tokenService->revokeUserTokens(1);
// 使用已撤销的刷新令牌
$result = $this->tokenService->refreshAccessToken($tokens['refresh_token']);
$this->assertNull($result);
}
private function createExpiredAccessToken(): string
{
$now = time();
$payload = [
'user_id' => 1,
'username' => 'test',
'iat' => $now - 7200,
'nbf' => $now - 7200,
'exp' => $now - 3600,
'jti' => bin2hex(random_bytes(16)),
'type' => 'access'
];
return JWT::encode($payload, $this->privateKey, 'RS256');
}
}
最佳实践总结
关键点:
- 访问令牌和刷新令牌分离
- 刷新令牌轮换机制
- 并发安全处理
- 设备指纹识别
- 速率限制
- 令牌撤销机制
安全事项:
- 使用HTTPS
- 访问令牌短期+刷新令牌长期
- 实现令牌轮换
- 检测异常行为
- 用户操作后立即撤销
性能优化:
- Redis缓存令牌状态
- 黑名单过期自动清理
- 批量处理过期令牌
这个完整的实现方案提供了生产级的JWT过期刷新机制,包含了安全处理、性能和可用性的平衡。