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:
191
api.php
191
api.php
@@ -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 . '/*'));
|
||||
|
||||
Reference in New Issue
Block a user