chore: 重写初始提交(清空历史,整理后全量提交)
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
<?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)
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user