安全(P0) - 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、 ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签 (ywxapp.framework_package_verify,默认开启) - JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret() 校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET - /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验 - 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流 - TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive - 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php) - 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis - paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG 工程化 / 交付 - 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本 - 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过 - 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障 - .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess 结构 - appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
337 lines
14 KiB
PHP
337 lines
14 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();
|
||
}
|
||
|
||
/**
|
||
* HS256 密钥强度校验(线上签发前的最后一道闸)
|
||
*
|
||
* 背景:config/jwt.php 曾给过一个公开的默认值 'your-default-secret-key-change-in-production',
|
||
* 而 .env.production.example 里的键名又写成了 JWT_SECRET_KEY(代码读的是 JWT_SECRET),
|
||
* 结果「照着生产模板部署 = 用公开密钥签发」,任何人都能伪造 isAdmin 的管理员 token。
|
||
* 因此这里不仅要求非空,还要求足够长、且不得命中已知弱值。
|
||
*
|
||
* @param string $secret
|
||
* @return string 原样返回,便于直接作为 InMemory::plainText() 入参
|
||
* @throws Exception
|
||
*/
|
||
protected function assertStrongSecret(string $secret): string
|
||
{
|
||
if ($secret === '') {
|
||
throw new Exception('JWT 密钥未配置:请在 .env 中设置 JWT_SECRET(生成命令:openssl rand -base64 64),'
|
||
. '留空会导致所有登录/签发接口不可用(这是刻意的 fail-closed 设计)');
|
||
}
|
||
if (strlen($secret) < 32) {
|
||
throw new Exception('JWT 密钥强度不足(当前 ' . strlen($secret) . ' 字节,要求 ≥32):'
|
||
. '请重新生成 openssl rand -base64 64');
|
||
}
|
||
$weak = [
|
||
'your-default-secret-key-change-in-production',
|
||
'changeme',
|
||
'secret',
|
||
'123456',
|
||
'ywxapp',
|
||
];
|
||
if (in_array(strtolower($secret), $weak, true)) {
|
||
throw new Exception('JWT 密钥为公开的默认值,已被拒绝签发:请立即用 openssl rand -base64 64 重新生成');
|
||
}
|
||
return $secret;
|
||
}
|
||
|
||
/**
|
||
* 返回 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->assertStrongSecret((string) ($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)
|
||
];
|
||
}
|
||
}
|