在分布式系统中,令牌续期不是“把过期时间重置”这么简单,而是需要在安全性、用户体验与系统性能之间取得平衡。常见痛点包括:
续期策略设计需优先回答三个问题:
|
方案 |
安全性 |
用户体验 |
实现复杂度 |
适用场景 |
性能影响 |
|---|---|---|---|---|---|
|
单Token基础版 |
★☆☆☆☆ |
★★☆☆☆ |
★☆☆☆☆ |
内部测试系统 |
低 |
|
单Token+黑名单 |
★★☆☆☆ |
★★★☆☆ |
★★☆☆☆ |
低风险Web应用 |
中 |
|
双Token基础版 |
★★★☆☆ |
★★★★☆ |
★★★☆☆ |
常规Web/APP |
中 |
|
双Token+三验证 |
★★★★★ |
★★★☆☆ |
★★★★☆ |
金融/支付系统 |
高 |
|
自动续期方案 |
★★★★☆ |
★★★★★ |
★★★★☆ |
高用户体验要求系统 |
中高 |
|
分布式环境增强 |
★★★★☆ |
★★★★☆ |
★★★★☆ |
多设备、跨服务系统 |
中 |
思路:仅使用 Access Token,不进行任何额外的续期或黑名单处理。当 Token 过期后,用户需重新登录获取新的 Token。
实现:直接签发一个有固定过期时间的 Access Token,在验证 Token 时,仅检查其是否在有效期内。
适用场景:内部低安全要求的测试系统、短期活动页面、快速原型开发等对安全性和用户体验要求不高的场景。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
<?php class TokenService { private const ACCESS_TTL = 1800; // 30 分钟 // 生成 Access Token public function generateToken(string $username): string { return JwtUtil::encode([ 'sub' => $username, 'iat' => time(), 'exp' => time() + self::ACCESS_TTL, ]); } // 验证 Token 是否有效 public function validateToken(string $token): bool { try { $expiration = JwtUtil::getExpiration($token); return $expiration > time(); } catch (Exception $e) { // 若解析 Token 失败,认为 Token 无效 return false; } } } |
思路:仅使用Access Token,续期时签发新令牌,并将旧令牌加入黑名单,黑名单 TTL 略大于令牌 TTL,避免并发刷新导致旧令牌仍可用。
适用:内部低风险系统、短期活动页、快速原型。
要点:

|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
<?php
class TokenService { private const ACCESS_TTL = 1800; // 30分钟 private const BLACKLIST_TTL = 2100; // 35分钟
public function refresh(string $oldToken): string { if ($this->isBlacklisted($oldToken)) { throw new RuntimeException('Token revoked', 401); }
$payload = JwtUtil::decode($oldToken); $sub = $payload['sub'] ?? null; if (!$sub) { throw new RuntimeException('Invalid token', 401); }
// 先拉黑旧令牌,再签发新令牌(减少并发窗口) $this->addBlacklist($oldToken, self::BLACKLIST_TTL);
return JwtUtil::encode([ 'sub' => $sub, 'iat' => time(), 'exp' => time() + self::ACCESS_TTL, ]); }
private function isBlacklisted(string $token): bool { return (bool) Redis::get("blacklist:{$token}"); }
private function addBlacklist(string $token, int $ttl): void { Redis::setex("blacklist:{$token}", $ttl, '1'); } } |
思路:登录签发Access Token(短期)与Refresh Token(长期);Access 过期后,用 Refresh 换取新 Access。Refresh 存于服务端(如 Redis),便于撤销与管控。
适用:常规 Web/APP,安全性与体验均衡。
要点:

|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
<?php class TokenService { private const ACCESS_TTL = 900; // 15分钟 private const REFRESH_TTL = 604800; // 7天
public function login(string $userId): array { $accessToken = JwtUtil::encode([ 'sub' => $userId, 'iat' => time(), 'exp' => time() + self::ACCESS_TTL, 'type' => 'access', ]);
$refreshToken = bin2hex(random_bytes(32)); Redis::setex("refresh:{$refreshToken}", self::REFRESH_TTL, $userId);
return compact('accessToken', 'refreshToken'); }
public function refresh(string $refreshToken): string { $userId = Redis::get("refresh:{$refreshToken}"); if (!$userId) { throw new RuntimeException('Invalid or expired refresh token', 401); }
// 方案A:撤销式刷新(推荐) Redis::del("refresh:{$refreshToken}");
// 方案B:滑动续期(可选) // Redis::expire("refresh:{$refreshToken}", self::REFRESH_TTL);
return JwtUtil::encode([ 'sub' => $userId, 'iat' => time(), 'exp' => time() + self::ACCESS_TTL, 'type' => 'access', ]); }
public function logout(string $refreshToken): void { Redis::del("refresh:{$refreshToken}"); } } |
思路:在基础版上增加:
要点:

|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
<?php class TokenService { private const ACCESS_TTL = 900; private const REFRESH_TTL = 604800; private const STATE_TTL = 300; // 5分钟
public function refreshWithState(string $refreshToken, string $clientState, string $deviceId): array { $lockKey = "lock:refresh:{$refreshToken}"; $stateKey = "state:{$clientState}";
// 分布式锁(SETNX + EX 简化示例) $acquired = Redis::set($lockKey, 1, ['NX', 'EX' => 5]); if (!$acquired) { throw new RuntimeException('Refresh in progress, try later', 429); }
try { // 1) 一次性 StateToken 校验 $stored = Redis::get($stateKey); if (!$stored || $stored !== $deviceId) { throw new RuntimeException('Invalid state or device mismatch', 401); } Redis::del($stateKey);
// 2) Refresh 令牌校验 $userId = Redis::get("refresh:{$refreshToken}"); if (!$userId) { throw new RuntimeException('Invalid or expired refresh token', 401); }
// 3) 撤销式:删除旧 RefreshToken Redis::del("refresh:{$refreshToken}");
// 4) 生成新令牌对 $newAccess = JwtUtil::encode([ 'sub' => $userId, 'iat' => time(), 'exp' => time() + self::ACCESS_TTL, 'type' => 'access', ]); $newRefresh = bin2hex(random_bytes(32)); Redis::setex("refresh:{$newRefresh}", self::REFRESH_TTL, $userId);
return compact('newAccess', 'newRefresh'); } finally { Redis::del($lockKey); } }
public function createRefreshState(string $deviceId): string { $state = bin2hex(random_bytes(16)); Redis::setex("state:{$state}", self::STATE_TTL, $deviceId); return $state; } } |
思路:在网关/中间件或业务拦截器中检测令牌剩余有效期,低于阈值时签发新令牌并通过响应头返回,客户端无感替换。
适用:微服务、前后端分离、高并发系统。
要点:

|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 |
<?php // 网关/中间件示例(伪代码,可按 Webman/Laravel/Swoole 适配) class TokenRenewMiddleware { private const RENEW_THRESHOLD = 300; // 5分钟
public function handle($request, $next) { $token = $this->extractToken($request); if (!$token) { return $next($request); }
try { $payload = JwtUtil::decode($token); } catch (Exception $e) { return $this->unauthorized('Invalid token'); }
$remaining = $payload['exp'] - time(); if ($remaining > self::RENEW_THRESHOLD) { return $next($request); }
// 签发新令牌 $newToken = JwtUtil::encode([ 'sub' => $payload['sub'], 'iat' => time(), 'exp' => time() + 900, // 15分钟 'type' => 'access', ]);
$response = $next($request); $response->withHeader('X-New-Token', $newToken); return $response; }
private function extractToken($request): ?string { $auth = $request->header('Authorization', ''); return str_starts_with($auth, 'Bearer ') ? substr($auth, 7) : null; }
private function unauthorized(string $msg) { return new Response(401, ['Content-Type' => 'application/json'], json_encode(['error' => $msg])); } } |
思路:以用户+设备维度维护最新令牌,登录时使旧令牌失效(黑名单或缓存淘汰);跨服务通过本地快速校验 + 认证中心兜底提升性能与一致性。
要点:

|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
<?php class SessionService { // 登录:单设备登录(踢旧) public function login(string $userId, string $deviceId): string { $token = JwtUtil::encode([ 'sub' => $userId, 'iat' => time(), 'exp' => time() + 900, 'type' => 'access', 'device' => $deviceId, ]);
$key = "session:{$userId}:{$deviceId}"; $oldToken = Redis::get($key); if ($oldToken) { // 使旧令牌失效(黑名单或缓存失效) Redis::setex("blacklist:{$oldToken}", 2100, '1'); } Redis::setex($key, 900, $token);
return $token; }
// 跨服务验证:本地快速校验 + 认证中心兜底 public function validateAcrossServices(string $token): bool { try { $payload = JwtUtil::decode($token); $key = "session:{$payload['sub']}:{$payload['device']}"; $current = Redis::get($key); return $current && hash_equals($current, $token); } catch (Exception $e) { // 本地失败,调用认证中心兜底 return AuthCenterClient::validate($token); } } } |
要点: