PHP Token无感刷新:从原理到实战,打造永不掉线的用户会话
目录导读
- 为什么需要Token无感刷新?
- Token刷新的核心机制与JWT基础
- 双Token模型:Access Token + Refresh Token
- 无感刷新的完整时序流程
- PHP实现代码精讲(含Redis存储)
- 并发请求下的Token竞态处理
- 安全加固:黑名单、旋转与指纹校验
- 常见问题与性能优化
- 总结与最佳实践
为什么需要Token无感刷新?
在现代Web应用中,JWT(JSON Web Token)已成为主流的身份认证方式,但JWT的无状态性带来了一个痛点:Token过期后,用户必须重新登录,如果Access Token有效期设得过短(如15分钟),用户体验会支离破碎;设得过长(如24小时),又面临安全风险——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被重复使用,产生竞态。
解决方案:
- 客户端串行化:前端维护一个
refreshPromise,如果正在刷新中,其他请求等待该Promise完成,然后复用新Access Token。 - 服务端单次使用(已实现):上面代码中,刷新时立即将旧
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,并记录到存储中以便撤销。
- 客户端必须处理并发刷新,服务端必须处理重放攻击。
架构建议:
- 存储:生产环境务必用Redis Cluster或带持久化的Redis,不要存内存。
- 密钥管理:使用环境变量或密钥管理服务(如Vault),切勿硬编码。
- 日志与监控:记录刷新失败次数,异常IP,实施风控。
- 降级策略:若Redis不可用,可降级为数据库存储Refresh Token,同时考虑限流。
无感刷新不是复杂炫技,而是工程上对安全与体验的周全考量,掌握上述原理与代码实现,你将能够构建出会话不中断、用户零打扰的现代PHP应用。