Files
MrleeAdmin/backend/core/tool/ApiEncryptService.php
T
2026-08-31 11:33:59 +08:00

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'] ?? ''));
}
}