From 250722dc419ed227a82071ed403c024749cedfbb Mon Sep 17 00:00:00 2001 From: Jukka Lampikoski Date: Wed, 11 Mar 2026 08:48:34 +0200 Subject: [PATCH] Yrityskohtainen IP-rajoitus kirjautumiseen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lisätty allowed_ips kenttä yrityksiin. Tyhjä = ei rajoitusta, muuten vain listatut IP:t/CIDR-alueet pääsevät kirjautumaan. Superadmin ohittaa aina IP-tarkistuksen (backdoor). Tarkistus tehdään login, check_auth ja company_switch -endpointeissa. Co-Authored-By: Claude Opus 4.6 --- api.php | 88 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- db.php | 9 ++++-- index.html | 5 ++++ script.js | 8 +++-- 4 files changed, 103 insertions(+), 7 deletions(-) diff --git a/api.php b/api.php index c7f95ed..df7bacc 100644 --- a/api.php +++ b/api.php @@ -130,6 +130,37 @@ function getClientIp(): string { return $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } +/** + * Tarkista onko IP sallittujen listalla. + * Tyhjä lista = ei rajoitusta (kaikki sallittu). + * Tukee yksittäisiä IP-osoitteita ja CIDR-alueita (esim. 192.168.1.0/24). + */ +function isIpAllowed(string $ip, string $allowedIps): bool { + $allowedIps = trim($allowedIps); + if ($allowedIps === '') return true; // ei rajoitusta + $entries = preg_split('/[\s,]+/', $allowedIps, -1, PREG_SPLIT_NO_EMPTY); + $ipLong = ip2long($ip); + if ($ipLong === false) return false; + foreach ($entries as $entry) { + $entry = trim($entry); + if ($entry === '') continue; + if (strpos($entry, '/') !== false) { + // CIDR-alue (esim. 192.168.1.0/24) + [$subnet, $bits] = explode('/', $entry, 2); + $bits = (int)$bits; + if ($bits < 0 || $bits > 32) continue; + $subnetLong = ip2long($subnet); + if ($subnetLong === false) continue; + $mask = $bits === 0 ? 0 : (~0 << (32 - $bits)); + if (($ipLong & $mask) === ($subnetLong & $mask)) return true; + } else { + // Yksittäinen IP + if (ip2long($entry) === $ipLong) return true; + } + } + return false; +} + function normalizeAddress(string $addr): string { $addr = strtolower(trim($addr)); $addr = preg_replace('/\s+/', ' ', $addr); @@ -1115,6 +1146,28 @@ switch ($action) { echo json_encode(['error' => 'Sinulla ei ole oikeutta kirjautua tälle sivustolle.']); break; } + // IP-rajoitus: superadmin ohittaa aina + $allCompanies = dbLoadCompanies(); + if ($u['role'] !== 'superadmin') { + $allowedCompanies = []; + foreach ($userCompanies as $ucId) { + foreach ($allCompanies as $comp) { + if ($comp['id'] === $ucId) { + if (isIpAllowed($ip, $comp['allowed_ips'] ?? '')) { + $allowedCompanies[] = $ucId; + } + break; + } + } + } + if (empty($allowedCompanies)) { + dbRecordLoginAttempt($ip); + http_response_code(403); + echo json_encode(['error' => 'IP-osoitteesi ei ole sallittu.']); + break; + } + $userCompanies = $allowedCompanies; + } session_regenerate_id(true); $_SESSION['user_id'] = $u['id']; $_SESSION['username'] = $u['username']; @@ -1128,7 +1181,6 @@ switch ($action) { $_SESSION['company_id'] = !empty($userCompanies) ? $userCompanies[0] : ''; } // Hae yritysten nimet - $allCompanies = dbLoadCompanies(); $companyList = []; foreach ($allCompanies as $comp) { if (in_array($comp['id'], $userCompanies)) { @@ -1167,15 +1219,32 @@ switch ($action) { $_SESSION['company_id'] = !empty($_SESSION['companies']) ? $_SESSION['companies'][0] : ''; } } - // Hae yritysten nimet + // Hae yritysten nimet + IP-rajoitus $userCompanyIds = $_SESSION['companies'] ?? []; $allCompanies = dbLoadCompanies(); + $ip = getClientIp(); $companyList = []; foreach ($allCompanies as $comp) { if (in_array($comp['id'], $userCompanyIds)) { + // IP-rajoitus: superadmin ohittaa aina + if (($_SESSION['role'] ?? '') !== 'superadmin' && !isIpAllowed($ip, $comp['allowed_ips'] ?? '')) { + continue; + } $companyList[] = ['id' => $comp['id'], 'nimi' => $comp['nimi']]; } } + // Jos IP-rajoitus poistaa kaikki yritykset (ei superadmin) → kirjaa ulos + if (empty($companyList) && ($_SESSION['role'] ?? '') !== 'superadmin') { + session_destroy(); + echo json_encode(['authenticated' => false]); + break; + } + // Päivitä session companies IP-suodatuksen mukaan + $allowedIds = array_column($companyList, 'id'); + $_SESSION['companies'] = $allowedIds; + if (!in_array($_SESSION['company_id'] ?? '', $allowedIds) && !empty($allowedIds)) { + $_SESSION['company_id'] = $allowedIds[0]; + } // Hae allekirjoitukset (oletus generoituna jos omaa ei ole) $userSignatures = $u ? buildSignaturesWithDefaults($u, $u['companies'] ?? []) : []; // Brändäystiedot domain-pohjaisesti (sama kuin branding-endpoint) @@ -2914,6 +2983,7 @@ switch ($action) { if (isset($input['enabled_modules']) && is_array($input['enabled_modules'])) { $c['enabled_modules'] = array_values($input['enabled_modules']); } + if (isset($input['allowed_ips'])) $c['allowed_ips'] = trim($input['allowed_ips']); dbSaveCompany($c); $found = true; echo json_encode($c); @@ -2957,6 +3027,20 @@ switch ($action) { echo json_encode(['error' => 'Ei oikeutta tähän yritykseen']); break; } + // IP-rajoitus yritystä vaihdettaessa (superadmin ohittaa) + if (($_SESSION['role'] ?? '') !== 'superadmin') { + $companies = dbLoadCompanies(); + foreach ($companies as $comp) { + if ($comp['id'] === $companyId) { + if (!isIpAllowed(getClientIp(), $comp['allowed_ips'] ?? '')) { + http_response_code(403); + echo json_encode(['error' => 'IP-osoitteesi ei ole sallittu tälle yritykselle.']); + break 2; + } + break; + } + } + } $_SESSION['company_id'] = $companyId; echo json_encode(['success' => true, 'company_id' => $companyId]); break; diff --git a/db.php b/db.php index 1b79d83..68e46bd 100644 --- a/db.php +++ b/db.php @@ -447,6 +447,7 @@ function initDatabase(): void { "ALTER TABLE tickets ADD COLUMN ticket_number INT DEFAULT NULL AFTER id", "ALTER TABLE mailboxes ADD COLUMN auto_reply_enabled BOOLEAN DEFAULT FALSE AFTER aktiivinen", "ALTER TABLE mailboxes ADD COLUMN auto_reply_body TEXT AFTER auto_reply_enabled", + "ALTER TABLE companies ADD COLUMN allowed_ips TEXT DEFAULT '' AFTER enabled_modules", ]; foreach ($alters as $sql) { try { $db->query($sql); } catch (\Throwable $e) { /* sarake on jo olemassa / jo ajettu */ } @@ -473,6 +474,7 @@ function dbLoadCompanies(): array { // enabled_modules: JSON-array tai tyhjä (= kaikki päällä) $raw = $c['enabled_modules'] ?? ''; $c['enabled_modules'] = $raw ? (json_decode($raw, true) ?: []) : []; + $c['allowed_ips'] = $c['allowed_ips'] ?? ''; } return $companies; } @@ -484,13 +486,13 @@ function dbSaveCompany(array $company): void { $enabledModules = $company['enabled_modules'] ?? []; $enabledModulesJson = is_array($enabledModules) ? json_encode($enabledModules) : ($enabledModules ?: ''); _dbExecute(" - INSERT INTO companies (id, nimi, luotu, aktiivinen, primary_color, subtitle, logo_file, api_key, cors_origins, enabled_modules) - VALUES (:id, :nimi, :luotu, :aktiivinen, :primary_color, :subtitle, :logo_file, :api_key, :cors_origins, :enabled_modules) + INSERT INTO companies (id, nimi, luotu, aktiivinen, primary_color, subtitle, logo_file, api_key, cors_origins, enabled_modules, allowed_ips) + VALUES (:id, :nimi, :luotu, :aktiivinen, :primary_color, :subtitle, :logo_file, :api_key, :cors_origins, :enabled_modules, :allowed_ips) ON DUPLICATE KEY UPDATE nimi = VALUES(nimi), aktiivinen = VALUES(aktiivinen), primary_color = VALUES(primary_color), subtitle = VALUES(subtitle), logo_file = VALUES(logo_file), api_key = VALUES(api_key), cors_origins = VALUES(cors_origins), - enabled_modules = VALUES(enabled_modules) + enabled_modules = VALUES(enabled_modules), allowed_ips = VALUES(allowed_ips) ", [ 'id' => $company['id'], 'nimi' => $company['nimi'], @@ -502,6 +504,7 @@ function dbSaveCompany(array $company): void { 'api_key' => $company['api_key'] ?? '', 'cors_origins' => $company['cors_origins'] ?? '', 'enabled_modules' => $enabledModulesJson, + 'allowed_ips' => $company['allowed_ips'] ?? '', ]); // Päivitä domainit diff --git a/index.html b/index.html index 6262794..e8dfd71 100644 --- a/index.html +++ b/index.html @@ -760,6 +760,11 @@ +
+ + + Yksi IP tai CIDR per rivi. Tyhjä = ei rajoitusta. Superadmin ohittaa aina. +
diff --git a/script.js b/script.js index 8fc6ea3..bed2d71 100644 --- a/script.js +++ b/script.js @@ -2302,6 +2302,9 @@ async function showCompanyDetail(id) { cb.checked = enabledMods.length === 0 ? DEFAULT_MODULES.includes(mod) : enabledMods.includes(mod); }); + // Sallitut IP-osoitteet + document.getElementById('company-edit-allowed-ips').value = comp?.allowed_ips || ''; + // Vaihda aktiivinen yritys jotta API-kutsut kohdistuvat oikein await apiCall('company_switch', 'POST', { company_id: id }); @@ -2434,12 +2437,13 @@ document.getElementById('btn-save-company-settings').addEventListener('click', a document.querySelectorAll('#modules-checkboxes input[data-module]:checked').forEach(cb => { enabled_modules.push(cb.dataset.module); }); + const allowed_ips = document.getElementById('company-edit-allowed-ips').value.trim(); try { - await apiCall('company_update', 'POST', { id: currentCompanyDetail, nimi, subtitle, primary_color, domains, enabled_modules }); + await apiCall('company_update', 'POST', { id: currentCompanyDetail, nimi, subtitle, primary_color, domains, enabled_modules, allowed_ips }); alert('Asetukset tallennettu!'); // Päivitä paikalliset tiedot const comp = companiesTabData.find(c => c.id === currentCompanyDetail); - if (comp) { comp.nimi = nimi; comp.subtitle = subtitle; comp.primary_color = primary_color; comp.domains = domains; comp.enabled_modules = enabled_modules; } + if (comp) { comp.nimi = nimi; comp.subtitle = subtitle; comp.primary_color = primary_color; comp.domains = domains; comp.enabled_modules = enabled_modules; comp.allowed_ips = allowed_ips; } const avail = availableCompanies.find(c => c.id === currentCompanyDetail); if (avail) avail.nimi = nimi; populateCompanySelector();