302 lines
12 KiB
PHP
302 lines
12 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | YwxApp [ WE CAN DO IT JUST THINK ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2026-2036 http://ywxapp.cn All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Author: ywxapp<admin@ywxapp.cn>
|
||
// +----------------------------------------------------------------------
|
||
declare (strict_types = 1);
|
||
namespace ywxapp\service;
|
||
|
||
use DateInterval;
|
||
use DateTimeImmutable;
|
||
use DateTimeZone;
|
||
use Lcobucci\Clock\SystemClock;
|
||
use Lcobucci\JWT\Configuration;
|
||
use Lcobucci\JWT\Signer;
|
||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||
use Lcobucci\JWT\Signer\Key\LocalFileReference;
|
||
use Lcobucci\JWT\Signer\Rsa\Sha256 as RsaSha256;
|
||
use Lcobucci\JWT\Token;
|
||
use Lcobucci\JWT\Validation\Constraint\IssuedBy;
|
||
use Lcobucci\JWT\Validation\Constraint\LooseValidAt;
|
||
use Lcobucci\JWT\Validation\Constraint\PermittedFor;
|
||
use Lcobucci\JWT\Validation\Constraint\SignedWith;
|
||
use think\Exception;
|
||
use think\facade\Config;
|
||
|
||
date_default_timezone_set('Asia/Shanghai');
|
||
/**
|
||
* JwtService 类
|
||
*
|
||
* @author ywxapp <admin@ywxapp.cn>
|
||
*/
|
||
class JwtService
|
||
{
|
||
protected Configuration $config;
|
||
protected array $jwtConf;
|
||
|
||
|
||
public function __construct()
|
||
{
|
||
$this->jwtConf = [
|
||
// 签名算法:HS256 / RS256
|
||
'algorithm' => 'HS256',
|
||
// 对称密钥(HS256 使用)
|
||
// 生成强随机密钥示例: openssl rand -base64 64 或 bin2hex(random_bytes(32))
|
||
'secret' => env('JWT_SECRET', ''),
|
||
// 非对称密钥路径(RS256 使用)
|
||
'private_key_path' => root_path() . 'keys/private.key',
|
||
'public_key_path' => root_path() . 'keys/public.key',
|
||
// Token 配置
|
||
'access_ttl' => 1800, // Access Token 有效期(秒)→ 30分钟
|
||
'refresh_ttl' => 604800, // Refresh Token 有效期(秒)→ 7天
|
||
// JWT 标准声明
|
||
'issuer' => 'https://your-app.com',
|
||
'audience' => 'https://api.your-app.com',
|
||
];
|
||
$this->jwtConf = Config::get('jwt');
|
||
|
||
$this->initConfiguration();
|
||
}
|
||
|
||
/**
|
||
* 返回 JwtService 单例(经容器解析,便于替换/单测)。
|
||
* @param array $options 参数(预留,当前未使用)
|
||
* @return JwtService
|
||
*/
|
||
public static function instance($options = []): JwtService
|
||
{
|
||
return app()->jwt;
|
||
}
|
||
|
||
/**
|
||
* 初始化 JWT 配置(根据算法选择对称/非对称)
|
||
*/
|
||
protected function initConfiguration(): void
|
||
{
|
||
$algorithm = $this->jwtConf['algorithm'];
|
||
if ($algorithm === 'HS256') {
|
||
$signer = new Sha256();
|
||
$key = InMemory::plainText($this->jwtConf['secret']);
|
||
$this->config = Configuration::forSymmetricSigner($signer, $key);
|
||
} elseif ($algorithm === 'RS256') {
|
||
$signer = new RsaSha256();
|
||
$privateKey = file_exists($this->jwtConf['private_key_path']) ? LocalFileReference::file($this->jwtConf['private_key_path']) : null;
|
||
$publicKey = LocalFileReference::file($this->jwtConf['public_key_path']);
|
||
$this->config = Configuration::forAsymmetricSigner($signer, $privateKey, $publicKey);
|
||
} else {
|
||
throw new Exception("Unsupported JWT algorithm: {$algorithm}");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 创建 Access Token 和 Refresh Token
|
||
*
|
||
* @param array $claims 自定义声明,如 ['uid' => 123, 'role' => 'admin']
|
||
* @return array ['access_token' => '...', 'refresh_token' => '...', 'expires_in' => 1800]
|
||
*/
|
||
public function createToken(array $claims = [])
|
||
{
|
||
$accessToken = $this->createAccessToken($claims);
|
||
$refreshToken = $this->createRefreshToken($claims);
|
||
return [
|
||
'access_token' => $accessToken,
|
||
'refresh_token' => $refreshToken,
|
||
'expires_in' => $this->jwtConf['access_ttl']
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 创建 Access Token
|
||
*
|
||
* @param array $claims 自定义声明,如 ['uid' => 123, 'role' => 'admin']
|
||
* @return string JWT 字符串
|
||
*/
|
||
public function createAccessToken(array $claims = []): string
|
||
{
|
||
list($accessToken, $expiresAt) = $this->buildToken($claims, $this->jwtConf['access_ttl']);
|
||
app()->result->setAccessToken($accessToken, $expiresAt);
|
||
\think\facade\Session::set('access_token', $accessToken);
|
||
return $accessToken;
|
||
}
|
||
|
||
/**
|
||
* 创建 Refresh Token
|
||
*
|
||
* @param array $claims 必须包含唯一标识,如 ['uid' => 123]
|
||
* @return string JWT 字符串
|
||
*/
|
||
public function createRefreshToken(array $claims = []): string
|
||
{
|
||
list($refreshToken, $expiresAt) = $this->buildToken($claims, $this->jwtConf['refresh_ttl'], true);
|
||
app()->result->setRefreshToken($refreshToken, $expiresAt);
|
||
// 将 refresh_token 一并存入 Session,供 access_token 过期时后端自动续期(整页/iframe 场景)
|
||
\think\facade\Session::set('refresh_token', $refreshToken);
|
||
return $refreshToken;
|
||
}
|
||
|
||
/**
|
||
* 内部构建 Token 方法
|
||
*/
|
||
protected function buildToken(array $claims, int $ttl, bool $isRefresh = false)
|
||
{
|
||
$now = new DateTimeImmutable();
|
||
$expiresAt = $now->modify("+" . $ttl . " seconds");
|
||
$now = new DateTimeImmutable('now', new DateTimeZone('Asia/Shanghai'));
|
||
$builder = $this->config->builder()
|
||
// 👇 设置签发者(Issuer)—— 对应 JWT 的 `iss` (issuer) 声明
|
||
// 表示该 Token 是由哪个系统或服务签发的,用于验证来源是否可信
|
||
->issuedBy($this->jwtConf['issuer'])
|
||
|
||
// 👇 设置接收方(Audience)—— 对应 JWT 的 `aud` (audience) 声明
|
||
// 表示该 Token 是发给哪个客户端或服务使用的,防止 Token 被其他服务误用
|
||
->permittedFor($this->jwtConf['audience'])
|
||
|
||
// 👇 设置主题(Subject)—— 对应 JWT 的 `sub` (subject) 声明
|
||
// 通常表示 Token 所代表的主体,比如用户 ID、设备 ID 或组件名
|
||
// 这里表示该 Token 与 "component1" 这个组件相关
|
||
->relatedTo('component1')
|
||
|
||
// 👇 设置签发时间 —— 对应 JWT 的 `iat` (issued at) 声明
|
||
// 记录 Token 的创建时间(Unix 时间戳),用于审计或计算有效期
|
||
->issuedAt($now)
|
||
|
||
// 👇 设置生效时间 —— 对应 JWT 的 `nbf` (not before) 声明
|
||
// 表示 Token 在此时间之前**不能被使用**(即使未过期)
|
||
// 此处设置为 1 分钟后生效(注意:modify() 会修改原对象,实际使用建议用 clone 或新实例)
|
||
//->canOnlyBeUsedAfter($now->modify('+1 minute'))
|
||
|
||
// 👇 设置过期时间 —— 对应 JWT 的 `exp` (expiration time) 声明
|
||
// 表示 Token 在此时间之后**失效**,必须重新获取
|
||
// 注意:由于上一行 modify() 修改了 $now,此处实际是“1分钟后 + 1小时” = 1小时1分钟后过期
|
||
->expiresAt($expiresAt);
|
||
|
||
// 👇 添加自定义头部(Header)—— 非标准头部,一般不推荐随意添加
|
||
// JWT 头部通常包含 alg、typ 等,这里额外加了一个 "foo": "bar"
|
||
// 除非有特殊协议要求,否则应避免修改头部
|
||
// ->withHeader('foo', 'bar');
|
||
|
||
// 如果是 refresh token,添加 jti(用于未来黑名单)
|
||
if ($isRefresh) {
|
||
$jti = md5(uniqid((string) microtime(true), true));
|
||
// 👇 设置唯一标识符 —— 对应 JWT 的 `jti` (JWT ID) 声明
|
||
// 用于唯一标识该 Token,常用于防止重放攻击(replay attack)
|
||
// 例如:在用户登出后,可将 jti 加入黑名单使其立即失效
|
||
$builder = $builder->identifiedBy($jti);
|
||
}
|
||
|
||
foreach ($claims as $key => $value) {
|
||
// 👇 添加自定义私有声明(Private Claim)—— 不属于 JWT 标准,但可自由定义
|
||
// 这里添加了一个名为 "uid" 的字段,值为整数 1,通常用于存储用户 ID
|
||
// 解析时可通过 $token->claims()->get('uid') 获取
|
||
$builder = $builder->withClaim($key, $value);
|
||
}
|
||
|
||
// 👇 最终生成并签名 Token
|
||
// 使用指定的签名算法(如 HS256)和密钥对上述内容进行签名
|
||
// 返回一个不可变的 Token 对象
|
||
$token = $builder->getToken($this->config->signer(), $this->config->signingKey());
|
||
return [$token->toString(), (int) $expiresAt->format('U')];
|
||
}
|
||
|
||
/**
|
||
* 解析并验证 Token(用于 Access Token)
|
||
*
|
||
* @param string $tokenStr Bearer 后的字符串
|
||
* @return Token 验证通过的 Token 对象
|
||
* @throws Exception 验证失败
|
||
*/
|
||
public function parseAndValidate(string $tokenStr): Token
|
||
{
|
||
try {
|
||
$token = $this->config->parser()->parse($tokenStr);
|
||
$clock = new SystemClock(new DateTimeZone('Asia/Shanghai')); // 根据你项目时区调整
|
||
|
||
$timeDrift = new DateInterval('PT1S'); // 允许1秒偏差
|
||
$constraints = [
|
||
new SignedWith($this->config->signer(), $this->getVerificationKey()),
|
||
new IssuedBy($this->jwtConf['issuer']),
|
||
new PermittedFor($this->jwtConf['audience']),
|
||
new LooseValidAt($clock, $timeDrift),
|
||
];
|
||
$this->config->validator()->assert($token, ...$constraints);
|
||
|
||
return $token;
|
||
} catch (\Exception $e) {
|
||
throw new Exception('Invalid or expired token: ' . $e->getMessage(), 401);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 仅解析 Refresh Token(不验证 iss/aud,但验证签名和过期)
|
||
*
|
||
* @param string $refreshToken
|
||
* @return Token
|
||
* @throws Exception
|
||
*/
|
||
public function parseRefreshToken(string $refreshToken): Token
|
||
{
|
||
try {
|
||
$token = $this->config->parser()->parse($refreshToken);
|
||
|
||
// Refresh Token 只验证签名和时间
|
||
$clock = new SystemClock(new DateTimeZone('Asia/Shanghai'));
|
||
$constraints = [
|
||
new SignedWith($this->config->signer(), $this->getVerificationKey()),
|
||
new LooseValidAt($clock),
|
||
];
|
||
|
||
$this->config->validator()->assert($token, ...$constraints);
|
||
|
||
return $token;
|
||
} catch (\Exception $e) {
|
||
throw new Exception('Invalid refresh token', 401);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取用于验证的密钥(对称用 secret,非对称用 public key)
|
||
*/
|
||
protected function getVerificationKey()
|
||
{
|
||
if ($this->jwtConf['algorithm'] === 'HS256') {
|
||
return InMemory::plainText($this->jwtConf['secret']);
|
||
}
|
||
return LocalFileReference::file($this->jwtConf['public_key_path']);
|
||
}
|
||
|
||
/**
|
||
* 刷新 Access Token
|
||
*
|
||
* @param string $refreshToken
|
||
* @return array ['access_token' => '...', 'expires_in' => 1800]
|
||
* @throws Exception
|
||
*/
|
||
public function refreshAccessToken(string $refreshToken): array
|
||
{
|
||
// 1. 验证 refresh token
|
||
$token = $this->parseRefreshToken($refreshToken);
|
||
// 2. 提取用户标识(假设 claims 中有 'uid')
|
||
$claims = $token->claims()->all();
|
||
if (! isset($claims['uid'])) {
|
||
throw new Exception('Refresh token missing uid', 400);
|
||
}
|
||
// 3. 重建 access token:保留原 refresh token 中的全部自定义声明(uid / isAdmin / role / account 等)。
|
||
// ⚠️ 必须保留 isAdmin:否则刷新出的 access_token 缺少 isAdmin 声明,
|
||
// 后台 AdminAuth 的上下文校验(isAdmin=true)永远不匹配 → 后台/插件后台无法还原登录态(反复 401 / 跳登录页)。
|
||
// 标准声明(iss/aud/sub/iat/exp/nbf/jti)由 buildToken 重新生成,这里剔除避免重复写入冲突。
|
||
$standard = ['iss', 'aud', 'sub', 'iat', 'exp', 'nbf', 'jti'];
|
||
$newClaims = array_diff_key($claims, array_flip($standard));
|
||
$newClaims = array_filter($newClaims, fn($v) => $v !== null);
|
||
$newAccessToken = $this->createAccessToken($newClaims);
|
||
return [
|
||
'access_token' => $newAccessToken,
|
||
'expires_in' => $this->jwtConf['access_ttl'],
|
||
// 注意:通常不返回新的 refresh_token(除非实现 rotation)
|
||
];
|
||
}
|
||
}
|