feat: add safe esign production config template
This commit is contained in:
parent
2075e7a1a1
commit
a4724f12e2
@ -8,6 +8,7 @@ use App\Models\Business\CourierAgreementSignature;
|
||||
use App\Models\Business\CourierQuoteSignature;
|
||||
use App\Support\CourierAgreementSignatureService;
|
||||
use App\Support\CourierQuoteSignatureService;
|
||||
use App\Services\Esign\EsignConfiguration;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controller;
|
||||
@ -16,13 +17,15 @@ class EsignCallbacks extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, CourierQuoteSignatureService $service, CourierAgreementSignatureService $agreementService): JsonResponse
|
||||
{
|
||||
$appId = trim((string) config('services.esign.app_id'));
|
||||
$appSecret = trim((string) config('services.esign.app_secret'));
|
||||
$configuration = EsignConfiguration::fromConfig();
|
||||
|
||||
if ($appId === '' || $appSecret === '') {
|
||||
if (! $configuration->isConfigured()) {
|
||||
return response()->json(['code' => '503', 'msg' => 'not configured'], 503);
|
||||
}
|
||||
|
||||
$appId = $configuration->appId();
|
||||
$appSecret = $configuration->appSecret();
|
||||
|
||||
$timestamp = trim((string) $request->header('X-Tsign-Open-TIMESTAMP'));
|
||||
$algorithm = trim((string) $request->header('X-Tsign-Open-SIGNATURE-ALGORITHM'));
|
||||
$callbackAppId = trim((string) $request->header('X-Tsign-Open-App-Id'));
|
||||
@ -94,7 +97,7 @@ class EsignCallbacks extends Controller
|
||||
private function timestampIsFresh(string $timestamp): bool
|
||||
{
|
||||
$seconds = strlen($timestamp) >= 13 ? intdiv((int) $timestamp, 1000) : (int) $timestamp;
|
||||
$tolerance = max(60, (int) config('services.esign.callback_tolerance_seconds', 900));
|
||||
$tolerance = EsignConfiguration::fromConfig()->callbackToleranceSeconds();
|
||||
|
||||
return abs(now()->timestamp - $seconds) <= $tolerance;
|
||||
}
|
||||
|
||||
@ -12,22 +12,30 @@ final class EsignClient
|
||||
|
||||
private readonly EsignRequestSigner $signer;
|
||||
|
||||
public function __construct()
|
||||
public function __construct(?EsignConfiguration $configuration = null)
|
||||
{
|
||||
$appId = trim((string) config('services.esign.app_id'));
|
||||
$appSecret = trim((string) config('services.esign.app_secret'));
|
||||
$configuration ??= EsignConfiguration::fromConfig();
|
||||
$configuration->assertReadyForApi();
|
||||
|
||||
if ($appId === '' || $appSecret === '') {
|
||||
throw new RuntimeException('E 签宝沙箱尚未配置 App ID 或 AppSecret。');
|
||||
}
|
||||
|
||||
$this->baseUrl = rtrim((string) config('services.esign.base_url'), '/');
|
||||
$this->signer = new EsignRequestSigner($appId, $appSecret);
|
||||
$this->baseUrl = $configuration->baseUrl();
|
||||
$this->signer = new EsignRequestSigner($configuration->appId(), $configuration->appSecret());
|
||||
}
|
||||
|
||||
public static function configured(): bool
|
||||
{
|
||||
return filled(config('services.esign.app_id')) && filled(config('services.esign.app_secret'));
|
||||
$configuration = EsignConfiguration::fromConfig();
|
||||
|
||||
if (! $configuration->isConfigured()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$configuration->assertReadyForApi();
|
||||
} catch (RuntimeException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function uploadPdf(string $bytes, string $fileName): string
|
||||
@ -82,7 +90,7 @@ final class EsignClient
|
||||
'autoFinish' => true,
|
||||
'identityVerify' => true,
|
||||
];
|
||||
$notifyUrl = trim((string) config('services.esign.notify_url'));
|
||||
$notifyUrl = EsignConfiguration::fromConfig()->notifyUrl();
|
||||
|
||||
if ($notifyUrl !== '') {
|
||||
$flowConfig['notifyUrl'] = $notifyUrl;
|
||||
@ -249,6 +257,6 @@ final class EsignClient
|
||||
|
||||
private function timeout(): int
|
||||
{
|
||||
return max(5, (int) config('services.esign.timeout', 20));
|
||||
return EsignConfiguration::fromConfig()->timeout();
|
||||
}
|
||||
}
|
||||
|
||||
132
app/Services/Esign/EsignConfiguration.php
Normal file
132
app/Services/Esign/EsignConfiguration.php
Normal file
@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Esign;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Reads the E签宝 settings that Laravel loaded from the private runtime/.env.
|
||||
*
|
||||
* Keeping this normalization and endpoint check in one place prevents a
|
||||
* production credential from silently being sent to the sandbox endpoint (or
|
||||
* the reverse) when a deployment omits ESIGN_BASE_URL.
|
||||
*/
|
||||
final class EsignConfiguration
|
||||
{
|
||||
private readonly string $environment;
|
||||
|
||||
private readonly string $baseUrl;
|
||||
|
||||
private readonly string $appId;
|
||||
|
||||
private readonly string $appSecret;
|
||||
|
||||
private readonly string $notifyUrl;
|
||||
|
||||
private readonly int $callbackToleranceSeconds;
|
||||
|
||||
private readonly int $timeout;
|
||||
|
||||
public function __construct(
|
||||
string $environment,
|
||||
string $baseUrl,
|
||||
string $appId,
|
||||
string $appSecret,
|
||||
string $notifyUrl,
|
||||
int $callbackToleranceSeconds,
|
||||
int $timeout,
|
||||
) {
|
||||
$this->environment = strtolower(trim($environment));
|
||||
$this->baseUrl = rtrim(trim($baseUrl), '/');
|
||||
$this->appId = trim($appId);
|
||||
$this->appSecret = trim($appSecret);
|
||||
$this->notifyUrl = trim($notifyUrl);
|
||||
$this->callbackToleranceSeconds = max(60, $callbackToleranceSeconds);
|
||||
$this->timeout = max(5, $timeout);
|
||||
}
|
||||
|
||||
public static function fromConfig(): self
|
||||
{
|
||||
return new self(
|
||||
environment: strtolower(trim((string) config('services.esign.environment', 'sandbox'))),
|
||||
baseUrl: rtrim(trim((string) config('services.esign.base_url')), '/'),
|
||||
appId: trim((string) config('services.esign.app_id')),
|
||||
appSecret: trim((string) config('services.esign.app_secret')),
|
||||
notifyUrl: trim((string) config('services.esign.notify_url')),
|
||||
callbackToleranceSeconds: max(60, (int) config('services.esign.callback_tolerance_seconds', 900)),
|
||||
timeout: max(5, (int) config('services.esign.timeout', 20)),
|
||||
);
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->appId !== '' && $this->appSecret !== '';
|
||||
}
|
||||
|
||||
public function assertReadyForApi(): void
|
||||
{
|
||||
if (! $this->isConfigured()) {
|
||||
$label = $this->environment === 'production' ? '正式' : '沙箱';
|
||||
|
||||
throw new RuntimeException("E 签宝{$label}尚未配置 App ID 或 AppSecret。");
|
||||
}
|
||||
|
||||
if (! in_array($this->environment, ['sandbox', 'production'], true)) {
|
||||
throw new RuntimeException('E 签宝环境只能是 sandbox 或 production。');
|
||||
}
|
||||
|
||||
if ($this->baseUrl === '' || filter_var($this->baseUrl, FILTER_VALIDATE_URL) === false) {
|
||||
throw new RuntimeException('E 签宝 API 地址无效,请检查 ESIGN_BASE_URL。');
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($this->baseUrl, PHP_URL_SCHEME));
|
||||
$host = strtolower((string) parse_url($this->baseUrl, PHP_URL_HOST));
|
||||
|
||||
if ($scheme !== 'https') {
|
||||
throw new RuntimeException('E 签宝 API 地址必须使用 HTTPS。');
|
||||
}
|
||||
|
||||
if ($this->environment === 'production' && $host === 'smlopenapi.esign.cn') {
|
||||
throw new RuntimeException('E 签宝正式环境不能使用沙箱 API 地址。');
|
||||
}
|
||||
|
||||
if ($this->environment === 'sandbox' && $host === 'openapi.esign.cn') {
|
||||
throw new RuntimeException('E 签宝沙箱环境不能使用正式 API 地址。');
|
||||
}
|
||||
}
|
||||
|
||||
public function environment(): string
|
||||
{
|
||||
return $this->environment;
|
||||
}
|
||||
|
||||
public function baseUrl(): string
|
||||
{
|
||||
return $this->baseUrl;
|
||||
}
|
||||
|
||||
public function appId(): string
|
||||
{
|
||||
return $this->appId;
|
||||
}
|
||||
|
||||
public function appSecret(): string
|
||||
{
|
||||
return $this->appSecret;
|
||||
}
|
||||
|
||||
public function notifyUrl(): string
|
||||
{
|
||||
return $this->notifyUrl;
|
||||
}
|
||||
|
||||
public function callbackToleranceSeconds(): int
|
||||
{
|
||||
return $this->callbackToleranceSeconds;
|
||||
}
|
||||
|
||||
public function timeout(): int
|
||||
{
|
||||
return $this->timeout;
|
||||
}
|
||||
}
|
||||
@ -35,8 +35,16 @@ return [
|
||||
],
|
||||
|
||||
'esign' => [
|
||||
// Credentials are deliberately read from the private runtime/.env only.
|
||||
// Keep the endpoint aligned with the selected environment when the
|
||||
// explicit ESIGN_BASE_URL override is omitted.
|
||||
'environment' => env('ESIGN_ENVIRONMENT', 'sandbox'),
|
||||
'base_url' => env('ESIGN_BASE_URL', 'https://smlopenapi.esign.cn'),
|
||||
'base_url' => env(
|
||||
'ESIGN_BASE_URL',
|
||||
env('ESIGN_ENVIRONMENT', 'sandbox') === 'production'
|
||||
? 'https://openapi.esign.cn'
|
||||
: 'https://smlopenapi.esign.cn',
|
||||
),
|
||||
'app_id' => env('ESIGN_APP_ID'),
|
||||
'app_secret' => env('ESIGN_APP_SECRET'),
|
||||
'notify_url' => env('ESIGN_NOTIFY_URL'),
|
||||
|
||||
10
docs/esign-production.env.example
Normal file
10
docs/esign-production.env.example
Normal file
@ -0,0 +1,10 @@
|
||||
# 脱敏模板:复制需要的键到服务器私有 runtime/.env,再填写易签宝正式应用值。
|
||||
# 本文件只用于说明,禁止直接作为生产配置提交或把真实值回填到这里。
|
||||
|
||||
ESIGN_ENVIRONMENT=production
|
||||
ESIGN_BASE_URL=https://openapi.esign.cn
|
||||
ESIGN_APP_ID=
|
||||
ESIGN_APP_SECRET=
|
||||
ESIGN_NOTIFY_URL=https://<你的公网域名>/integrations/esign/callback
|
||||
ESIGN_CALLBACK_TOLERANCE_SECONDS=900
|
||||
ESIGN_TIMEOUT=20
|
||||
75
tests/Unit/EsignConfigurationTest.php
Normal file
75
tests/Unit/EsignConfigurationTest.php
Normal file
@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Esign\EsignConfiguration;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use RuntimeException;
|
||||
|
||||
class EsignConfigurationTest extends TestCase
|
||||
{
|
||||
public function test_it_normalizes_private_environment_values_without_exposing_the_secret(): void
|
||||
{
|
||||
$configuration = new EsignConfiguration(
|
||||
environment: 'production',
|
||||
baseUrl: 'https://openapi.esign.cn/',
|
||||
appId: 'production-app-id',
|
||||
appSecret: 'production-app-secret',
|
||||
notifyUrl: 'https://example.test/integrations/esign/callback',
|
||||
callbackToleranceSeconds: 900,
|
||||
timeout: 20,
|
||||
);
|
||||
|
||||
$configuration->assertReadyForApi();
|
||||
|
||||
$this->assertTrue($configuration->isConfigured());
|
||||
$this->assertSame('https://openapi.esign.cn', $configuration->baseUrl());
|
||||
$this->assertSame('production-app-id', $configuration->appId());
|
||||
}
|
||||
|
||||
public function test_it_rejects_production_credentials_with_the_sandbox_endpoint(): void
|
||||
{
|
||||
$configuration = $this->configuration(baseUrl: 'https://smlopenapi.esign.cn');
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('正式环境不能使用沙箱 API 地址');
|
||||
|
||||
$configuration->assertReadyForApi();
|
||||
}
|
||||
|
||||
public function test_it_rejects_sandbox_credentials_with_the_production_endpoint(): void
|
||||
{
|
||||
$configuration = $this->configuration(environment: 'sandbox', baseUrl: 'https://openapi.esign.cn');
|
||||
|
||||
$this->expectException(RuntimeException::class);
|
||||
$this->expectExceptionMessage('沙箱环境不能使用正式 API 地址');
|
||||
|
||||
$configuration->assertReadyForApi();
|
||||
}
|
||||
|
||||
public function test_it_requires_both_credentials_before_an_api_request(): void
|
||||
{
|
||||
$configuration = $this->configuration(appSecret: '');
|
||||
|
||||
$this->assertFalse($configuration->isConfigured());
|
||||
$this->expectExceptionMessage('尚未配置 App ID 或 AppSecret');
|
||||
$configuration->assertReadyForApi();
|
||||
}
|
||||
|
||||
private function configuration(
|
||||
string $environment = 'production',
|
||||
string $baseUrl = 'https://openapi.esign.cn',
|
||||
string $appId = 'production-app-id',
|
||||
string $appSecret = 'production-app-secret',
|
||||
): EsignConfiguration {
|
||||
return new EsignConfiguration(
|
||||
environment: $environment,
|
||||
baseUrl: $baseUrl,
|
||||
appId: $appId,
|
||||
appSecret: $appSecret,
|
||||
notifyUrl: '',
|
||||
callbackToleranceSeconds: 900,
|
||||
timeout: 20,
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user