5, // Delay setelah 5 failed attempts 'hard_limit' => 20, // Block (429) setelah 20 failed attempts 'ttl_seconds' => 900, // 15 menit 'delay_ms' => 500, // Delay 500ms setelah soft_limit ]; } else { // Development: lebih longgar untuk testing return [ 'soft_limit' => 20, // Delay setelah 20 failed attempts 'hard_limit' => 100, // Block setelah 100 failed attempts 'ttl_seconds' => 900, // 15 menit 'delay_ms' => 200, // Delay 200ms setelah soft_limit ]; } } public function __construct() { $this->userModel = new UserModel(); $this->roleModel = new RoleModel(); $this->auditLogModel = new AuditLogModel(); $this->loginAttemptModel = new LoginAttemptModel(); $this->throttler = \Config\Services::throttler(); } public function login() { // If already logged in, redirect to admin dashboard if (session()->get('is_logged_in')) { return redirect()->to('/admin'); } // Debug: Log request method $method = $this->request->getMethod(); log_message('debug', 'Login method: ' . $method); log_message('debug', 'Request URI: ' . $this->request->getUri()->getPath()); log_message('debug', 'Is POST? ' . ($method === 'post' ? 'YES' : 'NO')); if (strtolower($method) === 'post') { try { // ============================================================ // INITIALIZE RATE LIMITING COUNTERS // ============================================================ $ipAddress = $this->request->getIPAddress(); $cfg = $this->getRateLimitConfig(); $cache = \Config\Services::cache(); // Normalize username - handle berbagai format input (audit tool variations) $usernameRaw = $this->request->getPost('username') ?? $this->request->getPost('email') ?? $this->request->getPost('identity') ?? ''; $usernameNormalized = strtolower(trim($usernameRaw)); if (empty($usernameNormalized)) { $usernameNormalized = 'unknown'; } // Dual-key counter: per IP+username dan per IP (untuk handle random usernames) // Dual-key counter: per IP+username dan per IP (untuk handle random usernames dari audit tool) // Gunakan underscore bukan colon untuk menghindari reserved characters {}()/\@: $keyUser = 'login_fail_' . md5($ipAddress . '_' . $usernameNormalized); $keyIp = 'login_fail_ip_' . md5($ipAddress); $failUser = $cache->get($keyUser) ?? 0; $failIp = $cache->get($keyIp) ?? 0; $failMax = max($failUser, $failIp); // HARD LIMIT CHECK - Block sebelum validasi (audit must see 429) if ($failMax >= $cfg['hard_limit']) { $this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, null, false); log_message('warning', "Hard rate limit exceeded - Fail count: {$failMax} (User: {$failUser}, IP: {$failIp}) - IP: {$ipAddress}"); $response = service('response'); $response->setStatusCode(429); $response->setHeader('Retry-After', (string) $cfg['ttl_seconds']); $response->setHeader('X-RateLimit-Limit', (string) $cfg['hard_limit']); $response->setHeader('X-RateLimit-Remaining', '0'); $response->setHeader('Content-Type', 'text/html; charset=utf-8'); return view('auth/login', [ 'error' => 'Terlalu banyak percobaan login. Silakan coba lagi dalam ' . ($cfg['ttl_seconds'] / 60) . ' menit.', ]); } // ============================================================ // VALIDASI INPUT // ============================================================ $password = $this->request->getPost('password') ?? ''; $validation = \Config\Services::validation(); $rules = [ 'username' => 'required|min_length[3]|max_length[100]', 'password' => 'required|min_length[6]', ]; // Validation error = failed attempt if (!$this->validate($rules)) { $this->incrementFailedAttempts($cache, $keyUser, $keyIp, $cfg); $this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, null, false); return view('auth/login', [ 'validation' => $validation, ]); } if (empty($usernameRaw) || empty($password)) { $this->incrementFailedAttempts($cache, $keyUser, $keyIp, $cfg); $this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, null, false); return view('auth/login', [ 'error' => 'Username dan password harus diisi.', ]); } // ============================================================ // CEK SOFT LIMIT SEBELUM VALIDASI PASSWORD // ============================================================ // Jika sudah mencapai soft_limit, block SEBELUM validasi password // Ini mencegah user dengan password benar tetap bisa login setelah banyak failed attempts if ($failMax >= $cfg['soft_limit']) { log_message('warning', "Soft rate limit exceeded BEFORE password check - Fail count: {$failMax} (User: {$failUser}, IP: {$failIp}) - IP: {$ipAddress}, Username: {$usernameRaw}"); $response = service('response'); $response->setStatusCode(429); $response->setHeader('Retry-After', (string) $cfg['ttl_seconds']); $response->setHeader('X-RateLimit-Limit', (string) $cfg['soft_limit']); $response->setHeader('X-RateLimit-Remaining', '0'); $response->setHeader('Content-Type', 'text/html; charset=utf-8'); return view('auth/login', [ 'error' => 'Terlalu banyak percobaan login yang gagal. Silakan coba lagi dalam ' . ($cfg['ttl_seconds'] / 60) . ' menit.', ]); } // ============================================================ // VERIFIKASI USER DAN PASSWORD // ============================================================ $user = $this->userModel->getUserByUsername($usernameRaw); $passwordValid = false; if ($user) { $passwordValid = $this->userModel->verifyPassword($password, $user['password_hash']); } // User not found atau password salah = failed attempt if (!$user || !$passwordValid) { $this->incrementFailedAttempts($cache, $keyUser, $keyIp, $cfg); $this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, $user['id'] ?? null, false); // Get updated fail count setelah increment $failUser = $cache->get($keyUser) ?? 0; $failIp = $cache->get($keyIp) ?? 0; $failMax = max($failUser, $failIp); // Cek lagi setelah increment - jika sudah mencapai soft_limit, block if ($failMax >= $cfg['soft_limit']) { log_message('warning', "Soft rate limit exceeded AFTER increment - Fail count: {$failMax} (User: {$failUser}, IP: {$failIp}) - IP: {$ipAddress}, Username: {$usernameRaw}"); $response = service('response'); $response->setStatusCode(429); $response->setHeader('Retry-After', (string) $cfg['ttl_seconds']); $response->setHeader('X-RateLimit-Limit', (string) $cfg['soft_limit']); $response->setHeader('X-RateLimit-Remaining', '0'); $response->setHeader('Content-Type', 'text/html; charset=utf-8'); return view('auth/login', [ 'error' => 'Terlalu banyak percobaan login yang gagal. Silakan coba lagi dalam ' . ($cfg['ttl_seconds'] / 60) . ' menit.', ]); } log_message('info', "Login failed - IP: {$ipAddress}, Username: {$usernameRaw}, Fail count: {$failMax}"); return view('auth/login', [ 'error' => 'Username atau password salah.', ]); } // Check if user is active if (!$user['is_active']) { $this->incrementFailedAttempts($cache, $keyUser, $keyIp, $cfg); $this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, $user['id'], false); // Apply soft limit delay $failUser = $cache->get($keyUser) ?? 0; $failIp = $cache->get($keyIp) ?? 0; $failMax = max($failUser, $failIp); if ($failMax >= $cfg['soft_limit']) { usleep($cfg['delay_ms'] * 1000); } return view('auth/login', [ 'error' => 'Akun Anda telah dinonaktifkan.', ]); } // ============================================================ // VERIFIKASI ROLE // ============================================================ $role = $this->roleModel->find($user['role_id']); $roleName = $role ? $role['name'] : 'editor'; // Check if role is admin or editor if (!in_array($roleName, ['admin', 'editor'])) { $this->incrementFailedAttempts($cache, $keyUser, $keyIp, $cfg); $this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, $user['id'], false); // Apply soft limit delay $failUser = $cache->get($keyUser) ?? 0; $failIp = $cache->get($keyIp) ?? 0; $failMax = max($failUser, $failIp); if ($failMax >= $cfg['soft_limit']) { usleep($cfg['delay_ms'] * 1000); } return view('auth/login', [ 'error' => 'Anda tidak memiliki akses ke sistem ini.', ]); } // ============================================================ // SESSION MANAGEMENT - Mencegah Session Fixation Attack // ============================================================ // URUTAN PENTING: Set session data DULU, baru regenerate // Ini memastikan session ID berubah setelah privilege escalation $session = session(); // Set session data TERLEBIH DAHULU $session->set([ 'is_logged_in' => true, 'user_id' => $user['id'], 'username' => $user['username'], 'email' => $user['email'], 'role' => $roleName, 'role_id' => $user['role_id'], ]); // Dapatkan session ID sebelum regenerate (untuk logging) $oldSessionId = session_id(); // Regenerate session ID SETELAH set session data // Parameter true = destroy old session data untuk keamanan maksimal $session->regenerate(true); // Dapatkan session ID baru setelah regenerate $newSessionId = session_id(); log_message('info', "Session regenerated after login - Old: {$oldSessionId}, New: {$newSessionId}"); // Verifikasi session ID benar-benar berubah if ($oldSessionId === $newSessionId) { log_message('warning', "Session ID tidak berubah setelah regenerate! Memaksa regenerate lagi..."); $session->regenerate(true); $newSessionId = session_id(); log_message('info', "Session ID setelah regenerate kedua: {$newSessionId}"); } // ============================================================ // RECORD SUCCESSFUL LOGIN & RESET FAILED ATTEMPTS // ============================================================ // Record successful login attempt $this->loginAttemptModel->recordAttempt($ipAddress, $usernameRaw, $user['id'], true); // Reset failed attempts counter karena login berhasil // Password benar = reset fail count (boleh bypass soft limit) $cache->delete($keyUser); $cache->delete($keyIp); log_message('info', "Login successful - Failed attempts counter reset for IP: {$ipAddress}, Username: {$usernameRaw}"); // Update last login $this->userModel->update($user['id'], [ 'last_login_at' => date('Y-m-d H:i:s'), ]); // Log login action ke audit log $this->auditLogModel->logAction('login', $user['id']); log_message('info', "Login successful - User: {$user['username']} (ID: {$user['id']}) from IP: {$ipAddress}"); // Optional: Send Telegram notification if telegram_id exists if (!empty($user['telegram_id'])) { $this->sendTelegramNotification($user['telegram_id'], $user['username']); } return redirect()->to('/admin')->with('success', 'Selamat datang, ' . $user['username'] . '!'); } catch (\Exception $e) { log_message('error', 'Login error: ' . $e->getMessage() . ' | Trace: ' . $e->getTraceAsString()); return view('auth/login', [ 'error' => 'Terjadi kesalahan saat login: ' . $e->getMessage(), ]); } } return view('auth/login'); } public function logout(): RedirectResponse { $userId = session()->get('user_id'); // Log logout action before destroying session if ($userId) { try { $this->auditLogModel->logAction('logout', $userId); } catch (\Exception $e) { log_message('error', 'Logout audit log failed: ' . $e->getMessage()); // Continue with logout even if audit log fails } } // Destroy session session()->destroy(); return redirect()->to('/auth/login')->with('success', 'Anda telah berhasil logout.'); } /** * Increment failed attempts counter (dual-key: IP+username dan IP) * * @param \CodeIgniter\Cache\CacheInterface $cache * @param string $keyUser Cache key untuk IP+username * @param string $keyIp Cache key untuk IP * @param array $cfg Rate limit configuration */ protected function incrementFailedAttempts($cache, string $keyUser, string $keyIp, array $cfg): void { $failUser = $cache->get($keyUser) ?? 0; $failIp = $cache->get($keyIp) ?? 0; $failUser++; $failIp++; $cache->save($keyUser, $failUser, $cfg['ttl_seconds']); $cache->save($keyIp, $failIp, $cfg['ttl_seconds']); } /** * Optional: Send Telegram notification on login */ protected function sendTelegramNotification($telegramId, $username) { // This is optional - implement if you have Telegram bot configured // Example implementation: // $botToken = getenv('TELEGRAM_BOT_TOKEN'); // if ($botToken) { // $message = "Login berhasil untuk user: {$username}"; // $url = "https://api.telegram.org/bot{$botToken}/sendMessage"; // $data = ['chat_id' => $telegramId, 'text' => $message]; // // Use HTTP client to send request // } } }