SESSION_IDLE_MIN * 60 || $now - ($_SESSION['started'] ?? 0) > SESSION_ABSOLUTE_HOURS * 3600) { logout_admin(); return null; } if ($now - ($_SESSION['rotated'] ?? 0) > SESSION_ROTATE_MIN * 60) { session_regenerate_id(true); $_SESSION['rotated'] = $now; } $_SESSION['seen'] = $now; return $a; } /** * Why a logged-in session is no longer valid, or null when it is: the account * may have been deactivated, its password changed, or it logged in elsewhere. */ /** The admin's current database row (read once per request). */ function admin_db_row(int $id, bool $fresh = false): ?array { static $rows = []; if ($fresh || !array_key_exists($id, $rows)) { $st = db()->prepare('SELECT * FROM admins WHERE id = ?'); $st->execute([$id]); $rows[$id] = $st->fetch() ?: null; } return $rows[$id]; } function admin_session_problem(array $a): ?string { $row = admin_db_row($a['id']); if (!$row || !(int)$row['is_active']) { return 'inactive'; } if (!hash_equals((string)($_SESSION['pwv'] ?? ''), password_fingerprint($row['password_hash']))) { return 'password'; } if (!hash_equals((string)($_SESSION['login_at'] ?? ''), (string)$row['last_login_at'])) { return 'elsewhere'; // one active session per account: the newest login wins } return null; } function require_admin(): array { send_security_headers(true); $a = current_admin(); if (!$a) { redirect(base_url('admin/login.php')); } if ($reason = admin_session_problem($a)) { logout_admin(); redirect(base_url('admin/login.php?reason=' . $reason)); } // Role and pending password change are read from the database on every // request, so a change by a super admin applies immediately. $row = admin_db_row($a['id']); $a['role'] = $row['role'] === 'super_admin' ? 'super_admin' : 'admin'; $a['must_change'] = (int)$row['must_change_password'] === 1; return $a; } function is_super_admin(array $me): bool { return ($me['role'] ?? '') === 'super_admin'; } /** * Re-authentication before managing other admins: the acting admin types * their own password. Wrong answers count like failed logins (IP lockout). */ function reauth_ok(array $me, string $password): bool { $row = admin_db_row($me['id'], true); $ok = $row && password_verify($password, $row['password_hash']); if (!$ok) { db()->prepare('INSERT INTO login_attempts (ip, username, success) VALUES (?,?,0)') ->execute([client_ip(), substr('reauth:' . $me['username'], 0, 50)]); } return $ok; } /** * The logged-in admin on a public page, or null. Never redirects, and never * starts a session for visitors who have no admin session cookie. */ function viewing_admin(): ?array { if (empty($_COOKIE[SESSION_COOKIE])) { return null; } $a = current_admin(); if ($a && admin_session_problem($a)) { logout_admin(); return null; } return $a; } function admin_count(): int { return (int)db()->query('SELECT COUNT(*) FROM admins')->fetchColumn(); } /** * True when this IP has too many failures. There is deliberately no lockout * per username (anyone could lock an admin out by guessing on purpose); the * CAPTCHA and the OTP step slow down distributed guessing instead, and the * owner is told about failed attempts at the next login. */ function login_locked(): bool { $st = db()->prepare('SELECT COUNT(*) FROM login_attempts WHERE ip = ? AND success = 0 AND created_at > (NOW() - INTERVAL ' . LOGIN_WINDOW_MIN . ' MINUTE)'); $st->execute([client_ip()]); return (int)$st->fetchColumn() >= LOGIN_MAX_FAILS; } /** Failed password/OTP attempts recorded for a username since a moment (null = last 30 days). */ function failed_attempts_since(string $username, ?string $since): int { $st = db()->prepare('SELECT COUNT(*) FROM login_attempts WHERE username IN (?, ?) AND success = 0 AND created_at > ?'); $st->execute([substr($username, 0, 50), substr('otp:' . $username, 0, 50), $since ?: date('Y-m-d H:i:s', time() - 30 * 86400)]); return (int)$st->fetchColumn(); } /** Keeps the attempts table small; called on login. */ function prune_login_attempts(): void { if (random_int(1, 50) === 1) { db()->exec('DELETE FROM login_attempts WHERE created_at < (NOW() - INTERVAL 30 DAY)'); } } /** Step 1 of the login: the admin row when username and password are right, else null. */ function verify_credentials(string $username, string $password): ?array { $st = db()->prepare('SELECT * FROM admins WHERE username = ? AND is_active = 1'); $st->execute([$username]); $row = $st->fetch(); // Verify against a dummy hash when the user does not exist to keep timing similar. static $dummy = null; if ($dummy === null) { $dummy = password_hash(bin2hex(random_bytes(8)), PASSWORD_DEFAULT); } prune_login_attempts(); $ok = password_verify($password, $row['password_hash'] ?? $dummy) && $row; db()->prepare('INSERT INTO login_attempts (ip, username, success) VALUES (?,?,?)') ->execute([client_ip(), substr($username, 0, 50), $ok ? 1 : 0]); if (!$ok) { // A burst of failures on one username is recorded for the admins (no lockout, see login_locked()). if (failed_attempts_since($username, date('Y-m-d H:i:s', time() - LOGIN_WINDOW_MIN * 60)) === LOGIN_MAX_FAILS_USER) { db()->prepare("INSERT INTO audit_log (action, summary, ip) VALUES ('login.suspicious', ?, ?)") ->execute(['Percobaan login gagal beruntun untuk username ' . substr($username, 0, 50), client_ip()]); } return null; } if (password_needs_rehash($row['password_hash'], PASSWORD_DEFAULT)) { $row['password_hash'] = password_hash($password, PASSWORD_DEFAULT); db()->prepare('UPDATE admins SET password_hash = ? WHERE id = ?')->execute([$row['password_hash'], $row['id']]); } return $row; } /** * After a correct password the admin is NOT logged in yet: the session only * remembers who passed step 1. Mode 'verify' asks for the OTP code, mode * 'enroll' sets up the authenticator first (2FA is mandatory). */ function begin_second_factor(array $row): void { start_session(); session_regenerate_id(true); $_SESSION['pre_auth'] = [ 'id' => (int)$row['id'], 'mode' => (int)$row['otp_enabled'] === 1 ? 'verify' : 'enroll', 'exp' => time() + PRE_AUTH_TTL, ]; } /** [admin row, mode] of a login waiting for its second factor, or null. */ function pending_login(): ?array { start_session(); $p = $_SESSION['pre_auth'] ?? null; if (!$p || time() > $p['exp']) { unset($_SESSION['pre_auth']); return null; } $row = admin_db_row($p['id'], true); if (!$row || !(int)$row['is_active']) { unset($_SESSION['pre_auth']); return null; } return [$row, $p['mode']]; } /** Wrong OTP codes for this username within the window. */ function otp_failures(string $username): int { $st = db()->prepare('SELECT COUNT(*) FROM login_attempts WHERE username = ? AND success = 0 AND created_at > (NOW() - INTERVAL ' . LOGIN_WINDOW_MIN . ' MINUTE)'); $st->execute([substr('otp:' . $username, 0, 50)]); return (int)$st->fetchColumn(); } function record_otp_attempt(string $username, bool $ok): void { db()->prepare('INSERT INTO login_attempts (ip, username, success) VALUES (?,?,?)') ->execute([client_ip(), substr('otp:' . $username, 0, 50), $ok ? 1 : 0]); } /** Final step: creates the admin session after password + second factor. */ function complete_login(array $row, string $method): void { unset($_SESSION['pre_auth']); $failed = failed_attempts_since($row['username'], $row['last_login_at']); // The login time doubles as the session marker: any later login on another // device changes it and ends this session (see require_admin()). $row['last_login_at'] = date('Y-m-d H:i:s'); db()->prepare('UPDATE admins SET last_login_at = ? WHERE id = ?')->execute([$row['last_login_at'], $row['id']]); login_admin($row); audit('login', 'Masuk ke halaman admin (' . $method . ')'); if ($failed > 0) { flash('info', "Ada $failed percobaan login gagal pada akun Anda sejak login terakhir. Bila itu bukan Anda, segera ganti password dan hubungi super admin."); } } function login_admin(array $row): void { start_session(); session_regenerate_id(true); $_SESSION['admin'] = ['id' => (int)$row['id'], 'username' => $row['username'], 'name' => $row['full_name']]; $_SESSION['pwv'] = password_fingerprint($row['password_hash']); $_SESSION['login_at'] = (string)$row['last_login_at']; $_SESSION['seen'] = $_SESSION['started'] = $_SESSION['rotated'] = time(); unset($_SESSION['csrf']); // fresh CSRF token for the authenticated session } function logout_admin(): void { start_session(); $_SESSION = []; if (ini_get('session.use_cookies')) { $p = session_get_cookie_params(); setcookie(session_name(), '', time() - 3600, $p['path'], $p['domain'] ?? '', (bool)$p['secure'], true); } session_destroy(); } /** Returns an error message or null. */ function validate_new_admin(string $username, string $name, string $password, string $confirm): ?string { if (!preg_match('/^[a-z0-9._-]{3,50}$/', $username)) { return 'Username 3–50 karakter: huruf kecil, angka, titik, minus, atau garis bawah.'; } if (trim($name) === '' || mb_strlen($name) > 120 || preg_match('/[[:cntrl:]<>]/u', $name)) { return 'Nama lengkap wajib diisi, maksimal 120 karakter, tanpa karakter khusus < >.'; } return validate_password($password, $confirm, $username); } function validate_password(string $password, string $confirm, string $username = ''): ?string { if (mb_strlen($password) < PASSWORD_MIN || strlen($password) > 72) { return 'Password ' . PASSWORD_MIN . '–72 karakter.'; } if (!preg_match('/[A-Za-z]/', $password) || !preg_match('/\d/', $password) || !preg_match('/[^A-Za-z\d]/', $password)) { return 'Password harus berisi huruf, angka, dan simbol.'; } if ($username !== '' && stripos($password, $username) !== false) { return 'Password tidak boleh memuat username.'; } if ($password !== $confirm) { return 'Konfirmasi password tidak sama.'; } return null; }