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 @@
+ Unohdin salasanan
+
+
+
+
+
+
CuituNet Intra
+
Aseta uusi salasana
+
+
+
@@ -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);
|