ba64342702
phpMyAdmin login was broken because phpmyadmin_install.sh never wired the private phpMyAdmin copy into the web server: no Apache Alias was registered via DirectAdmin CustomBuild, so the SSO redirect target was unreachable. Add configure_apache_alias()/apply_apache_alias() (Alias + Directory blocks, ./build rewrite_confs, httpd reload), detect the real Apache group instead of hardcoding diradmin:diradmin, stop silently swallowing chown/chmod failures, and verify an optional SHA256 for the downloaded phpMyAdmin tarball. Extend phpmyadmin_health_check.sh to detect a missing/incorrect Apache alias and to probe HTTP reachability. Security hardening: scope temporary phpMyAdmin SSO MySQL roles to 127.0.0.1 instead of '%', tighten SSO ticket file permissions to 0640 (they contain a plaintext MySQL password), and log MySQL connection failures for diagnosis instead of swallowing them silently. Bump version to 1.2.12 and rebuild the release archive.
217 lines
7.4 KiB
PHP
217 lines
7.4 KiB
PHP
<?php
|
|
declare(strict_types=1);
|
|
|
|
final class PhpMyAdminSso
|
|
{
|
|
private Settings $settings;
|
|
private DirectAdminUser $daUser;
|
|
private MySQLService $db;
|
|
|
|
public function __construct(Settings $settings, DirectAdminUser $daUser, MySQLService $db)
|
|
{
|
|
$this->settings = $settings;
|
|
$this->daUser = $daUser;
|
|
$this->db = $db;
|
|
}
|
|
|
|
/**
|
|
* @return array{target:string,auth:array<string,string|int>}
|
|
*/
|
|
public function issueLoginPayload(?string $requestedDatabase = null): array
|
|
{
|
|
if (!$this->settings->enableAdminer()) {
|
|
throw new RuntimeException('Integracja phpMyAdmin jest wyłączona.');
|
|
}
|
|
|
|
$this->db->cleanupExpiredAdminerRoles();
|
|
|
|
$databaseRows = $this->db->listDatabasesForUser($this->daUser->username(), false);
|
|
if (empty($databaseRows)) {
|
|
throw new RuntimeException('Użytkownik nie ma żadnej bazy MySQL do otwarcia w phpMyAdmin.');
|
|
}
|
|
|
|
$databaseNames = [];
|
|
foreach ($databaseRows as $row) {
|
|
$name = (string)($row['name'] ?? '');
|
|
if ($name !== '') {
|
|
$databaseNames[] = $name;
|
|
}
|
|
}
|
|
$databaseNames = array_values(array_unique($databaseNames));
|
|
|
|
$targetDatabase = '';
|
|
if ($requestedDatabase !== null) {
|
|
$requestedDatabase = trim($requestedDatabase);
|
|
if ($requestedDatabase !== '') {
|
|
if (!in_array($requestedDatabase, $databaseNames, true)) {
|
|
throw new RuntimeException('Wskazana baza danych nie należy do zalogowanego użytkownika.');
|
|
}
|
|
$targetDatabase = $requestedDatabase;
|
|
}
|
|
}
|
|
if ($targetDatabase === '') {
|
|
$targetDatabase = $databaseNames[0];
|
|
}
|
|
|
|
$roleTtl = max($this->settings->adminerRoleTtl(), $this->settings->adminerSessionTtl() + 120);
|
|
$role = $this->generateTemporaryRoleName();
|
|
$password = $this->generateSecret(32);
|
|
$expiresAt = time() + $roleTtl;
|
|
|
|
$this->db->createTemporaryRole($role, $password, $expiresAt);
|
|
try {
|
|
foreach ($databaseNames as $dbName) {
|
|
$this->db->grantTemporaryAdminerAccess($dbName, $role);
|
|
}
|
|
} catch (Throwable $e) {
|
|
try {
|
|
$this->db->dropTemporaryRole($role);
|
|
} catch (Throwable $ignored) {
|
|
}
|
|
throw new RuntimeException('Nie udało się przygotować sesji phpMyAdmin: ' . $e->getMessage(), 0, $e);
|
|
}
|
|
|
|
$auth = [
|
|
'user' => $role,
|
|
'password' => $password,
|
|
'host' => $this->db->connectionEndpoint()['host'],
|
|
'port' => $this->db->connectionEndpoint()['port'],
|
|
'db' => $targetDatabase,
|
|
];
|
|
$ticket = $this->writeLoginTicket($auth, $targetDatabase);
|
|
|
|
return [
|
|
'target' => $this->buildPhpMyAdminLoginUrl($targetDatabase, $ticket),
|
|
'auth' => $auth,
|
|
];
|
|
}
|
|
|
|
private function buildPhpMyAdminLoginUrl(string $database, string $ticket): string
|
|
{
|
|
$query = http_build_query([
|
|
'ticket' => $ticket,
|
|
'route' => '/database/structure',
|
|
'db' => $database,
|
|
]);
|
|
|
|
return $this->phpMyAdminBaseUrl() . '/da_login.php?' . $query;
|
|
}
|
|
|
|
private function phpMyAdminBaseUrl(): string
|
|
{
|
|
$path = $this->settings->adminerUrlPath();
|
|
$customBase = $this->settings->adminerPublicBaseUrl();
|
|
if ($customBase !== '') {
|
|
return $customBase . $path;
|
|
}
|
|
|
|
$scheme = 'http';
|
|
$https = strtolower(Http::server('HTTPS'));
|
|
if ($https !== '' && $https !== 'off' && $https !== '0') {
|
|
$scheme = 'https';
|
|
} elseif (strtolower(Http::server('HTTP_X_FORWARDED_PROTO')) === 'https') {
|
|
$scheme = 'https';
|
|
} elseif (strtolower(Http::server('REQUEST_SCHEME')) === 'https') {
|
|
$scheme = 'https';
|
|
}
|
|
|
|
$host = Http::server('HTTP_HOST');
|
|
if ($host === '') {
|
|
$host = Http::server('SERVER_NAME', 'localhost');
|
|
}
|
|
$host = preg_replace('/:\d+$/', '', $host) ?? $host;
|
|
|
|
return $scheme . '://' . $host . $path;
|
|
}
|
|
|
|
/**
|
|
* @param array<string,string|int> $auth
|
|
*/
|
|
private function writeLoginTicket(array $auth, string $database): string
|
|
{
|
|
$ticketDir = rtrim($this->settings->adminerSsoDir(), '/') . '/tickets';
|
|
if (!is_dir($ticketDir) && !@mkdir($ticketDir, 0755, true) && !is_dir($ticketDir)) {
|
|
throw new RuntimeException('Nie można utworzyć katalogu ticketów phpMyAdmin: ' . $ticketDir);
|
|
}
|
|
|
|
$this->cleanupExpiredTickets($ticketDir);
|
|
|
|
$token = bin2hex(random_bytes(32));
|
|
$payload = [
|
|
'version' => 1,
|
|
'created_at' => time(),
|
|
'expires_at' => time() + $this->settings->adminerTicketTtl(),
|
|
'auth' => $auth,
|
|
'route' => '/database/structure',
|
|
'db' => $database,
|
|
];
|
|
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
|
if (!is_string($encoded) || $encoded === '') {
|
|
throw new RuntimeException('Nie udało się zakodować ticketu phpMyAdmin.');
|
|
}
|
|
|
|
$path = $ticketDir . '/' . $token . '.json';
|
|
$tmpPath = $path . '.tmp.' . bin2hex(random_bytes(4));
|
|
if (@file_put_contents($tmpPath, $encoded, LOCK_EX) === false) {
|
|
throw new RuntimeException('Nie udało się zapisać ticketu phpMyAdmin.');
|
|
}
|
|
@chmod($tmpPath, 0640);
|
|
if (!@rename($tmpPath, $path)) {
|
|
@unlink($tmpPath);
|
|
throw new RuntimeException('Nie udało się aktywować ticketu phpMyAdmin.');
|
|
}
|
|
|
|
return $token;
|
|
}
|
|
|
|
private function cleanupExpiredTickets(string $ticketDir): void
|
|
{
|
|
foreach (glob($ticketDir . '/*.json') ?: [] as $path) {
|
|
if (!is_file($path)) {
|
|
continue;
|
|
}
|
|
$raw = @file_get_contents($path);
|
|
$decoded = is_string($raw) ? json_decode($raw, true) : null;
|
|
$expiresAt = is_array($decoded) ? (int)($decoded['expires_at'] ?? 0) : 0;
|
|
if ($expiresAt > 0 && $expiresAt >= time()) {
|
|
continue;
|
|
}
|
|
@unlink($path);
|
|
}
|
|
}
|
|
|
|
private function generateTemporaryRoleName(): string
|
|
{
|
|
$base = strtolower($this->daUser->username());
|
|
$base = preg_replace('/[^a-z0-9_]+/', '_', $base) ?? 'user';
|
|
$base = trim($base, '_');
|
|
if ($base === '') {
|
|
$base = 'user';
|
|
}
|
|
|
|
for ($i = 0; $i < 5; $i++) {
|
|
$suffix = bin2hex(random_bytes(6));
|
|
$maxBaseLen = NamePolicy::MAX_IDENTIFIER_LEN - strlen('da_tmp_phpmyadmin_') - 1 - strlen($suffix);
|
|
if ($maxBaseLen < 1) {
|
|
$maxBaseLen = 1;
|
|
}
|
|
$safeBase = substr($base, 0, $maxBaseLen);
|
|
$role = 'da_tmp_phpmyadmin_' . $safeBase . '_' . $suffix;
|
|
if (!$this->db->roleExists($role)) {
|
|
return $role;
|
|
}
|
|
}
|
|
|
|
throw new RuntimeException('Nie udało się wygenerować unikalnego tymczasowego użytkownika dla phpMyAdmin.');
|
|
}
|
|
|
|
private function generateSecret(int $len): string
|
|
{
|
|
$value = rtrim(strtr(base64_encode(random_bytes(48)), '+/', 'AZ'), '=');
|
|
if (strlen($value) >= $len) {
|
|
return substr($value, 0, $len);
|
|
}
|
|
return str_pad($value, $len, 'x');
|
|
}
|
|
}
|