Search by

harry / think-jwt

xichunjian

JSON Web Token (JWT) for ThinkPHP plugin

Package info

github.com/harryYKH/think-jwt

pkg:composer/harry/think-jwt

Statistics

Installs: 8

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

0.1.2 2026-09-24 00:33 UTC

This package is auto-updated.

Last update: 2026-09-24 00:49:21 UTC


README

Latest Stable Version Total Downloads License PHP Version Require

基于 PHP 8.4 重构的 ThinkPHP JWT 认证组件,采用现代 PHP 特性与领域驱动设计。

架构概览

src/
├── JWT.php                       # 门面入口(含验证结果缓存)
├── RedisHandler.php              # Redis 令牌管理(兼容层)
├── Enum/
│   └── TokenType.php             # 令牌类型枚举
├── DTO/
│   ├── JWTConfig.php             # 配置不可变对象(属性钩子)
│   └── TokenPayload.php          # 载荷数据对象(属性钩子)
├── Contract/
│   └── TokenStorageInterface.php # 存储接口(策略模式)
├── Storage/
│   └── RedisTokenStorage.php     # Redis 存储实现
├── Service/
│   ├── TokenFactory.php          # 令牌工厂
│   ├── TokenValidator.php        # 令牌验证器
│   └── RedisService.php          # Redis 连接复用与命令转发
├── Support/
│   ├── RequestContext.php        # 请求上下文(可脱离框架运行)
│   └── FrameworkConfig.php       # 宿主 config() 助手的兼容读取
├── Exception/                    # 异常体系
└── config.php                    # 默认配置

设计模式

模式 应用
门面模式 JWT 类提供统一静态入口
工厂模式 TokenFactory 负责创建令牌对
策略模式 TokenStorageInterface 解耦存储实现(Redis / 内存 / 数据库)
DTO 模式 JWTConfig / TokenPayload 只读数据对象
枚举 TokenType 替代类常量

安装

composer require harry/think-jwt

配置

发布配置文件到 config/jwt.php:

return [
    'algorithms' => 'HS256',
    // HS256 至少 32 字节 / HS384 48 字节 / HS512 64 字节(firebase/php-jwt 7.x 强制校验)
    'access_secret_key' => 'your-access-secret-at-least-32-bytes',
    'refresh_secret_key' => 'your-refresh-secret-at-least-32-bytes',
    'access_exp' => 7200,        // 2 小时
    'refresh_exp' => 604800,     // 7 天
    'access_is_force' => false,  // 是否允许已发放令牌提前失效
    'access_force_exp' => 7200,
    'refresh_disable' => false,
    'refresh_is_store' => false, // 刷新令牌落库,服务端可吊销
    'access_is_store' => true,   // 访问令牌落库,验证时与服务端记录比对(踢下线即时生效)
    'access_max_tokens' => 10,   // 单账号最多保留的访问令牌记录数
    'is_single_device' => false, // 单设备登录:只允许存在一个有效令牌
    'iss' => 'your-app.com',
    'nbf' => 0,
    'leeway' => 60,
    'cache_token_pre' => 'JWT:TOKEN:',
    'cache_token_ttl' => 604800,
    'user_model' => fn(int|string $uid) => User::find($uid)?->toArray(),
];

配置会在首次使用时被 JWTConfig::fromArray() 解析并校验,密钥缺失、算法不支持、密钥过短都会提前抛出 JWTConfigException,而不是等到签名时才报错。

使用

生成令牌

use harry\JWT;

$token = JWT::generateToken(['id' => 2026, 'name' => 'Harry', 'email' => 'harry@163.com']);

// 返回
// [
//     'token_type' => 'Bearer',
//     'expires_in' => 7200,
//     'access_token' => 'eyJ...',
//     'refresh_token' => 'eyJ...',
// ]

验证令牌

use harry\JWT;
use harry\Enum\TokenType;

$payload = JWT::verify(TokenType::Access);   // 自动从 Authorization 头解析

$payload->userId();        // 用户ID
$payload->claim('name');   // 自定义字段
$payload->remainingTtl;    // 剩余有效期(秒)
$payload->isExpired;       // 是否已过期

同一个令牌在同一次请求内只会解码一次,后续 getCurrentId() / getExtend() 等调用直接命中缓存。

缓存按请求自动隔离:$_SERVER['REQUEST_TIME_FLOAT'] 变化(即进入新请求)时自动清空, 因此在 FPM 与 Swoole 下都无需手动清理,也不会出现「服务端已吊销、进程仍返回旧结果」的问题。 缓存条目上限 256 条并按 FIFO 淘汰。仅在 CLI / 队列等无请求边界的长驻循环中才需要调用 JWT::clearCache()。

刷新令牌

刷新令牌可以显式传入,不再只能从请求头获取;不传(或传空字符串)时仍从 Authorization 头解析。 入参允许原始令牌,也允许带 Bearer 前缀。

// 方式一:从请求头解析(默认)
$newToken = JWT::refreshToken();

// 方式二:显式传入刷新令牌(适合令牌放在请求体 / 自定义头的接口)
$newToken = JWT::refreshToken($request->post('refresh_token'));

// 方式三:带 Bearer 前缀同样可以
$newToken = JWT::refreshToken('Bearer eyJ...');

// ['token_type' => 'Bearer', 'expires_in' => 7200, 'access_token' => 'eyJ...']

踢人下线(强制下线)

// 清理该账号的全部访问令牌 + 设备令牌 + 刷新令牌,并写入下线标记
JWT::kickOffline(2026);

// 只踢掉当前登录态,保留刷新令牌(用户可用刷新令牌静默续期)
JWT::kickOffline(2026, includeRefresh: false);

// 查看某账号当前有几个有效令牌(多设备模式下可 > 1)
JWT::countOnlineTokens(2026);

被踢的账号在下一次请求会收到精确提示:账号(2026)已于 2026-09-24 00:30:12 被强制下线,请重新登录。 重新登录后下线标记自动清除。

获取当前用户信息

$uid    = JWT::getCurrentId();
$role   = JWT::getCurrentRoleCode();
$extend = JWT::getExtend();
$email  = JWT::getExtendVal('email');
$ttl    = JWT::getTokenExp();
$user   = JWT::getUser();  // 通过 user_model 回调获取

删除刷新令牌(踢下线)

JWT::deleteRefreshToken(2026);

API 速查

方法 说明
JWT::generateToken(array $claims) 生成令牌对(含唯一 jti)
JWT::verify(?TokenType $type, ?string $token) 验证令牌,返回 TokenPayload(带缓存,入参可为 Bearer xxx)
JWT::refreshToken(?string $token) 刷新 Access 令牌(可传入令牌,缺省取请求头)
JWT::kickOffline(int|string $userId, bool $includeRefresh) 强制下线指定账号
JWT::countOnlineTokens(int|string $userId) 该账号当前有效令牌数量
JWT::getCurrentId() 获取当前用户ID
JWT::getCurrentRoleCode() 获取角色code
JWT::getCurrentRoleId() 获取角色ID
JWT::getUser() 获取用户信息(通过模型回调)
JWT::getExtend() 获取所有扩展字段
JWT::getExtendVal(string $key, mixed $default) 获取指定扩展字段
JWT::getTokenExp(?TokenType $type) 获取剩余有效期(秒)
JWT::deleteRefreshToken(int|string $userId) 删除刷新令牌
JWT::configure(JWTConfig $config) 运行时注入配置
JWT::setStorage(TokenStorageInterface $storage) 替换存储实现
JWT::clearCache() / JWT::flush() 清理缓存 / 重置全部静态状态

刷新令牌存储(refresh_is_store)

开启后刷新令牌会同时写入存储(默认 Redis),服务端获得吊销能力:

'refresh_is_store' => true,
  • 登录:刷新令牌写入 JWT:REFRESH:{userId},TTL 与 refresh_exp 一致
  • 刷新:与存储的令牌做 hash_equals 时序安全比对,不一致即拒绝
  • 踢下线:调用 JWT::deleteRefreshToken($userId) 即可让该用户的刷新令牌立即失效

该开关依赖 Redis 连接。组件已修复“每次命令新建连接 + 空密码也发送 AUTH”导致的 ERR Client sent AUTH, but no password is set 报错,现在连接按进程复用且仅在 配置了密码时才认证。

访问令牌服务端比对(access_is_store)

'access_is_store' => true,   // 默认开启

与单设备登录无关:只要开启,任何访问令牌在验证时都会与服务端记录比对,不在记录中的一律拒绝。

  • 登录 / 刷新时,令牌的 xxh128 指纹写入 {cache_token_pre}{userId}(Redis ZSET,score 为登记时间),不保存令牌原文
  • 验证时按指纹比对,未通过即抛出 JWTTokenRevokedException
  • 关闭该开关则不写入、也不比对(纯无状态 JWT,踢下线能力随之失效)

记录数有上限:access_max_tokens(默认 10)限制单账号并存的记录数, 同时按登记时间剔除超过有效期的成员——否则每次刷新都会新增记录并续期 TTL, 长期活跃用户的记录会无限增长。超出上限时淘汰最旧的记录,最新签发的令牌始终保留。

'access_max_tokens' => 10,   // 单账号最多保留 10 条访问令牌记录

Redis 键设计:

键 类型 说明
{cache_token_pre}{userId} ZSET 该账号有效访问令牌的 xxh128 指纹集合,score 为登记时间
JWT:REFRESH:{userId} STRING 刷新令牌原文(仅在 refresh_is_store 开启时)
JWT:DEVICE:{userId}:{ip} STRING 单设备登录的设备记录
JWT:OFFLINE:{userId} STRING 强制下线标记,值为标记时间

多设备并存 / 单设备独占

模式 存储策略 失效提示
is_single_device = false 同一账号可并存多个令牌(多端同时在线) 令牌与服务端记录不一致(该账号现有 N 个有效令牌),请重新登录
is_single_device = true 登录时清空旧令牌集合,只保留最新一个;同时记录设备 IP 已在其他设备登录,当前令牌已被挤下线,请重新登录

服务端没有任何记录时,提示 当前没有有效令牌记录,可能已登出、已被强制下线或服务端记录已过期。 被踢下线的账号会单独提示 已于 YYYY-MM-DD HH:MM:SS 被强制下线。

每次签发都会生成唯一的 jti(JWT ID)。否则同一秒内、声明相同的两次登录会签出 逐字节一致的令牌,服务端就无法区分多端登录。

高级用法

自定义配置注入

use harry\DTO\JWTConfig;
use harry\JWT;

JWT::configure(new JWTConfig(
    algorithms: 'RS256',
    accessPrivateKey: file_get_contents('/path/to/private.pem'),
    accessPublicKey: file_get_contents('/path/to/public.pem'),
    refreshPrivateKey: file_get_contents('/path/to/refresh-private.pem'),
    refreshPublicKey: file_get_contents('/path/to/refresh-public.pem'),
    accessTtl: 3600,
));

自定义存储实现

实现 TokenStorageInterface 后注入即可(无需 Redis):

use harry\Contract\TokenStorageInterface;
use harry\JWT;

final class DatabaseTokenStorage implements TokenStorageInterface
{
    #[Override]
    public function store(string $key, string $token, int $ttl): bool { /* ... */ }

    #[Override]
    public function retrieve(string $key): ?string { /* ... */ }

    #[Override]
    public function delete(string $key): bool { /* ... */ }

    #[Override]
    public function exists(string $key): bool { /* ... */ }

    #[Override]
    public function storeRefreshToken(string $userId, string $token, int $ttl): bool { /* ... */ }

    #[Override]
    public function retrieveRefreshToken(string $userId): ?string { /* ... */ }

    #[Override]
    public function deleteRefreshToken(string $userId): bool { /* ... */ }

    #[Override]
    public function storeDeviceToken(string $prefix, string $userId, string $ip, string $extend, int $ttl): bool { /* ... */ }

    #[Override]
    public function verifyDeviceToken(string $prefix, string $userId, string $ip): bool { /* ... */ }

    #[Override]
    public function deleteDeviceTokens(string $prefix, string $userId): int { /* ... */ }

    // 访问令牌仓库:只保存令牌指纹,不要保存原文
    #[Override]
    public function storeAccessToken(string $userId, string $token, int $ttl, bool $exclusive = false): bool { /* ... */ }

    #[Override]
    public function hasAccessToken(string $userId, string $token): bool { /* ... */ }

    #[Override]
    public function countAccessTokens(string $userId): int { /* ... */ }

    #[Override]
    public function deleteAccessTokens(string $userId): int { /* ... */ }

    // 强制下线标记
    #[Override]
    public function markOffline(string $userId, int $ttl): bool { /* ... */ }

    #[Override]
    public function offlineMarkedAt(string $userId): ?int { /* ... */ }

    #[Override]
    public function clearOfflineMark(string $userId): bool { /* ... */ }
}

JWT::setStorage(new DatabaseTokenStorage());

直接使用服务层

use harry\DTO\JWTConfig;
use harry\Enum\TokenType;
use harry\Service\TokenFactory;
use harry\Service\TokenValidator;
use harry\Storage\RedisTokenStorage;

$config  = JWTConfig::fromArray(config('jwt'));
$storage = new RedisTokenStorage();

$tokens = (new TokenFactory($config, $storage))->pair(['id' => 2026, 'role' => 'admin']);
$payload = (new TokenValidator($config, $storage))->verify($tokens['access_token'], TokenType::Access);

自定义 Redis 连接参数

use harry\Service\RedisService;

RedisService::configure([
    'host' => '10.0.0.9',
    'port' => 6380,
    'password' => null,   // 留空即不发送 AUTH
    'database' => 1,
    'timeout' => 2.0,
    'persistent' => true,
]);

本次修复的缺陷

问题 影响 修复
Redis 每条命令都 new Redis + connect + 无条件 auth('') 开启 refresh_is_store 后报 ERR Client sent AUTH, but no password is set,且连接泄漏 连接懒加载复用,仅在配置密码时 AUTH
RedisTokenStorage::exists() 声明 bool 却返回 phpredis 的 int strict_types 下 TypeError RedisService::has() 统一布尔语义
JWTConfig::fromArray() 未做类型转换 配置为字符串时(access_exp => '7200')触发 TypeError 宽松转换 + 启动校验,配置错误提前暴露为 JWTConfigException
命名空间 harry\Exception / harry\Service 与目录 exception / service 大小写不一致 Linux 生产环境 Class not found 目录重命名并与命名空间对齐
单设备登录只校验、从不写入设备令牌 开启后所有请求都被判定为「已在其他设备登录」 登录时写入设备令牌
access_is_force 判定逻辑写反(比较 exp 而非当前时间) 缩短 access_force_exp 后新签发的令牌也被立即判废 改为 iat + force_exp <= now 判定
默认密钥仅 12/16 字节 firebase/php-jwt 7.x 起抛 Provided key is too short 默认密钥加长,并在配置校验中给出明确提示
直接调用宿主 config() 且未做兼容 宿主若定义签名不同的 config()(如要求数组参数),鉴权链路直接 TypeError 崩溃 新增 Support\FrameworkConfig,异常/类型不符一律回退默认值
#[Override] 未 use Override; 命名空间下解析为 harry\Storage\Override,属性静默失效,编译期检查不生效 补全 use Override;
验证缓存不区分请求 长驻进程(Swoole/Workerman)中一直返回首次结果,服务端吊销与单设备踢下线被绕过 缓存按请求标识自动隔离

PHP 8.4 特性应用

特性 位置
属性钩子(Property Hooks) TokenPayload::$remainingTtl / $isExpired / $isValid、JWTConfig::$isAsymmetric
array_any() JWTConfig::validate() 算法白名单校验
类型化类常量 RedisService、RedisTokenStorage、JWTConfig
#[Override] 存储实现对接口方法的显式标注
枚举 + 只读提升属性 TokenType、JWTConfig / TokenPayload
never / Union 捕获 / match 异常体系与密钥选择
hash('xxh128') 验证结果缓存键

性能要点

  1. Redis 连接复用:连接懒加载并在进程内复用(此前每条命令都新建连接 + AUTH)。实测 200 次读取:7.1 ms vs 2335 ms
  2. SCAN 替代 KEYS:设备令牌清理不再阻塞 Redis 单线程
  3. 验证结果缓存:同一令牌仅解码一次,避免重复 HMAC 计算。实测 500 次校验:0.3 ms vs 3.5 ms
  4. 消除 JSON 往返:解码结果直接 (array) 转换,不再 json_encode/json_decode,载荷构造开销降低约 78%
  5. 按需构造:配置、工厂、验证器、存储均懒加载

端到端检测

仓库内置检测脚本,可在本机 PHP + Redis 环境一键复跑全部用例(74 项):

php scripts/verify.php --host=127.0.0.1 --port=6379 --log=docs/verify-log.txt

覆盖环境、语法、配置校验、Redis 连接层、refresh_is_store、单设备登录、令牌生命周期、 缓存安全边界、性能对比、PHP 8.4 特性与宿主兼容性回归;失败时以非 0 退出码结束。

签名算法

算法 类型 说明
HS256 / HS384 / HS512 对称 同一密钥签名验证(密钥长度 ≥ 32/48/64 字节)
RS256 / RS384 / RS512 非对称 RSA 私钥签名,公钥验证(密钥 ≥ 2048 bit)
ES256 / ES384 / ES512 非对称 ECDSA 签名
Ed25519 非对称 EdDSA 签名(需 sodium 扩展,公钥可省略)

生成 RSA 密钥对

ssh-keygen -t rsa -b 4096 -E SHA256 -m PEM -P "" -f rs256.key
openssl rsa -in rs256.key -pubout -outform PEM -out rs256.key.pub

异常体系

RuntimeException
├── JWTTokenException                      # 通用令牌异常
│   ├── JWTTokenExpiredException           # 令牌已过期
│   ├── JWTTokenRevokedException           # 令牌已被注销(踢下线/已登出/服务端无记录)
│   ├── JWTCacheTokenException             # 单设备登录冲突(已被挤下线)
│   └── JWTRefreshTokenExpiredException    # 刷新令牌过期
│       └── JWTStoreRefreshTokenExpiredException  # 存储的刷新令牌失效
└── JWTConfigException                     # 配置异常

失效原因按具体程度分级返回,便于直接回传给前端:

场景 异常 提示
被踢下线 JWTTokenRevokedException 账号(2026)已于 2026-09-24 00:30:12 被强制下线,请重新登录
单设备被挤下线 JWTCacheTokenException 账号(2026)已在其他设备登录,当前令牌已被挤下线,请重新登录
服务端无记录 JWTTokenRevokedException 账号(2026)当前没有有效令牌记录,可能已登出、已被强制下线或服务端记录已过期,请重新登录
记录不一致 JWTTokenRevokedException 账号(2026)的令牌与服务端记录不一致(该账号现有 3 个有效令牌),请重新登录
刷新令牌被注销 JWTStoreRefreshTokenExpiredException 账号(2026)的刷新令牌不存在或已被注销,可能已登出、已被强制下线或服务端记录已过期,请重新登录

安全性建议

  1. 密钥管理:生产环境使用 RS256 非对称算法,私钥妥善保管
  2. HTTPS 传输:始终通过 HTTPS 传输令牌
  3. 令牌过期:设置合理的过期时间,Access 令牌建议 ≤ 2 小时
  4. 刷新令牌存储:开启 refresh_is_store 实现令牌吊销
  5. 单设备登录:开启 is_single_device 防止多设备同时在线
  6. 时序安全:使用 hash_equals 比较令牌,防止计时攻击

要求

  • PHP >= 8.4
  • topthink/framework >= 8.1.4
  • firebase/php-jwt >= 7.1
  • ext-redis(可选,用于 Redis 存储)

测试

composer update
vendor/bin/phpunit

License

MIT