PHP JWT过期刷新机制

wen PHP项目 3

本文目录导读:

PHP JWT过期刷新机制

  1. JWT基础概念
  2. 完整实现方案
  3. 数据库结构
  4. 安全建议
  5. 完整的中间件实现
  6. 测试用例
  7. 最佳实践总结

我来详细讲解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');
    }
}

最佳实践总结

关键点:

  1. 访问令牌和刷新令牌分离
  2. 刷新令牌轮换机制
  3. 并发安全处理
  4. 设备指纹识别
  5. 速率限制
  6. 令牌撤销机制

安全事项:

  • 使用HTTPS
  • 访问令牌短期+刷新令牌长期
  • 实现令牌轮换
  • 检测异常行为
  • 用户操作后立即撤销

性能优化:

  • Redis缓存令牌状态
  • 黑名单过期自动清理
  • 批量处理过期令牌

这个完整的实现方案提供了生产级的JWT过期刷新机制,包含了安全处理、性能和可用性的平衡。

抱歉,评论功能暂时关闭!