383 lines
18 KiB
PHP
383 lines
18 KiB
PHP
<?php
|
|
|
|
namespace App\Controllers;
|
|
|
|
use App\Models\UserModel;
|
|
use App\Models\RoleModel;
|
|
use App\Models\AuditLogModel;
|
|
use App\Models\LoginAttemptModel;
|
|
use CodeIgniter\HTTP\RedirectResponse;
|
|
|
|
class AuthController extends BaseController
|
|
{
|
|
protected $userModel;
|
|
protected $roleModel;
|
|
protected $auditLogModel;
|
|
protected $loginAttemptModel;
|
|
protected $throttler;
|
|
|
|
/**
|
|
* Konfigurasi rate limiting - BERDASARKAN FAILED ATTEMPTS SAJA
|
|
* Environment-aware: lebih longgar di development, ketat di production
|
|
*/
|
|
protected function getRateLimitConfig(): array
|
|
{
|
|
if (ENVIRONMENT === 'production') {
|
|
return [
|
|
'soft_limit' => 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
|
|
// }
|
|
}
|
|
}
|
|
|