Files
da-postgresql/exec/lib/AdminerSso.php
T
2026-07-04 16:00:04 +02:00

327 lines
11 KiB
PHP

<?php
declare(strict_types=1);
final class AdminerSso
{
private const TMP_ROLE_PREFIX = 'da_tmp_adminer_';
private Settings $settings;
private DirectAdminUser $daUser;
private PostgresService $pg;
private string $runtimeDir;
private string $ticketsDir;
public function __construct(Settings $settings, DirectAdminUser $daUser, PostgresService $pg)
{
$this->settings = $settings;
$this->daUser = $daUser;
$this->pg = $pg;
$this->runtimeDir = rtrim($settings->adminerSsoDir(), '/');
$this->ticketsDir = $this->runtimeDir . '/tickets';
}
public function issueLoginUrl(?string $requestedDatabase = null): string
{
if (!$this->settings->enableAdminer()) {
throw new RuntimeException('Integracja Adminera jest wyłączona.');
}
$this->assertRuntimeReady();
$now = time();
$this->cleanupTicketFiles($now);
$this->pg->cleanupExpiredAdminerRoles(self::TMP_ROLE_PREFIX);
$databaseRows = $this->pg->listDatabasesForUser($this->daUser->username(), false);
if (empty($databaseRows)) {
throw new RuntimeException('Użytkownik nie ma żadnej bazy PostgreSQL do otwarcia w Adminerze.');
}
$databaseNames = [];
foreach ($databaseRows as $row) {
$name = (string)($row['name'] ?? '');
if ($name !== '') {
$databaseNames[] = $name;
}
}
$databaseNames = array_values(array_unique($databaseNames));
if (empty($databaseNames)) {
throw new RuntimeException('Nie znaleziono poprawnych nazw baz danych dla użytkownika.');
}
$ticketTtl = $this->settings->adminerTicketTtl();
$sessionTtl = $this->settings->adminerSessionTtl();
$roleTtl = max($this->settings->adminerRoleTtl(), $sessionTtl + 120);
$ticketExpiresAt = $now + $ticketTtl;
$sessionExpiresAt = $now + $sessionTtl;
$roleExpiresAt = $now + $roleTtl;
$role = $this->generateTemporaryRoleName();
$password = $this->generateSecret(36);
$endpoint = $this->pg->connectionEndpoint();
$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;
}
}
$createdRole = false;
try {
$this->pg->createTemporaryRole($role, $password, $roleExpiresAt);
$createdRole = true;
foreach ($databaseNames as $dbName) {
$this->pg->grantTemporaryAdminerAccess($dbName, $role);
}
$ticketId = $this->generateTicketId();
$payload = [
'v' => 1,
'da_user' => $this->daUser->username(),
'host' => (string)($endpoint['host'] ?? 'localhost'),
'port' => (int)($endpoint['port'] ?? 5432),
'role' => $role,
'password' => $password,
'database' => $targetDatabase,
'issued_at' => $now,
'expires_at' => $ticketExpiresAt,
'session_expires_at' => $sessionExpiresAt,
'role_expires_at' => $roleExpiresAt,
'user_binding' => $this->buildUserBinding(),
];
$this->writeTicket($ticketId, $payload);
$targetBase = $this->buildAdminerBaseUrl();
$separator = str_contains($targetBase, '?') ? '&' : '?';
return $targetBase . $separator . 'ticket=' . rawurlencode($ticketId);
} catch (Throwable $e) {
if ($createdRole) {
try {
$this->pg->dropTemporaryRole($role);
} catch (Throwable $cleanupError) {
// Ignorujemy błąd cleanupu roli, aby zwrócić pierwotny wyjątek.
}
}
throw new RuntimeException('Nie udało się przygotować sesji Adminera: ' . $e->getMessage(), 0, $e);
}
}
private function assertRuntimeReady(): void
{
if (!is_dir($this->runtimeDir)) {
throw new RuntimeException('Brak katalogu runtime Adminera: ' . $this->runtimeDir . '. Uruchom scripts/setup/adminer_install.sh');
}
if (!is_dir($this->ticketsDir)) {
throw new RuntimeException('Brak katalogu ticketów Adminera: ' . $this->ticketsDir . '. Uruchom scripts/setup/adminer_install.sh');
}
if (!is_readable($this->ticketsDir) || !is_writable($this->ticketsDir)) {
throw new RuntimeException('Brak uprawnień odczytu/zapisu do katalogu ticketów: ' . $this->ticketsDir);
}
}
private function cleanupTicketFiles(int $now): void
{
$files = glob($this->ticketsDir . '/*.json');
if ($files === false) {
return;
}
$hardTtl = max($this->settings->adminerRoleTtl(), 3600);
foreach ($files as $path) {
if (!is_file($path)) {
continue;
}
$remove = false;
$raw = @file_get_contents($path);
if (is_string($raw) && $raw !== '') {
$data = json_decode($raw, true);
if (is_array($data) && isset($data['expires_at']) && is_numeric($data['expires_at'])) {
$expiresAt = (int)$data['expires_at'];
if ($expiresAt < ($now - 60)) {
$remove = true;
}
}
}
if (!$remove) {
$mtime = @filemtime($path);
if ($mtime !== false && $mtime < ($now - $hardTtl)) {
$remove = true;
}
}
if ($remove) {
@unlink($path);
}
}
}
/**
* @param array<string,mixed> $payload
*/
private function writeTicket(string $ticketId, array $payload): void
{
$path = $this->ticketsDir . '/' . $ticketId . '.json';
$encoded = json_encode($payload, JSON_UNESCAPED_SLASHES);
if ($encoded === false) {
throw new RuntimeException('Nie udało się zakodować ticketu Adminera.');
}
$handle = @fopen($path, 'xb');
if (!is_resource($handle)) {
throw new RuntimeException('Nie udało się utworzyć ticketu Adminera: ' . $path);
}
$written = @fwrite($handle, $encoded);
@fclose($handle);
if ($written === false || $written < strlen($encoded)) {
@unlink($path);
throw new RuntimeException('Nie udało się zapisać ticketu Adminera.');
}
$this->alignTicketGroup($path);
@chmod($path, 0644);
}
private function alignTicketGroup(string $path): void
{
$dirGroupId = @filegroup($this->ticketsDir);
if (!is_int($dirGroupId) || $dirGroupId < 0) {
return;
}
$targetGroup = null;
if (function_exists('posix_getgrgid')) {
$groupInfo = @posix_getgrgid($dirGroupId);
if (is_array($groupInfo) && isset($groupInfo['name']) && is_string($groupInfo['name'])) {
$targetGroup = $groupInfo['name'];
}
}
if ($targetGroup === null || $targetGroup === '') {
$targetGroup = (string)$dirGroupId;
}
@chgrp($path, $targetGroup);
}
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(self::TMP_ROLE_PREFIX) - 1 - strlen($suffix);
if ($maxBaseLen < 1) {
$maxBaseLen = 1;
}
$safeBase = substr($base, 0, $maxBaseLen);
$role = self::TMP_ROLE_PREFIX . $safeBase . '_' . $suffix;
if (!$this->pg->roleExists($role)) {
return $role;
}
}
throw new RuntimeException('Nie udało się wygenerować unikalnej tymczasowej roli dla Adminera.');
}
private function generateTicketId(): string
{
return rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '=');
}
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');
}
private function buildUserBinding(): string
{
$ua = Http::server('HTTP_USER_AGENT');
if ($ua === '') {
$raw = getenv('HTTP_USER_AGENT');
if ($raw !== false) {
$ua = trim((string)$raw);
}
}
return hash('sha256', $ua);
}
private function buildAdminerBaseUrl(): string
{
$path = $this->settings->adminerUrlPath();
$customBase = $this->settings->adminerPublicBaseUrl();
if ($customBase !== '') {
return $customBase . $path;
}
$host = trim(Http::server('HTTP_HOST'));
if ($host === '') {
$host = trim(Http::server('SERVER_NAME'));
}
if ($host === '') {
return $path;
}
$host = preg_replace('/:\d+$/', '', $host) ?? $host;
$scheme = $this->detectRequestScheme();
if ($scheme !== '') {
return $scheme . '://' . $host . $path;
}
// Fallback bezpieczny: odziedzicz protokół aktualnej strony (unika mixed content).
return '//' . $host . $path;
}
private function detectRequestScheme(): string
{
$forwardedProto = strtolower(trim(Http::server('HTTP_X_FORWARDED_PROTO')));
if ($forwardedProto === 'https' || $forwardedProto === 'http') {
return $forwardedProto;
}
$requestScheme = strtolower(trim(Http::server('REQUEST_SCHEME')));
if ($requestScheme === 'https' || $requestScheme === 'http') {
return $requestScheme;
}
$frontEndHttps = strtolower(trim(Http::server('HTTP_FRONT_END_HTTPS')));
if ($frontEndHttps === 'on' || $frontEndHttps === '1' || $frontEndHttps === 'https') {
return 'https';
}
$https = strtolower(trim(Http::server('HTTPS')));
if ($https === 'on' || $https === '1' || $https === 'https' || $https === 'true') {
return 'https';
}
$serverPort = trim(Http::server('SERVER_PORT'));
if ($serverPort === '443') {
return 'https';
}
if ($serverPort === '80') {
return 'http';
}
return '';
}
}