PHP Token无感刷新

wen PHP项目 3

PHP Token无感刷新:从原理到实战,打造永不掉线的用户会话

目录导读

  1. 为什么需要Token无感刷新?
  2. Token刷新的核心机制与JWT基础
  3. 双Token模型:Access Token + Refresh Token
  4. 无感刷新的完整时序流程
  5. PHP实现代码精讲(含Redis存储)
  6. 并发请求下的Token竞态处理
  7. 安全加固:黑名单、旋转与指纹校验
  8. 常见问题与性能优化
  9. 总结与最佳实践

为什么需要Token无感刷新?

在现代Web应用中,JWT(JSON Web Token)已成为主流的身份认证方式,但JWT的无状态性带来了一个痛点:Token过期后,用户必须重新登录,如果Access Token有效期设得过短(如15分钟),用户体验会支离破碎;设得过长(如24小时),又面临安全风险——Token一旦泄露,攻击者长时间可用。

PHP Token无感刷新

无感刷新(Silent Refresh)正是为解决这一矛盾而生的技术方案:用户无需感知,系统在后台自动完成Token更换,既保证会话连续性,又维持短时Token的安全性,根据OAuth 2.0与OpenID Connect规范,这是目前业界公认的最佳实践。


Token刷新的核心机制与JWT基础

1 什么是Access Token与Refresh Token?

  • Access Token(访问令牌):有效期短(如15分钟~2小时),用于访问受保护资源,每次API请求必须携带。
  • Refresh Token(刷新令牌):有效期长(如7天~30天),仅用于换取新的Access Token,不参与业务请求。

2 JWT结构回顾

一个标准JWT由三部分组成(用分隔):

header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUxNjI0MjYyMn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
  • payload中的exp(过期时间)、iat(签发时间)是刷新的关键字段。

双Token模型:Access Token + Refresh Token

核心思想:用Refresh Token(离线验证)换取新的Access Token(在线验证),Refresh Token只在换取新Token时通过网络传输,且存储在更安全的位置(如HttpOnly Cookie中),从而降低泄露风险。

1 为什么不用单个长时Token?

方案 安全性 用户体验
单个长时Token 泄露后风险窗口大 好(无需刷新)
单个短时Token 差(频繁登录)
双Token 高(短时泄露风险小) 极好(无感)

无感刷新的完整时序流程

客户端                        PHP后端 (API)
   |                              |
   |  1. 请求API (携带AccessToken) |
   |----------------------------->|
   |                              | 2. 验证Token是否有效
   |                              |   - 有效 -> 返回业务数据
   |  <----------------------------- 返回数据
   |                              |   - 无效(过期) -> 返回401
   |  <----------------------------- 401 Unauthorized
   |                              |
   |  3. 收到401,触发刷新逻辑     |
   |  4. 请求 /refresh (携带RefreshToken) |
   |----------------------------->|
   |                              | 5. 验证RefreshToken
   |                              |   - 通过 -> 生成新Token对
   |  <----------------------------- 新AccessToken + 新RefreshToken
   |  6. 用新AccessToken重新请求原API |
   |----------------------------->|
   |                              | 7. 正常返回
   |  <----------------------------- 业务数据

关键点:客户端(前端或APP)必须拦截401响应,自动执行刷新并重放原请求,整个过程对用户完全透明。


PHP实现代码精讲(含Redis存储)

1 生成Token对(登录成功时)

<?php
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Predis\Client;
class TokenService {
    private $redis;
    private $accessSecret = 'your_access_secret_123456'; // 建议用环境变量
    private $refreshSecret = 'your_refresh_secret_654321';
    private $accessExpire = 900; // 15分钟
    private $refreshExpire = 604800; // 7天
    public function __construct() {
        $this->redis = new Client(['scheme' => 'tcp', 'host' => '127.0.0.1', 'port' => 6379]);
    }
    public function generateTokenPair(int $userId): array {
        $now = time();
        // Access Token
        $accessPayload = [
            'sub' => $userId,
            'iat' => $now,
            'exp' => $now + $this->accessExpire,
            'type' => 'access'
        ];
        $accessToken = JWT::encode($accessPayload, $this->accessSecret, 'HS256');
        // Refresh Token (带唯一ID,用于旋转)
        $refreshId = bin2hex(random_bytes(32));
        $refreshPayload = [
            'sub' => $userId,
            'iat' => $now,
            'exp' => $now + $this->refreshExpire,
            'type' => 'refresh',
            'jti' => $refreshId // 唯一标识
        ];
        $refreshToken = JWT::encode($refreshPayload, $this->refreshSecret, 'HS256');
        // 存储RefreshToken的hash到Redis,用于撤销和旋转验证
        $this->redis->hmset("refresh:{$userId}:{$refreshId}", [
            'valid' => 1,
            'created_at' => $now
        ]);
        $this->redis->expire("refresh:{$userId}:{$refreshId}", $this->refreshExpire);
        return ['access_token' => $accessToken, 'refresh_token' => $refreshToken];
    }
}

2 刷新端点(核心无感逻辑)

public function refresh(string $refreshToken): array {
    try {
        // 1. 验证签名和过期
        $decoded = JWT::decode($refreshToken, new Key($this->refreshSecret, 'HS256'));
    } catch (\Exception $e) {
        http_response_code(401);
        return ['error' => 'Invalid refresh token'];
    }
    // 2. 检查是否在黑名单(已被使用/撤销)
    $userId = $decoded->sub;
    $jti = $decoded->jti;
    $redisKey = "refresh:{$userId}:{$jti}";
    if (!$this->redis->exists($redisKey) || $this->redis->hget($redisKey, 'valid') != 1) {
        http_response_code(401);
        return ['error' => 'Refresh token has been revoked'];
    }
    // 3. 旋转机制:先撤销旧Token,再生成新Token对
    $this->redis->hset($redisKey, 'valid', 0); // 标记旧Token无效
    // 4. 生成新Token对,并返回
    return $this->generateTokenPair($userId);
}

3 API请求验证中间件(判断是否需刷新)

public function authenticateRequest(string $accessToken): ?array {
    try {
        $decoded = JWT::decode($accessToken, new Key($this->accessSecret, 'HS256'));
        // 还可以检查是否存在于黑名单(如用户强制注销)
        return ['user_id' => $decoded->sub];
    } catch (\Exception $e) {
        return null; // Token无效或过期
    }
}

并发请求下的Token竞态处理

场景:用户同时发起3个API请求,都携带同一个即将过期的Access Token,当它们均收到401后,会同时发3个刷新请求,处理不当会导致Token旋转后,旧的Refresh Token被重复使用,产生竞态。

解决方案:

  1. 客户端串行化:前端维护一个refreshPromise,如果正在刷新中,其他请求等待该Promise完成,然后复用新Access Token。
  2. 服务端单次使用(已实现):上面代码中,刷新时立即将旧refresh:{userId}:{jti}标记为valid=0,即使并发请求到达,只有一个能成功,其余返回401,客户端需重新登录。

进阶:使用Redis分布式锁

// 在刷新前加锁,保证同一UserId只有一个刷新请求执行
$lockKey = "refresh_lock:{$userId}";
$lock = $this->redis->set($lockKey, 1, 'EX', 5, 'NX');
if (!$lock) {
    http_response_code(429); // Too Many Requests
    return ['error' => 'Concurrent refresh detected'];
}
// 处理完释放锁
$this->redis->del($lockKey);

安全加固:黑名单、旋转与指纹校验

1 Refresh Token旋转(已实现)

每次刷新都生成全新Token,旧Token立即失效。这能防止重放攻击,因为每次刷新后的旧Token不可再用。

2 用户设备指纹

在生成Refresh Token时,结合用户客户端信息(如User-Agent、IP、设备ID)生成一个不可逆指纹(例如HMAC),存到Redis中,每次刷新时比对指纹,若不一致,立即撤销该用户所有Token并告警。

$fingerprint = hash_hmac('sha256', $userAgent . '|' . $ip, $this->accessSecret);
// 存储时记录,刷新时比对

3 短期Access Token黑名单

当用户主动注销时,将当前Access Token(即使未过期)加入Redis黑名单,设置TTL为Token剩余有效期,实现即时失效。


常见问题与性能优化

Q1:为什么Refresh Token要使用HttpOnly Cookie存储,而不放在localStorage?

:HttpOnly Cookie可防止XSS攻击读取Token,从而避免被窃取,localStorage易被恶意脚本访问,推荐将Refresh Token放在HttpOnly+Secure+SameSite=Strict的Cookie中。

Q2:无感刷新会不会让用户“无限期在线”?

:不会,Refresh Token有有效期(如7天),到期后必须重新认证,且每次刷新会检查用户状态(如是否被禁用)。

Q3:如何判断Access Token是“过期”还是“无效”?

:过期会抛出ExpiredException,无效则抛SignatureInvalidException,前端收到401后,可先判断响应头或错误码,若是过期则触发刷新,若无效则直接跳转登录页。

性能优化技巧:

  • Redis持久化:需开启AOF持久化,防止重启丢失Refresh Token数据。
  • 减少Redis请求:将valid字段与Token自身有效性合并,例如在Redis中只存refresh:{jti}键,存在即有效。
  • JWT签名算法选型:HS256比RS256快,但无法跨服务验证,微服务场景建议用RS256(公钥验证)。

总结与最佳实践

核心要点

  • 双Token模型是安全与体验的最优平衡。
  • Access Token短命(15分钟~2小时),Refresh Token长命(7天~30天)。
  • 每次刷新必须旋转Refresh Token,并记录到存储中以便撤销。
  • 客户端必须处理并发刷新,服务端必须处理重放攻击。

架构建议

  1. 存储:生产环境务必用Redis Cluster或带持久化的Redis,不要存内存。
  2. 密钥管理:使用环境变量或密钥管理服务(如Vault),切勿硬编码。
  3. 日志与监控:记录刷新失败次数,异常IP,实施风控。
  4. 降级策略:若Redis不可用,可降级为数据库存储Refresh Token,同时考虑限流。

无感刷新不是复杂炫技,而是工程上对安全与体验的周全考量,掌握上述原理与代码实现,你将能够构建出会话不中断、用户零打扰的现代PHP应用。

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