177 lines
5.8 KiB
PHP
177 lines
5.8 KiB
PHP
<?php
|
|
|
|
namespace App\Utilities;
|
|
|
|
use RuntimeException;
|
|
|
|
/**
|
|
* 使用团队系统数据库密码解锁固定连接资料。
|
|
*
|
|
* 仓库只保存加密后的主机、端口、库名和账号;密码既是解锁密钥,也是
|
|
* 实际数据库密码。这样新人页面只需要一个输入框,不需要判断 TLS、
|
|
* 证书、端口或账号。
|
|
*/
|
|
class TeamSystemDatabaseProfile
|
|
{
|
|
private const CIPHER = 'aes-256-gcm';
|
|
|
|
private const ITERATIONS = 210000;
|
|
|
|
/**
|
|
* 团队固定连接资料的加密版本。
|
|
*
|
|
* 这里不包含数据库密码;系统数据库密码既用于解锁资料,也作为实际
|
|
* 登录密码。资料必须保留在代码层,避免旧容器的 Laravel 配置缓存
|
|
* 缺少新配置项时把新人挡在连接页面。
|
|
*/
|
|
private const EMBEDDED_PROFILE = 'eyJ2IjoxLCJzYWx0IjoiSkdEMG5LckgxN3ZUcTRTcVY1N1NEdz09IiwiaXYiOiJoUEQyNmhrSUVPRkh6anpxIiwidGFnIjoib0dcLzVBR241a2ZLV1RxUWdLYVlcLzF3PT0iLCJkYXRhIjoiaFBxMjExdEFwMlZkdnRmVWtWU0h4dlZ2XC9RMmdTWU5jRFlxN1BJSFwvQXJHajVYU0ltbnhFKzRiTTdLaWZqYW1hYXlXNUhWVjR5ZkpYalBkbDV3ajRIWFk5cGFUNXdCZTVlR25qaTFVRGV6SmtHVEM5TUlEeTRqWExRWXBRTnRFVFZiMGFvbWJoIn0=';
|
|
|
|
public function __construct(private readonly ?string $encodedProfile = null)
|
|
{
|
|
}
|
|
|
|
public static function embeddedProfile(): string
|
|
{
|
|
return self::EMBEDDED_PROFILE;
|
|
}
|
|
|
|
public function settings(string $password): array
|
|
{
|
|
if ($password === '') {
|
|
throw new RuntimeException('请输入系统数据库密码。');
|
|
}
|
|
|
|
$profile = $this->decrypt($this->profile(), $password);
|
|
|
|
return [
|
|
'driver' => 'mysql',
|
|
'host' => $this->requiredString($profile, 'host'),
|
|
'port' => $this->requiredPort($profile),
|
|
'database' => $this->requiredString($profile, 'database'),
|
|
'mirror_database' => 'caiwu_business_mirror',
|
|
'username' => $this->requiredString($profile, 'username'),
|
|
'password' => $password,
|
|
'tls_required' => true,
|
|
'tls_verify_server_cert' => false,
|
|
'tls_ca' => '',
|
|
];
|
|
}
|
|
|
|
/**
|
|
* 仅供负责人轮换团队连接资料时生成新的密文,不输出明文密码。
|
|
*/
|
|
public static function seal(array $profile, string $password): string
|
|
{
|
|
$salt = random_bytes(16);
|
|
$iv = random_bytes(12);
|
|
$key = hash_pbkdf2('sha256', $password, $salt, self::ITERATIONS, 32, true);
|
|
$tag = '';
|
|
$plaintext = json_encode($profile, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE);
|
|
$ciphertext = openssl_encrypt(
|
|
$plaintext,
|
|
self::CIPHER,
|
|
$key,
|
|
OPENSSL_RAW_DATA,
|
|
$iv,
|
|
$tag
|
|
);
|
|
|
|
if ($ciphertext === false) {
|
|
throw new RuntimeException('团队系统数据库连接资料加密失败。');
|
|
}
|
|
|
|
return base64_encode(json_encode([
|
|
'v' => 1,
|
|
'salt' => base64_encode($salt),
|
|
'iv' => base64_encode($iv),
|
|
'tag' => base64_encode($tag),
|
|
'data' => base64_encode($ciphertext),
|
|
], JSON_THROW_ON_ERROR));
|
|
}
|
|
|
|
private function profile(): string
|
|
{
|
|
$profile = trim((string) (
|
|
$this->encodedProfile
|
|
?? config('system_database.team_profile')
|
|
));
|
|
|
|
return $profile !== '' ? $profile : self::EMBEDDED_PROFILE;
|
|
}
|
|
|
|
private function decrypt(string $encodedProfile, string $password): array
|
|
{
|
|
try {
|
|
$envelope = json_decode(
|
|
base64_decode($encodedProfile, true) ?: throw new RuntimeException(),
|
|
true,
|
|
16,
|
|
JSON_THROW_ON_ERROR
|
|
);
|
|
$salt = base64_decode((string) ($envelope['salt'] ?? ''), true);
|
|
$iv = base64_decode((string) ($envelope['iv'] ?? ''), true);
|
|
$tag = base64_decode((string) ($envelope['tag'] ?? ''), true);
|
|
$ciphertext = base64_decode((string) ($envelope['data'] ?? ''), true);
|
|
|
|
if (
|
|
($envelope['v'] ?? null) !== 1
|
|
|| $salt === false
|
|
|| strlen($salt) !== 16
|
|
|| $iv === false
|
|
|| strlen($iv) !== 12
|
|
|| $tag === false
|
|
|| strlen($tag) !== 16
|
|
|| $ciphertext === false
|
|
) {
|
|
throw new RuntimeException();
|
|
}
|
|
|
|
$key = hash_pbkdf2('sha256', $password, $salt, self::ITERATIONS, 32, true);
|
|
$plaintext = openssl_decrypt(
|
|
$ciphertext,
|
|
self::CIPHER,
|
|
$key,
|
|
OPENSSL_RAW_DATA,
|
|
$iv,
|
|
$tag
|
|
);
|
|
|
|
if ($plaintext === false) {
|
|
throw new RuntimeException();
|
|
}
|
|
|
|
$profile = json_decode($plaintext, true, 16, JSON_THROW_ON_ERROR);
|
|
|
|
if (! is_array($profile)) {
|
|
throw new RuntimeException();
|
|
}
|
|
|
|
return $profile;
|
|
} catch (\Throwable) {
|
|
throw new RuntimeException('系统数据库密码不正确,请重新输入。');
|
|
}
|
|
}
|
|
|
|
private function requiredString(array $profile, string $key): string
|
|
{
|
|
$value = trim((string) ($profile[$key] ?? ''));
|
|
|
|
if ($value === '' || preg_match('/[;\x00-\x1F]/', $value) === 1) {
|
|
throw new RuntimeException('团队系统数据库连接资料不完整,请联系负责人更新代码。');
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
|
|
private function requiredPort(array $profile): int
|
|
{
|
|
$port = (int) ($profile['port'] ?? 0);
|
|
|
|
if ($port < 1 || $port > 65535) {
|
|
throw new RuntimeException('团队系统数据库连接资料不完整,请联系负责人更新代码。');
|
|
}
|
|
|
|
return $port;
|
|
}
|
|
}
|