CaiWuXiTong/app/Utilities/TeamSystemDatabaseProfile.php

176 lines
5.7 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 = 'eyJ2IjoxLCJzYWx0IjoiMUNLdmVsREk4d3NBTTdUZGJsSDYydz09IiwiaXYiOiJaYW93YUM4YXYwZXMwd1oxIiwidGFnIjoiWUtpM3dJMXArZW1PY3Q0ZDlhaFF3Zz09IiwiZGF0YSI6ImxUaXZKZHVpVkF2R2RWM3Znd3hDVHViSVhKMFMzWWFwK1B3QUtcLzkyYm0wZnMzR05jZjZ2ZE5yeXZGbnlNelQ3d3JPbXNlc1B0MFpieWJhcDhkMktKMmZjb0lrZnRUdTBET1laZXc0VnFhRW5KR1VKQlNpaXZ5NXpYVmFVTWtLZnVMenNWNHEwZXc9PSJ9';
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'),
'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;
}
}