asd
This commit is contained in:
commit
3e4a056b14
67 files changed
+13926
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user