This commit is contained in:
DIGIDEV\corer committed 2026-10-20 13:16:38 +09:00
commit 3e4a056b14
67 files changed
+13926

No files matched your search

+306
View File
@@ -0,0 +1,306 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/helpers.php';
require_once __DIR__ . '/totp.php';
const LOGIN_MAX_FAILS = 5; // per IP within the window: the IP is locked out
const LOGIN_MAX_FAILS_USER = 10; // per username within the window: audited + shown to the owner (no lockout)
const LOGIN_WINDOW_MIN = 15;
const PRE_AUTH_TTL = 300; // seconds between a correct password and the OTP step
const SESSION_IDLE_MIN = 30;
const SESSION_ABSOLUTE_HOURS = 8;
const SESSION_ROTATE_MIN = 15;
const PASSWORD_MIN = 10;
/** Short fingerprint of the password hash; a password change invalidates every other session. */
function password_fingerprint(string $hash): string
{
return substr(hash('sha256', $hash), 0, 16);
}
function current_admin(): ?array
{
start_session();
$a = $_SESSION['admin'] ?? null;
if (!$a) {
return null;
}
$now = time();
if ($now - ($_SESSION['seen'] ?? 0) > 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;
}