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:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,5 +2,7 @@ data/customers.json
|
|||||||
data/users.json
|
data/users.json
|
||||||
data/changelog.json
|
data/changelog.json
|
||||||
data/archive.json
|
data/archive.json
|
||||||
|
data/reset_tokens.json
|
||||||
|
data/login_attempts.json
|
||||||
data/backups/
|
data/backups/
|
||||||
data/files/
|
data/files/
|
||||||
|
|||||||
20
.htaccess
Normal file
20
.htaccess
Normal 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
191
api.php
@@ -1,20 +1,33 @@
|
|||||||
<?php
|
<?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();
|
session_start();
|
||||||
|
|
||||||
header('Content-Type: application/json');
|
header('Content-Type: application/json');
|
||||||
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
||||||
define('DATA_DIR', __DIR__ . '/data');
|
define('DATA_DIR', __DIR__ . '/data');
|
||||||
define('DATA_FILE', DATA_DIR . '/customers.json');
|
define('DATA_FILE', DATA_DIR . '/customers.json');
|
||||||
define('USERS_FILE', DATA_DIR . '/users.json');
|
define('USERS_FILE', DATA_DIR . '/users.json');
|
||||||
define('CHANGELOG_FILE', DATA_DIR . '/changelog.json');
|
define('CHANGELOG_FILE', DATA_DIR . '/changelog.json');
|
||||||
define('ARCHIVE_FILE', DATA_DIR . '/archive.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
|
// Varmista data-kansio ja tiedostot
|
||||||
if (!file_exists(DATA_DIR)) mkdir(DATA_DIR, 0755, true);
|
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, '[]');
|
if (!file_exists($f)) file_put_contents($f, '[]');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Luo oletuskäyttäjä jos users.json on tyhjä
|
|
||||||
initUsers();
|
initUsers();
|
||||||
|
|
||||||
$method = $_SERVER['REQUEST_METHOD'];
|
$method = $_SERVER['REQUEST_METHOD'];
|
||||||
@@ -47,6 +60,43 @@ function generateId(): string {
|
|||||||
return bin2hex(random_bytes(8));
|
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 ====================
|
// ==================== USERS ====================
|
||||||
|
|
||||||
function initUsers(): void {
|
function initUsers(): void {
|
||||||
@@ -57,6 +107,7 @@ function initUsers(): void {
|
|||||||
'username' => 'admin',
|
'username' => 'admin',
|
||||||
'password_hash' => password_hash('cuitunet2024', PASSWORD_DEFAULT),
|
'password_hash' => password_hash('cuitunet2024', PASSWORD_DEFAULT),
|
||||||
'nimi' => 'Ylläpitäjä',
|
'nimi' => 'Ylläpitäjä',
|
||||||
|
'email' => '',
|
||||||
'role' => 'admin',
|
'role' => 'admin',
|
||||||
'luotu' => date('Y-m-d H:i:s'),
|
'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));
|
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 ====================
|
// ==================== CHANGELOG ====================
|
||||||
|
|
||||||
function addLog(string $action, string $customerId = '', string $customerName = '', string $details = ''): void {
|
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,
|
'customer_name' => $customerName,
|
||||||
'details' => $details,
|
'details' => $details,
|
||||||
]);
|
]);
|
||||||
// Säilytä max 500 lokimerkintää
|
|
||||||
if (count($log) > 500) $log = array_slice($log, 0, 500);
|
if (count($log) > 500) $log = array_slice($log, 0, 500);
|
||||||
file_put_contents(CHANGELOG_FILE, json_encode($log, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
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));
|
file_put_contents(DATA_FILE, json_encode($customers, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== ARCHIVE ====================
|
|
||||||
|
|
||||||
function loadArchive(): array {
|
function loadArchive(): array {
|
||||||
return json_decode(file_get_contents(ARCHIVE_FILE), true) ?: [];
|
return json_decode(file_get_contents(ARCHIVE_FILE), true) ?: [];
|
||||||
}
|
}
|
||||||
@@ -165,16 +246,41 @@ function parseLiittymat(array $input): array {
|
|||||||
|
|
||||||
switch ($action) {
|
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 ----------
|
// ---------- AUTH ----------
|
||||||
case 'login':
|
case 'login':
|
||||||
if ($method !== 'POST') break;
|
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);
|
$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'] ?? '');
|
$username = trim($input['username'] ?? '');
|
||||||
$password = $input['password'] ?? '';
|
$password = $input['password'] ?? '';
|
||||||
$users = loadUsers();
|
$users = loadUsers();
|
||||||
$found = false;
|
$found = false;
|
||||||
foreach ($users as $u) {
|
foreach ($users as $u) {
|
||||||
if ($u['username'] === $username && password_verify($password, $u['password_hash'])) {
|
if ($u['username'] === $username && password_verify($password, $u['password_hash'])) {
|
||||||
|
session_regenerate_id(true);
|
||||||
$_SESSION['user_id'] = $u['id'];
|
$_SESSION['user_id'] = $u['id'];
|
||||||
$_SESSION['username'] = $u['username'];
|
$_SESSION['username'] = $u['username'];
|
||||||
$_SESSION['nimi'] = $u['nimi'];
|
$_SESSION['nimi'] = $u['nimi'];
|
||||||
@@ -185,6 +291,7 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!$found) {
|
if (!$found) {
|
||||||
|
recordLoginAttempt($ip);
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
echo json_encode(['error' => 'Väärä käyttäjätunnus tai salasana']);
|
echo json_encode(['error' => 'Väärä käyttäjätunnus tai salasana']);
|
||||||
}
|
}
|
||||||
@@ -208,11 +315,79 @@ switch ($action) {
|
|||||||
}
|
}
|
||||||
break;
|
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 ----------
|
// ---------- USERS ----------
|
||||||
case 'users':
|
case 'users':
|
||||||
requireAdmin();
|
requireAdmin();
|
||||||
$users = loadUsers();
|
$users = loadUsers();
|
||||||
// Älä palauta salasanoja
|
|
||||||
$safe = array_map(function($u) {
|
$safe = array_map(function($u) {
|
||||||
unset($u['password_hash']);
|
unset($u['password_hash']);
|
||||||
return $u;
|
return $u;
|
||||||
@@ -227,6 +402,7 @@ switch ($action) {
|
|||||||
$username = trim($input['username'] ?? '');
|
$username = trim($input['username'] ?? '');
|
||||||
$password = $input['password'] ?? '';
|
$password = $input['password'] ?? '';
|
||||||
$nimi = trim($input['nimi'] ?? '');
|
$nimi = trim($input['nimi'] ?? '');
|
||||||
|
$email = trim($input['email'] ?? '');
|
||||||
$role = ($input['role'] ?? 'user') === 'admin' ? 'admin' : 'user';
|
$role = ($input['role'] ?? 'user') === 'admin' ? 'admin' : 'user';
|
||||||
if (empty($username) || empty($password)) {
|
if (empty($username) || empty($password)) {
|
||||||
http_response_code(400);
|
http_response_code(400);
|
||||||
@@ -251,6 +427,7 @@ switch ($action) {
|
|||||||
'username' => $username,
|
'username' => $username,
|
||||||
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
|
||||||
'nimi' => $nimi ?: $username,
|
'nimi' => $nimi ?: $username,
|
||||||
|
'email' => $email,
|
||||||
'role' => $role,
|
'role' => $role,
|
||||||
'luotu' => date('Y-m-d H:i:s'),
|
'luotu' => date('Y-m-d H:i:s'),
|
||||||
];
|
];
|
||||||
@@ -271,6 +448,7 @@ switch ($action) {
|
|||||||
foreach ($users as &$u) {
|
foreach ($users as &$u) {
|
||||||
if ($u['id'] === $id) {
|
if ($u['id'] === $id) {
|
||||||
if (isset($input['nimi'])) $u['nimi'] = trim($input['nimi']);
|
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 (isset($input['role'])) $u['role'] = $input['role'] === 'admin' ? 'admin' : 'user';
|
||||||
if (!empty($input['password'])) {
|
if (!empty($input['password'])) {
|
||||||
$u['password_hash'] = password_hash($input['password'], PASSWORD_DEFAULT);
|
$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));
|
$archive = array_values(array_filter($archive, fn($c) => $c['id'] !== $id));
|
||||||
saveArchive($archive);
|
saveArchive($archive);
|
||||||
// Poista tiedostot
|
|
||||||
$filesDir = DATA_DIR . '/files/' . $id;
|
$filesDir = DATA_DIR . '/files/' . $id;
|
||||||
if (is_dir($filesDir)) {
|
if (is_dir($filesDir)) {
|
||||||
array_map('unlink', glob($filesDir . '/*'));
|
array_map('unlink', glob($filesDir . '/*'));
|
||||||
|
|||||||
2
data/.htaccess
Normal file
2
data/.htaccess
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Estä kaikki suora pääsy data-kansioon
|
||||||
|
Deny from all
|
||||||
34
index.html
34
index.html
@@ -15,9 +15,38 @@
|
|||||||
<form id="login-form">
|
<form id="login-form">
|
||||||
<input type="text" id="login-username" placeholder="Käyttäjätunnus" required autofocus>
|
<input type="text" id="login-username" placeholder="Käyttäjätunnus" required autofocus>
|
||||||
<input type="password" id="login-password" placeholder="Salasana" required>
|
<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>
|
<button type="submit">Kirjaudu</button>
|
||||||
</form>
|
</form>
|
||||||
<div id="login-error" class="error" style="display:none"></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -180,6 +209,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Käyttäjätunnus</th>
|
<th>Käyttäjätunnus</th>
|
||||||
<th>Nimi</th>
|
<th>Nimi</th>
|
||||||
|
<th>Sähköposti</th>
|
||||||
<th>Rooli</th>
|
<th>Rooli</th>
|
||||||
<th>Luotu</th>
|
<th>Luotu</th>
|
||||||
<th>Toiminnot</th>
|
<th>Toiminnot</th>
|
||||||
@@ -316,6 +346,10 @@
|
|||||||
<label for="user-form-nimi">Nimi</label>
|
<label for="user-form-nimi">Nimi</label>
|
||||||
<input type="text" id="user-form-nimi">
|
<input type="text" id="user-form-nimi">
|
||||||
</div>
|
</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">
|
<div class="form-group">
|
||||||
<label for="user-form-password">Salasana <span id="user-pw-hint"></span></label>
|
<label for="user-form-password">Salasana <span id="user-pw-hint"></span></label>
|
||||||
<input type="password" id="user-form-password">
|
<input type="password" id="user-form-password">
|
||||||
|
|||||||
101
script.js
101
script.js
@@ -35,7 +35,96 @@ async function apiCall(action, method = 'GET', body = null) {
|
|||||||
|
|
||||||
// ==================== AUTH ====================
|
// ==================== 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() {
|
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 {
|
try {
|
||||||
const data = await apiCall('check_auth');
|
const data = await apiCall('check_auth');
|
||||||
if (data.authenticated) {
|
if (data.authenticated) {
|
||||||
@@ -49,14 +138,17 @@ loginForm.addEventListener('submit', async (e) => {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const username = document.getElementById('login-username').value;
|
const username = document.getElementById('login-username').value;
|
||||||
const password = document.getElementById('login-password').value;
|
const password = document.getElementById('login-password').value;
|
||||||
|
const captcha = document.getElementById('login-captcha').value;
|
||||||
try {
|
try {
|
||||||
const data = await apiCall('login', 'POST', { username, password });
|
const data = await apiCall('login', 'POST', { username, password, captcha: parseInt(captcha) });
|
||||||
loginError.style.display = 'none';
|
loginError.style.display = 'none';
|
||||||
currentUser = { username: data.username, nimi: data.nimi, role: data.role };
|
currentUser = { username: data.username, nimi: data.nimi, role: data.role };
|
||||||
showDashboard();
|
showDashboard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
loginError.textContent = err.message;
|
loginError.textContent = err.message;
|
||||||
loginError.style.display = 'block';
|
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';
|
loginScreen.style.display = 'flex';
|
||||||
document.getElementById('login-username').value = '';
|
document.getElementById('login-username').value = '';
|
||||||
document.getElementById('login-password').value = '';
|
document.getElementById('login-password').value = '';
|
||||||
|
document.getElementById('login-captcha').value = '';
|
||||||
|
showLoginView();
|
||||||
|
loadCaptcha();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function showDashboard() {
|
async function showDashboard() {
|
||||||
@@ -595,6 +690,7 @@ async function loadUsers() {
|
|||||||
utbody.innerHTML = users.map(u => `<tr>
|
utbody.innerHTML = users.map(u => `<tr>
|
||||||
<td><strong>${esc(u.username)}</strong></td>
|
<td><strong>${esc(u.username)}</strong></td>
|
||||||
<td>${esc(u.nimi)}</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><span class="role-badge role-${u.role}">${u.role === 'admin' ? 'Ylläpitäjä' : 'Käyttäjä'}</span></td>
|
||||||
<td>${esc(u.luotu)}</td>
|
<td>${esc(u.luotu)}</td>
|
||||||
<td class="actions-cell">
|
<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').value = user ? user.username : '';
|
||||||
document.getElementById('user-form-username').disabled = !!user;
|
document.getElementById('user-form-username').disabled = !!user;
|
||||||
document.getElementById('user-form-nimi').value = user ? user.nimi : '';
|
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-form-password').value = '';
|
||||||
document.getElementById('user-pw-hint').textContent = user ? '(jätä tyhjäksi jos ei muuteta)' : '*';
|
document.getElementById('user-pw-hint').textContent = user ? '(jätä tyhjäksi jos ei muuteta)' : '*';
|
||||||
document.getElementById('user-form-role').value = user ? user.role : 'user';
|
document.getElementById('user-form-role').value = user ? user.role : 'user';
|
||||||
@@ -644,6 +741,7 @@ document.getElementById('user-form').addEventListener('submit', async (e) => {
|
|||||||
const data = {
|
const data = {
|
||||||
username: document.getElementById('user-form-username').value,
|
username: document.getElementById('user-form-username').value,
|
||||||
nimi: document.getElementById('user-form-nimi').value,
|
nimi: document.getElementById('user-form-nimi').value,
|
||||||
|
email: document.getElementById('user-form-email').value,
|
||||||
role: document.getElementById('user-form-role').value,
|
role: document.getElementById('user-form-role').value,
|
||||||
};
|
};
|
||||||
const pw = document.getElementById('user-form-password').value;
|
const pw = document.getElementById('user-form-password').value;
|
||||||
@@ -671,4 +769,5 @@ document.addEventListener('keydown', (e) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Init
|
// Init
|
||||||
|
loadCaptcha();
|
||||||
checkAuth();
|
checkAuth();
|
||||||
|
|||||||
46
style.css
46
style.css
@@ -79,6 +79,52 @@ body {
|
|||||||
font-size: 0.9rem;
|
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 */
|
||||||
header {
|
header {
|
||||||
background: linear-gradient(135deg, #0f3460, #16213e);
|
background: linear-gradient(135deg, #0f3460, #16213e);
|
||||||
|
|||||||
Reference in New Issue
Block a user