1560759c51
After live testing on the real server, three follow-up issues surfaced: - PhpMyAdminSso granted the temporary SSO MySQL role access to every database the account owns, not just the one the "Zaloguj do bazy" button was clicked for, so phpMyAdmin showed all databases. Extract determineGrantTargets() and scope the GRANT to only the requested database; also set phpMyAdmin's only_db from the SSO session so the UI itself only shows that database. - phpMyAdmin's LogoutURL pointed at da_login.php with no ticket, which always produced "Missing or invalid phpMyAdmin login ticket." on logout. Add a DIRECTADMIN_PANEL_PORT setting (default 2222) and point LogoutURL at the plugin's own database list (/CMD_PLUGINS/alt-mysql/index.html) instead. - The create-database form kept echoing back the just-submitted values after a successful creation, forcing manual clearing before creating the next database. Clear the relevant $_POST keys once creation succeeds so the form resets while still preserving sticky values on validation failure. Bump version to 1.2.14 and rebuild the release archive.
226 lines
7.8 KiB
PHP
226 lines
7.8 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 (self::determineGrantTargets($targetDatabase, $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,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param string[] $ownedDatabases
|
|
* @return string[]
|
|
*/
|
|
private static function determineGrantTargets(string $targetDatabase, array $ownedDatabases): array
|
|
{
|
|
return in_array($targetDatabase, $ownedDatabases, true) ? [$targetDatabase] : [];
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|