327 lines
11 KiB
PHP
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 '';
|
|
}
|
|
}
|