Files

302 lines
12 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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
];
}
}