diff --git a/.gitignore b/.gitignore index 67812ed..5b680b3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,7 @@ data/customers.json data/users.json data/changelog.json data/archive.json +data/reset_tokens.json +data/login_attempts.json data/backups/ data/files/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..ae052a3 --- /dev/null +++ b/.htaccess @@ -0,0 +1,20 @@ +# HTTPS-pakotus +RewriteEngine On +RewriteCond %{HTTPS} off +RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + +# Tietoturvaotsikot +Header always set X-Content-Type-Options "nosniff" +Header always set X-Frame-Options "DENY" +Header always set X-XSS-Protection "1; mode=block" +Header always set Referrer-Policy "strict-origin-when-cross-origin" + +# Estä pääsy piilotiedostoihin + + Deny from all + + +# Estä pääsy server.py ja muihin ei-web-tiedostoihin + + Deny from all + diff --git a/api.php b/api.php index 1330c0a..2417ebd 100644 --- a/api.php +++ b/api.php @@ -1,20 +1,33 @@ ($now - $a['time']) < 900); + file_put_contents(RATE_FILE, json_encode(array_values($attempts))); + // Laske tämän IP:n yritykset viimeisen 15 min aikana + $ipAttempts = array_filter($attempts, fn($a) => $a['ip'] === $ip); + return count($ipAttempts) < 10; // Max 10 yritystä / 15 min +} + +function recordLoginAttempt(string $ip): void { + $attempts = json_decode(file_get_contents(RATE_FILE), true) ?: []; + $attempts[] = ['ip' => $ip, 'time' => time()]; + file_put_contents(RATE_FILE, json_encode($attempts)); +} + +function getClientIp(): string { + return $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; +} + +// ==================== EMAIL ==================== + +function sendMail(string $to, string $subject, string $htmlBody): bool { + $headers = "MIME-Version: 1.0\r\n"; + $headers .= "Content-Type: text/html; charset=UTF-8\r\n"; + $headers .= "From: " . MAIL_FROM_NAME . " <" . MAIL_FROM . ">\r\n"; + $headers .= "Reply-To: " . MAIL_FROM . "\r\n"; + return mail($to, $subject, $htmlBody, $headers, '-f ' . MAIL_FROM); +} + // ==================== USERS ==================== function initUsers(): void { @@ -57,6 +107,7 @@ function initUsers(): void { 'username' => 'admin', 'password_hash' => password_hash('cuitunet2024', PASSWORD_DEFAULT), 'nimi' => 'Ylläpitäjä', + 'email' => '', 'role' => 'admin', 'luotu' => date('Y-m-d H:i:s'), ]; @@ -72,6 +123,39 @@ function saveUsers(array $users): void { file_put_contents(USERS_FILE, json_encode($users, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } +// ==================== RESET TOKENS ==================== + +function saveToken(string $userId, string $token): void { + $tokens = json_decode(file_get_contents(TOKENS_FILE), true) ?: []; + // Poista vanhat tokenit tälle käyttäjälle + $tokens = array_filter($tokens, fn($t) => $t['user_id'] !== $userId); + $tokens[] = [ + 'user_id' => $userId, + 'token' => hash('sha256', $token), + 'expires' => time() + 3600, // 1 tunti + ]; + file_put_contents(TOKENS_FILE, json_encode(array_values($tokens))); +} + +function validateToken(string $token): ?string { + $tokens = json_decode(file_get_contents(TOKENS_FILE), true) ?: []; + $hashed = hash('sha256', $token); + $now = time(); + foreach ($tokens as $t) { + if ($t['token'] === $hashed && $t['expires'] > $now) { + return $t['user_id']; + } + } + return null; +} + +function removeToken(string $token): void { + $tokens = json_decode(file_get_contents(TOKENS_FILE), true) ?: []; + $hashed = hash('sha256', $token); + $tokens = array_filter($tokens, fn($t) => $t['token'] !== $hashed); + file_put_contents(TOKENS_FILE, json_encode(array_values($tokens))); +} + // ==================== CHANGELOG ==================== function addLog(string $action, string $customerId = '', string $customerName = '', string $details = ''): void { @@ -85,7 +169,6 @@ function addLog(string $action, string $customerId = '', string $customerName = 'customer_name' => $customerName, 'details' => $details, ]); - // Säilytä max 500 lokimerkintää if (count($log) > 500) $log = array_slice($log, 0, 500); file_put_contents(CHANGELOG_FILE, json_encode($log, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } @@ -132,8 +215,6 @@ function saveCustomers(array $customers): void { file_put_contents(DATA_FILE, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)); } -// ==================== ARCHIVE ==================== - function loadArchive(): array { return json_decode(file_get_contents(ARCHIVE_FILE), true) ?: []; } @@ -165,16 +246,41 @@ function parseLiittymat(array $input): array { switch ($action) { + // ---------- CAPTCHA ---------- + case 'captcha': + $a = rand(1, 20); + $b = rand(1, 20); + $_SESSION['captcha_answer'] = $a + $b; + echo json_encode(['question' => "$a + $b = ?"]); + break; + // ---------- AUTH ---------- case 'login': if ($method !== 'POST') break; + $ip = getClientIp(); + if (!checkRateLimit($ip)) { + http_response_code(429); + echo json_encode(['error' => 'Liian monta kirjautumisyritystä. Yritä uudelleen 15 minuutin kuluttua.']); + break; + } $input = json_decode(file_get_contents('php://input'), true); + // Captcha-tarkistus + $captchaAnswer = intval($input['captcha'] ?? 0); + if (!isset($_SESSION['captcha_answer']) || $captchaAnswer !== $_SESSION['captcha_answer']) { + recordLoginAttempt($ip); + http_response_code(400); + echo json_encode(['error' => 'Virheellinen captcha-vastaus']); + unset($_SESSION['captcha_answer']); + break; + } + unset($_SESSION['captcha_answer']); $username = trim($input['username'] ?? ''); $password = $input['password'] ?? ''; $users = loadUsers(); $found = false; foreach ($users as $u) { if ($u['username'] === $username && password_verify($password, $u['password_hash'])) { + session_regenerate_id(true); $_SESSION['user_id'] = $u['id']; $_SESSION['username'] = $u['username']; $_SESSION['nimi'] = $u['nimi']; @@ -185,6 +291,7 @@ switch ($action) { } } if (!$found) { + recordLoginAttempt($ip); http_response_code(401); echo json_encode(['error' => 'Väärä käyttäjätunnus tai salasana']); } @@ -208,11 +315,79 @@ switch ($action) { } break; + // ---------- PASSWORD RESET ---------- + case 'password_reset_request': + if ($method !== 'POST') break; + $ip = getClientIp(); + if (!checkRateLimit($ip)) { + http_response_code(429); + echo json_encode(['error' => 'Liian monta yritystä. Yritä uudelleen myöhemmin.']); + break; + } + recordLoginAttempt($ip); + $input = json_decode(file_get_contents('php://input'), true); + $username = trim($input['username'] ?? ''); + $users = loadUsers(); + $user = null; + foreach ($users as $u) { + if ($u['username'] === $username) { $user = $u; break; } + } + // Palauta aina sama viesti (ei paljasta onko tunnus olemassa) + if ($user && !empty($user['email'])) { + $token = generateToken(); + saveToken($user['id'], $token); + $resetUrl = SITE_URL . '/?reset=' . $token; + $html = '
'; + $html .= '

CuituNet Intra

'; + $html .= '

Hei ' . htmlspecialchars($user['nimi'] ?: $user['username']) . ',

'; + $html .= '

Sait tämän viestin koska salasanan palautusta pyydettiin tilillesi.

'; + $html .= '

Vaihda salasana

'; + $html .= '

Linkki on voimassa 1 tunnin. Jos et pyytänyt salasanan vaihtoa, voit jättää tämän viestin huomiotta.

'; + $html .= '
'; + sendMail($user['email'], 'Salasanan palautus - CuituNet Intra', $html); + } + echo json_encode(['success' => true, 'message' => 'Jos käyttäjätunnus löytyy ja sillä on sähköposti, palautuslinkki lähetetään.']); + break; + + case 'password_reset': + if ($method !== 'POST') break; + $input = json_decode(file_get_contents('php://input'), true); + $token = $input['token'] ?? ''; + $newPassword = $input['password'] ?? ''; + if (strlen($newPassword) < 4) { + http_response_code(400); + echo json_encode(['error' => 'Salasanan pitää olla vähintään 4 merkkiä']); + break; + } + $userId = validateToken($token); + if (!$userId) { + http_response_code(400); + echo json_encode(['error' => 'Palautuslinkki on vanhentunut tai virheellinen']); + break; + } + $users = loadUsers(); + foreach ($users as &$u) { + if ($u['id'] === $userId) { + $u['password_hash'] = password_hash($newPassword, PASSWORD_DEFAULT); + break; + } + } + unset($u); + saveUsers($users); + removeToken($token); + echo json_encode(['success' => true, 'message' => 'Salasana vaihdettu onnistuneesti']); + break; + + case 'validate_reset_token': + $token = $_GET['token'] ?? ''; + $userId = validateToken($token); + echo json_encode(['valid' => $userId !== null]); + break; + // ---------- USERS ---------- case 'users': requireAdmin(); $users = loadUsers(); - // Älä palauta salasanoja $safe = array_map(function($u) { unset($u['password_hash']); return $u; @@ -227,6 +402,7 @@ switch ($action) { $username = trim($input['username'] ?? ''); $password = $input['password'] ?? ''; $nimi = trim($input['nimi'] ?? ''); + $email = trim($input['email'] ?? ''); $role = ($input['role'] ?? 'user') === 'admin' ? 'admin' : 'user'; if (empty($username) || empty($password)) { http_response_code(400); @@ -251,6 +427,7 @@ switch ($action) { 'username' => $username, 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'nimi' => $nimi ?: $username, + 'email' => $email, 'role' => $role, 'luotu' => date('Y-m-d H:i:s'), ]; @@ -271,6 +448,7 @@ switch ($action) { foreach ($users as &$u) { if ($u['id'] === $id) { if (isset($input['nimi'])) $u['nimi'] = trim($input['nimi']); + if (isset($input['email'])) $u['email'] = trim($input['email']); if (isset($input['role'])) $u['role'] = $input['role'] === 'admin' ? 'admin' : 'user'; if (!empty($input['password'])) { $u['password_hash'] = password_hash($input['password'], PASSWORD_DEFAULT); @@ -473,7 +651,6 @@ switch ($action) { } $archive = array_values(array_filter($archive, fn($c) => $c['id'] !== $id)); saveArchive($archive); - // Poista tiedostot $filesDir = DATA_DIR . '/files/' . $id; if (is_dir($filesDir)) { array_map('unlink', glob($filesDir . '/*')); diff --git a/data/.htaccess b/data/.htaccess new file mode 100644 index 0000000..5cb2de6 --- /dev/null +++ b/data/.htaccess @@ -0,0 +1,2 @@ +# Estä kaikki suora pääsy data-kansioon +Deny from all diff --git a/index.html b/index.html index 7f8d8e3..05e67f7 100644 --- a/index.html +++ b/index.html @@ -15,9 +15,38 @@
+
+ Ladataan... + +
+ Unohdin salasanan + + + + + @@ -180,6 +209,7 @@ Käyttäjätunnus Nimi + Sähköposti Rooli Luotu Toiminnot @@ -316,6 +346,10 @@ +
+ + +
diff --git a/script.js b/script.js index 5d14a8e..deda62f 100644 --- a/script.js +++ b/script.js @@ -35,7 +35,96 @@ async function apiCall(action, method = 'GET', body = null) { // ==================== AUTH ==================== +const forgotBox = document.getElementById('forgot-box'); +const resetBox = document.getElementById('reset-box'); +const loginBox = document.querySelector('.login-box'); + +function showLoginView() { + loginBox.style.display = ''; + forgotBox.style.display = 'none'; + resetBox.style.display = 'none'; +} + +function showForgotView() { + loginBox.style.display = 'none'; + forgotBox.style.display = ''; + resetBox.style.display = 'none'; +} + +function showResetView() { + loginBox.style.display = 'none'; + forgotBox.style.display = 'none'; + resetBox.style.display = ''; +} + +document.getElementById('forgot-link').addEventListener('click', (e) => { e.preventDefault(); showForgotView(); }); +document.getElementById('forgot-back').addEventListener('click', (e) => { e.preventDefault(); showLoginView(); }); + +async function loadCaptcha() { + try { + const data = await apiCall('captcha'); + document.getElementById('captcha-question').textContent = data.question; + } catch (e) { + document.getElementById('captcha-question').textContent = 'Virhe'; + } +} + +// Salasanan palautuspyyntö +document.getElementById('forgot-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const username = document.getElementById('forgot-username').value; + const forgotMsg = document.getElementById('forgot-msg'); + const forgotError = document.getElementById('forgot-error'); + forgotMsg.style.display = 'none'; + forgotError.style.display = 'none'; + try { + await apiCall('password_reset_request', 'POST', { username }); + forgotMsg.textContent = 'Jos käyttäjätunnukselle on sähköposti, palautuslinkki on lähetetty.'; + forgotMsg.style.display = 'block'; + } catch (err) { + forgotError.textContent = err.message; + forgotError.style.display = 'block'; + } +}); + +// Salasanan vaihto (reset token) +document.getElementById('reset-form').addEventListener('submit', async (e) => { + e.preventDefault(); + const pw1 = document.getElementById('reset-password').value; + const pw2 = document.getElementById('reset-password2').value; + const resetMsg = document.getElementById('reset-msg'); + const resetError = document.getElementById('reset-error'); + resetMsg.style.display = 'none'; + resetError.style.display = 'none'; + if (pw1 !== pw2) { resetError.textContent = 'Salasanat eivät täsmää'; resetError.style.display = 'block'; return; } + const params = new URLSearchParams(window.location.search); + const token = params.get('reset'); + try { + await apiCall('password_reset', 'POST', { token, password: pw1 }); + resetMsg.textContent = 'Salasana vaihdettu! Voit nyt kirjautua.'; + resetMsg.style.display = 'block'; + document.getElementById('reset-form').style.display = 'none'; + setTimeout(() => { window.location.href = window.location.pathname; }, 3000); + } catch (err) { + resetError.textContent = err.message; + resetError.style.display = 'block'; + } +}); + async function checkAuth() { + // Tarkista onko URL:ssa reset-token + const params = new URLSearchParams(window.location.search); + if (params.get('reset')) { + try { + const data = await apiCall('validate_reset_token&token=' + encodeURIComponent(params.get('reset'))); + if (data.valid) { showResetView(); return; } + } catch (e) {} + showResetView(); + document.getElementById('reset-error').textContent = 'Palautuslinkki on vanhentunut tai virheellinen'; + document.getElementById('reset-error').style.display = 'block'; + document.getElementById('reset-form').style.display = 'none'; + return; + } try { const data = await apiCall('check_auth'); if (data.authenticated) { @@ -49,14 +138,17 @@ loginForm.addEventListener('submit', async (e) => { e.preventDefault(); const username = document.getElementById('login-username').value; const password = document.getElementById('login-password').value; + const captcha = document.getElementById('login-captcha').value; try { - const data = await apiCall('login', 'POST', { username, password }); + const data = await apiCall('login', 'POST', { username, password, captcha: parseInt(captcha) }); loginError.style.display = 'none'; currentUser = { username: data.username, nimi: data.nimi, role: data.role }; showDashboard(); } catch (err) { loginError.textContent = err.message; loginError.style.display = 'block'; + document.getElementById('login-captcha').value = ''; + loadCaptcha(); } }); @@ -66,6 +158,9 @@ document.getElementById('btn-logout').addEventListener('click', async () => { loginScreen.style.display = 'flex'; document.getElementById('login-username').value = ''; document.getElementById('login-password').value = ''; + document.getElementById('login-captcha').value = ''; + showLoginView(); + loadCaptcha(); }); async function showDashboard() { @@ -595,6 +690,7 @@ async function loadUsers() { utbody.innerHTML = users.map(u => ` ${esc(u.username)} ${esc(u.nimi)} + ${esc(u.email || '')} ${u.role === 'admin' ? 'Ylläpitäjä' : 'Käyttäjä'} ${esc(u.luotu)} @@ -616,6 +712,7 @@ function openUserForm(user = null) { document.getElementById('user-form-username').value = user ? user.username : ''; document.getElementById('user-form-username').disabled = !!user; document.getElementById('user-form-nimi').value = user ? user.nimi : ''; + document.getElementById('user-form-email').value = user ? (user.email || '') : ''; document.getElementById('user-form-password').value = ''; document.getElementById('user-pw-hint').textContent = user ? '(jätä tyhjäksi jos ei muuteta)' : '*'; document.getElementById('user-form-role').value = user ? user.role : 'user'; @@ -644,6 +741,7 @@ document.getElementById('user-form').addEventListener('submit', async (e) => { const data = { username: document.getElementById('user-form-username').value, nimi: document.getElementById('user-form-nimi').value, + email: document.getElementById('user-form-email').value, role: document.getElementById('user-form-role').value, }; const pw = document.getElementById('user-form-password').value; @@ -671,4 +769,5 @@ document.addEventListener('keydown', (e) => { }); // Init +loadCaptcha(); checkAuth(); diff --git a/style.css b/style.css index eea1396..3f0d8b2 100644 --- a/style.css +++ b/style.css @@ -79,6 +79,52 @@ body { font-size: 0.9rem; } +.success-msg { + color: #2ecc71; + margin-top: 1rem; + font-size: 0.9rem; +} + +.forgot-link { + display: inline-block; + margin-top: 1rem; + color: #0f3460; + font-size: 0.85rem; + text-decoration: none; + opacity: 0.7; + transition: opacity 0.2s; +} + +.forgot-link:hover { + opacity: 1; + text-decoration: underline; +} + +.captcha-row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.captcha-question { + font-size: 1.1rem; + font-weight: 700; + color: #0f3460; + white-space: nowrap; + min-width: 120px; + text-align: center; + background: #f0f2f5; + padding: 10px 14px; + border-radius: 8px; + letter-spacing: 1px; +} + +.captcha-row input { + flex: 1; + margin-bottom: 0 !important; +} + /* Header */ header { background: linear-gradient(135deg, #0f3460, #16213e);