331 lines
9.9 KiB
PHP
331 lines
9.9 KiB
PHP
<?php
|
|||
|
|
|
||
|
|
declare(strict_types=1);
|
||
|
|
|
||
|
|
namespace core\tool;
|
||
|
|
|
||
|
|
use app\service\admin\system\ConfigService as SystemConfigService;
|
||
|
|
use app\attribute\EncryptResponse;
|
||
|
|
use app\attribute\SkipApiEncrypt;
|
||
|
|
use madong\swagger\helper\AnnotationHelper;
|
||
|
|
use support\Container;
|
||
|
|
use support\Context;
|
||
|
|
use support\Request;
|
||
|
|
use Webman\Http\Response;
|
||
|
|
|
||
|
|
class ApiEncryptService
|
||
|
|
{
|
||
|
|
public const CONTEXT_AES_KEY = 'api_encrypt.aes_key';
|
||
|
|
public const CONTEXT_AES_IV = 'api_encrypt.aes_iv';
|
||
|
|
public const CONTEXT_ACTIVE = 'api_encrypt.active';
|
||
|
|
|
||
|
|
public function isEnabled(): bool
|
||
|
|
{
|
||
|
|
return (bool) $this->getConfigValue('enabled', false);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getScope(): string
|
||
|
|
{
|
||
|
|
return (string) $this->getConfigValue('scope', 'off');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getKeyVersion(): string
|
||
|
|
{
|
||
|
|
return (string) $this->getConfigValue('key_version', 'v1');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getAlgorithm(): string
|
||
|
|
{
|
||
|
|
return (string) $this->getConfigValue('algorithm', 'rsa-aes');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getAesMode(): string
|
||
|
|
{
|
||
|
|
return strtoupper((string) $this->getConfigValue('aes_mode', 'AES-256-CBC'));
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getPublicKey(): string
|
||
|
|
{
|
||
|
|
$path = (string) $this->getConfigValue('public_key_path', '');
|
||
|
|
return $path !== '' && is_file($path) ? (string) file_get_contents($path) : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getPrivateKey(): string
|
||
|
|
{
|
||
|
|
$path = (string) $this->getConfigValue('private_key_path', '');
|
||
|
|
return $path !== '' && is_file($path) ? (string) file_get_contents($path) : '';
|
||
|
|
}
|
||
|
|
|
||
|
|
public function isJsonRequest(Request $request): bool
|
||
|
|
{
|
||
|
|
$contentType = strtolower((string) $request->header('content-type', ''));
|
||
|
|
return str_contains($contentType, 'application/json');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function isJsonResponse(Response $response): bool
|
||
|
|
{
|
||
|
|
$contentType = strtolower((string) ($response->getHeader('Content-Type') ?: ''));
|
||
|
|
return str_contains($contentType, 'application/json');
|
||
|
|
}
|
||
|
|
|
||
|
|
public function shouldExcludePath(string $path): bool
|
||
|
|
{
|
||
|
|
$path = '/' . ltrim($path, '/');
|
||
|
|
$excludePaths = (array) $this->getConfigValue('exclude_paths', []);
|
||
|
|
|
||
|
|
foreach ($excludePaths as $excludePath) {
|
||
|
|
$excludePath = '/' . ltrim((string) $excludePath, '/');
|
||
|
|
if ($excludePath === $path || str_starts_with($path, $excludePath . '/')) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function shouldEncrypt(Request $request): bool
|
||
|
|
{
|
||
|
|
if (!$this->isEnabled()) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
$scope = $this->getScope();
|
||
|
|
if ($scope === 'off') {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
$path = $request->path();
|
||
|
|
if ($this->shouldExcludePath($path)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
$controllerClass = $request->controller;
|
||
|
|
$action = $request->action;
|
||
|
|
if (!$controllerClass || !$action) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (AnnotationHelper::hasMethodAnnotation($controllerClass, $action, SkipApiEncrypt::class)
|
||
|
|
|| AnnotationHelper::hasClassAnnotation($controllerClass, SkipApiEncrypt::class)) {
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($scope === 'all') {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
$encryptedPaths = (array) $this->getConfigValue('encrypted_paths', []);
|
||
|
|
if ($this->matchesEncryptedPath($request->path(), $encryptedPaths)) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
return AnnotationHelper::hasMethodAnnotation($controllerClass, $action, EncryptResponse::class)
|
||
|
|
|| AnnotationHelper::hasClassAnnotation($controllerClass, EncryptResponse::class);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function getEncryptedPaths(): array
|
||
|
|
{
|
||
|
|
$paths = (array) $this->getConfigValue('encrypted_paths', []);
|
||
|
|
return array_values(array_filter(array_map(static function ($path) {
|
||
|
|
return '/' . ltrim((string) $path, '/');
|
||
|
|
}, $paths)));
|
||
|
|
}
|
||
|
|
|
||
|
|
public function isEncryptedRequest(Request $request): bool
|
||
|
|
{
|
||
|
|
return (string) $request->header('x-api-encrypt', '') === '1';
|
||
|
|
}
|
||
|
|
|
||
|
|
public function decryptRequest(Request $request): array
|
||
|
|
{
|
||
|
|
$encryptedKey = trim((string) $request->header('x-api-encrypt-key', ''));
|
||
|
|
$encryptedIv = trim((string) $request->header('x-api-encrypt-iv', ''));
|
||
|
|
|
||
|
|
if ($encryptedKey === '' || $encryptedIv === '') {
|
||
|
|
throw new \RuntimeException('缺少加密请求头');
|
||
|
|
}
|
||
|
|
|
||
|
|
$privateKey = $this->getPrivateKey();
|
||
|
|
if ($privateKey === '') {
|
||
|
|
throw new \RuntimeException('接口加密私钥未配置');
|
||
|
|
}
|
||
|
|
|
||
|
|
$aesKey = RSAService::decryptOaep($encryptedKey, $privateKey);
|
||
|
|
$iv = RSAService::decryptOaep($encryptedIv, $privateKey);
|
||
|
|
|
||
|
|
if ($aesKey === '' || $iv === '') {
|
||
|
|
throw new \RuntimeException('会话密钥解密失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
Context::set(self::CONTEXT_AES_KEY, $aesKey);
|
||
|
|
Context::set(self::CONTEXT_AES_IV, $iv);
|
||
|
|
Context::set(self::CONTEXT_ACTIVE, true);
|
||
|
|
|
||
|
|
$body = trim($request->rawBody());
|
||
|
|
if ($body === '') {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
|
||
|
|
$payload = json_decode($body, true);
|
||
|
|
if (!is_array($payload)) {
|
||
|
|
throw new \RuntimeException('加密请求体格式错误');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (($payload['encrypted'] ?? false) !== true) {
|
||
|
|
return $payload;
|
||
|
|
}
|
||
|
|
|
||
|
|
$cipherText = (string) ($payload['data'] ?? '');
|
||
|
|
if ($cipherText === '') {
|
||
|
|
throw new \RuntimeException('缺少加密数据');
|
||
|
|
}
|
||
|
|
|
||
|
|
$plain = $this->aesDecrypt($cipherText, $aesKey, $iv);
|
||
|
|
$data = json_decode($plain, true);
|
||
|
|
if (!is_array($data)) {
|
||
|
|
throw new \RuntimeException('解密后的请求数据格式错误');
|
||
|
|
}
|
||
|
|
|
||
|
|
return $data;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function encryptResponseBody(string $body): string
|
||
|
|
{
|
||
|
|
$aesKey = (string) Context::get(self::CONTEXT_AES_KEY, '');
|
||
|
|
$iv = (string) Context::get(self::CONTEXT_AES_IV, '');
|
||
|
|
|
||
|
|
if ($aesKey === '' || $iv === '') {
|
||
|
|
throw new \RuntimeException('缺少响应加密会话信息');
|
||
|
|
}
|
||
|
|
|
||
|
|
$cipherText = $this->aesEncrypt($body, $aesKey, $iv);
|
||
|
|
|
||
|
|
return json_encode([
|
||
|
|
'encrypted' => true,
|
||
|
|
'algorithm' => $this->getAlgorithm(),
|
||
|
|
'key_version' => $this->getKeyVersion(),
|
||
|
|
'data' => $cipherText,
|
||
|
|
], JSON_UNESCAPED_UNICODE);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function applyDecryptedPayload(Request $request, array $data): void
|
||
|
|
{
|
||
|
|
if ($data === []) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
if ($request->method() === 'GET') {
|
||
|
|
$request->setGet(array_merge($request->get(), $data));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
$request->setPost($data);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function hasActiveSessionKey(): bool
|
||
|
|
{
|
||
|
|
return (bool) Context::get(self::CONTEXT_ACTIVE, false);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function generateAesKey(): string
|
||
|
|
{
|
||
|
|
return random_bytes(32);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function generateIv(): string
|
||
|
|
{
|
||
|
|
return random_bytes(16);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function aesEncrypt(string $plainText, string $aesKey, string $iv): string
|
||
|
|
{
|
||
|
|
$cipher = openssl_encrypt($plainText, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA, $iv);
|
||
|
|
if ($cipher === false) {
|
||
|
|
throw new \RuntimeException('AES 加密失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return base64_encode($cipher);
|
||
|
|
}
|
||
|
|
|
||
|
|
public function aesDecrypt(string $cipherText, string $aesKey, string $iv): string
|
||
|
|
{
|
||
|
|
$decoded = base64_decode($cipherText, true);
|
||
|
|
if ($decoded === false) {
|
||
|
|
throw new \RuntimeException('AES 密文解码失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
$plain = openssl_decrypt($decoded, 'AES-256-CBC', $aesKey, OPENSSL_RAW_DATA, $iv);
|
||
|
|
if ($plain === false) {
|
||
|
|
throw new \RuntimeException('AES 解密失败');
|
||
|
|
}
|
||
|
|
|
||
|
|
return $plain;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function generateKeyPair(int $bits = 2048): array
|
||
|
|
{
|
||
|
|
return RSAService::generateKeys($bits);
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function getConfigValue(string $key, mixed $default = null): mixed
|
||
|
|
{
|
||
|
|
$fileConfig = config('api_encrypt', []);
|
||
|
|
$dbConfig = $this->getDatabaseConfig();
|
||
|
|
|
||
|
|
if (array_key_exists($key, $dbConfig) && $dbConfig[$key] !== null && $dbConfig[$key] !== '') {
|
||
|
|
return $dbConfig[$key];
|
||
|
|
}
|
||
|
|
|
||
|
|
return $fileConfig[$key] ?? $default;
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function getDatabaseConfig(): array
|
||
|
|
{
|
||
|
|
try {
|
||
|
|
/** @var SystemConfigService $service */
|
||
|
|
$service = Container::get(SystemConfigService::class);
|
||
|
|
$config = $service->config('api_encrypt_setting', [], ['group_code' => 'system']);
|
||
|
|
return is_array($config) ? $config : [];
|
||
|
|
} catch (\Throwable) {
|
||
|
|
return [];
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
protected function matchesEncryptedPath(string $path, array $encryptedPaths): bool
|
||
|
|
{
|
||
|
|
$path = '/' . ltrim($path, '/');
|
||
|
|
foreach ($encryptedPaths as $encryptedPath) {
|
||
|
|
$encryptedPath = '/' . ltrim((string) $encryptedPath, '/');
|
||
|
|
if ($encryptedPath === $path || str_starts_with($path, $encryptedPath . '/')) {
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
public function persistKeyPair(array $keys): void
|
||
|
|
{
|
||
|
|
$publicPath = (string) $this->getConfigValue('public_key_path', '');
|
||
|
|
$privatePath = (string) $this->getConfigValue('private_key_path', '');
|
||
|
|
|
||
|
|
if ($publicPath === '' || $privatePath === '') {
|
||
|
|
throw new \RuntimeException('接口加密密钥路径未配置');
|
||
|
|
}
|
||
|
|
|
||
|
|
$publicDir = dirname($publicPath);
|
||
|
|
$privateDir = dirname($privatePath);
|
||
|
|
|
||
|
|
if (!is_dir($publicDir)) {
|
||
|
|
mkdir($publicDir, 0777, true);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!is_dir($privateDir)) {
|
||
|
|
mkdir($privateDir, 0777, true);
|
||
|
|
}
|
||
|
|
|
||
|
|
file_put_contents($publicPath, (string) ($keys['public'] ?? ''));
|
||
|
|
file_put_contents($privatePath, (string) ($keys['private'] ?? ''));
|
||
|
|
}
|
||
|
|
}
|