Files
2026-10-20 13:16:38 +09:00

307 lines
11 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}