Files
alt-mysql/exec/lib/PhpMyAdminSso.php
T
Marek Miklewicz 9a5781e5ed Grant all owned databases when phpMyAdmin SSO has no specific target
The top-nav PHPMYADMIN button intentionally omits adminer_db to request
access to every database. determineGrantTargets() and the ticket's new
restrict_db field now distinguish that case from a specific per-row
database request instead of collapsing both into single-database scope.
2026-07-04 21:29:47 +02:00

230 lines
8.1 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));
$normalizedRequestedDatabase = null;
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.');
}
$normalizedRequestedDatabase = $requestedDatabase;
}
}
$targetDatabase = $normalizedRequestedDatabase ?? $databaseNames[0];
$restrictToDatabase = $normalizedRequestedDatabase ?? '';
$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 (self::determineGrantTargets($normalizedRequestedDatabase, $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, $restrictToDatabase);
return [
'target' => $this->buildPhpMyAdminLoginUrl($targetDatabase, $ticket),
'auth' => $auth,
];
}
/**
* @param string[] $ownedDatabases
* @return string[]
*/
private static function determineGrantTargets(?string $requestedDatabase, array $ownedDatabases): array
{
if ($requestedDatabase === null || $requestedDatabase === '') {
return $ownedDatabases;
}
return in_array($requestedDatabase, $ownedDatabases, true) ? [$requestedDatabase] : [];
}
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 $restrictToDatabase): 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,
'restrict_db' => $restrictToDatabase,
];
$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');
}
}