Add security hardening, captcha login, and password reset via email

- .htaccess: HTTPS enforcement, security headers, block sensitive files
- data/.htaccess: deny all direct access to data directory
- Secure session settings (httponly, secure, strict mode, samesite)
- Rate limiting on login (10 attempts per 15 min per IP)
- Math captcha on login form (server-side validated)
- Password reset via email with token (1 hour expiry)
- Forgot password UI with reset link flow
- Email field added to user management
- Updated .gitignore for reset_tokens.json and login_attempts.json

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-10 01:00:19 +02:00
parent e4914e9edb
commit 8a07689a1f
7 changed files with 388 additions and 8 deletions

2
.gitignore vendored
View File

@@ -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/

20
.htaccess Normal file
View File

@@ -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
<FilesMatch "^\.">
Deny from all
</FilesMatch>
# Estä pääsy server.py ja muihin ei-web-tiedostoihin
<FilesMatch "\.(py|json|md|gitignore)$">
Deny from all
</FilesMatch>

191
api.php
View File

@@ -1,20 +1,33 @@
<?php
// Turvalliset session-asetukset
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.use_strict_mode', 1);
ini_set('session.cookie_samesite', 'Strict');
session_start();
header('Content-Type: application/json');
header('X-Content-Type-Options: nosniff');
define('DATA_DIR', __DIR__ . '/data');
define('DATA_FILE', DATA_DIR . '/customers.json');
define('USERS_FILE', DATA_DIR . '/users.json');
define('CHANGELOG_FILE', DATA_DIR . '/changelog.json');
define('ARCHIVE_FILE', DATA_DIR . '/archive.json');
define('TOKENS_FILE', DATA_DIR . '/reset_tokens.json');
define('RATE_FILE', DATA_DIR . '/login_attempts.json');
define('SITE_URL', 'https://intra.cuitunet.fi');
// Sähköpostiasetukset
define('MAIL_FROM', 'sivusto@cuitunet.fi');
define('MAIL_FROM_NAME', 'CuituNet Intra');
// Varmista data-kansio ja tiedostot
if (!file_exists(DATA_DIR)) mkdir(DATA_DIR, 0755, true);
foreach ([DATA_FILE, USERS_FILE, CHANGELOG_FILE, ARCHIVE_FILE] as $f) {
foreach ([DATA_FILE, USERS_FILE, CHANGELOG_FILE, ARCHIVE_FILE, TOKENS_FILE, RATE_FILE] as $f) {
if (!file_exists($f)) file_put_contents($f, '[]');
}
// Luo oletuskäyttäjä jos users.json on tyhjä
initUsers();
$method = $_SERVER['REQUEST_METHOD'];
@@ -47,6 +60,43 @@ function generateId(): string {
return bin2hex(random_bytes(8));
}
function generateToken(): string {
return bin2hex(random_bytes(32));
}
// ==================== RATE LIMITING ====================
function checkRateLimit(string $ip): bool {
$attempts = json_decode(file_get_contents(RATE_FILE), true) ?: [];
$now = time();
// Siivoa vanhat (yli 15 min)
$attempts = array_filter($attempts, fn($a) => ($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 = '<div style="font-family:sans-serif;max-width:500px;margin:0 auto;">';
$html .= '<h2 style="color:#0f3460;">CuituNet Intra</h2>';
$html .= '<p>Hei ' . htmlspecialchars($user['nimi'] ?: $user['username']) . ',</p>';
$html .= '<p>Sait tämän viestin koska salasanan palautusta pyydettiin tilillesi.</p>';
$html .= '<p><a href="' . $resetUrl . '" style="display:inline-block;background:#0f3460;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;">Vaihda salasana</a></p>';
$html .= '<p style="color:#888;font-size:0.9em;">Linkki on voimassa 1 tunnin. Jos et pyytänyt salasanan vaihtoa, voit jättää tämän viestin huomiotta.</p>';
$html .= '</div>';
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 . '/*'));

2
data/.htaccess Normal file
View File

@@ -0,0 +1,2 @@
# Estä kaikki suora pääsy data-kansioon
Deny from all

View File

@@ -15,9 +15,38 @@
<form id="login-form">
<input type="text" id="login-username" placeholder="Käyttäjätunnus" required autofocus>
<input type="password" id="login-password" placeholder="Salasana" required>
<div class="captcha-row">
<span id="captcha-question" class="captcha-question">Ladataan...</span>
<input type="number" id="login-captcha" placeholder="Vastaus" required>
</div>
<button type="submit">Kirjaudu</button>
</form>
<div id="login-error" class="error" style="display:none"></div>
<a href="#" id="forgot-link" class="forgot-link">Unohdin salasanan</a>
</div>
<!-- Salasanan palautuspyyntö -->
<div class="login-box" id="forgot-box" style="display:none">
<h1>CuituNet Intra</h1>
<p>Salasanan palautus</p>
<form id="forgot-form">
<input type="text" id="forgot-username" placeholder="Käyttäjätunnus" required autofocus>
<button type="submit">Lähetä palautuslinkki</button>
</form>
<div id="forgot-msg" class="success-msg" style="display:none"></div>
<div id="forgot-error" class="error" style="display:none"></div>
<a href="#" id="forgot-back" class="forgot-link">Takaisin kirjautumiseen</a>
</div>
<!-- Uusi salasana (reset token) -->
<div class="login-box" id="reset-box" style="display:none">
<h1>CuituNet Intra</h1>
<p>Aseta uusi salasana</p>
<form id="reset-form">
<input type="password" id="reset-password" placeholder="Uusi salasana" required>
<input type="password" id="reset-password2" placeholder="Salasana uudelleen" required>
<button type="submit">Vaihda salasana</button>
</form>
<div id="reset-msg" class="success-msg" style="display:none"></div>
<div id="reset-error" class="error" style="display:none"></div>
</div>
</div>
@@ -180,6 +209,7 @@
<tr>
<th>Käyttäjätunnus</th>
<th>Nimi</th>
<th>Sähköposti</th>
<th>Rooli</th>
<th>Luotu</th>
<th>Toiminnot</th>
@@ -316,6 +346,10 @@
<label for="user-form-nimi">Nimi</label>
<input type="text" id="user-form-nimi">
</div>
<div class="form-group">
<label for="user-form-email">Sähköposti</label>
<input type="email" id="user-form-email" placeholder="nimi@esimerkki.fi">
</div>
<div class="form-group">
<label for="user-form-password">Salasana <span id="user-pw-hint"></span></label>
<input type="password" id="user-form-password">

101
script.js
View File

@@ -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 => `<tr>
<td><strong>${esc(u.username)}</strong></td>
<td>${esc(u.nimi)}</td>
<td>${esc(u.email || '')}</td>
<td><span class="role-badge role-${u.role}">${u.role === 'admin' ? 'Ylläpitäjä' : 'Käyttäjä'}</span></td>
<td>${esc(u.luotu)}</td>
<td class="actions-cell">
@@ -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();

View File

@@ -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);