Initial commit - CMS Gov Bapenda Garut dengan EditorJS
This commit is contained in:
382
app/Controllers/AuthController.php
Normal file
382
app/Controllers/AuthController.php
Normal file
@@ -0,0 +1,382 @@
|
||||
<?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
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user