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

+498
View File
@@ -0,0 +1,498 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/auth.php';
/*
* POST handlers for the admin page. Each returns null on success (after a
* flash + redirect) or an array ['errors' => [...], 'draft' => [...]] so the
* form can be re-rendered with what the admin typed.
*/
const MAX_TIER_ROWS = 30;
const MAX_NOMINAL = 1e15; // Rp1.000 triliun; also keeps values inside DECIMAL(20,0)
/** Rejects oversized or malformed array input before any processing. */
function guard_rows(array $rows, array &$errors, string $key, string $label): array
{
if (count($rows) > MAX_TIER_ROWS) {
$errors[$key] = "$label: maksimal " . MAX_TIER_ROWS . ' baris.';
return [];
}
return array_values(array_filter($rows, 'is_array'));
}
function nominal_error(?float $v, string $label): ?string
{
return $v !== null && $v > MAX_NOMINAL ? "$label terlalu besar (maksimal Rp1.000 triliun)." : null;
}
function pct_bounds_error($v, string $label, float $max = 1.0): ?string
{
if ($v === false || $v === null) {
return "$label wajib diisi dengan angka persen, contoh 0,75.";
}
if ($v < 0 || $v > $max) {
return "$label harus antara 0 dan " . fmt_pct($max) . '%.';
}
return null;
}
/** Parses tier rows [['max' => '1.000.000', 'rate' => '0,75'], ...]; last row is "di atas". */
function parse_tier_rows(array $rows, string $label, array &$errors, string $errKey): array
{
$rows = guard_rows($rows, $errors, $errKey, $label);
if (isset($errors[$errKey])) {
return [];
}
$rows = array_values(array_filter($rows, fn($r) => trim(input_str($r['max'] ?? '')) !== '' || trim(input_str($r['rate'] ?? '')) !== ''));
if (!$rows) {
$errors[$errKey] = "$label: minimal satu baris.";
return [];
}
$out = [];
$prev = -1;
$last = count($rows) - 1;
foreach ($rows as $i => $r) {
$max = $i === $last ? null : parse_idr($r['max'] ?? '');
$rate = parse_pct($r['rate'] ?? '');
$n = $i + 1;
if ($e = nominal_error($max, "$label baris $n")) {
$errors[$errKey] = $e;
} elseif ($i !== $last && $max === null) {
$errors[$errKey] = "$label baris $n: isi batas nominal (hanya baris terakhir yang tanpa batas).";
} elseif ($max !== null && $max <= $prev) {
$errors[$errKey] = "$label baris $n: batas nominal harus lebih besar dari baris sebelumnya.";
} elseif ($err = pct_bounds_error($rate, "$label baris $n")) {
$errors[$errKey] = $err;
}
if ($max !== null) {
$prev = $max;
}
$out[] = ['max' => $max, 'rate' => $rate === false ? null : $rate];
}
return $out;
}
function changed_settings(array $old, array $new): array
{
$diff = [];
foreach ($new as $k => $v) {
if (abs((float)$old[$k]['value'] - $v) > 1e-9) {
$diff[$k] = ['label' => $old[$k]['label'], 'unit' => $old[$k]['unit'], 'old' => (float)$old[$k]['value'], 'new' => $v];
}
}
return $diff;
}
function action_save_settings(): ?array
{
$rows = settings_rows();
$input = (array)($_POST['s'] ?? []);
$errors = [];
$values = [];
foreach ($rows as $key => $row) {
$raw = input_str($input[$key] ?? '');
if ($row['unit'] === 'pct') {
$v = parse_pct($raw);
if ($e = pct_bounds_error($v, $row['label'])) {
$errors["s.$key"] = $e;
continue;
}
} elseif ($row['unit'] === 'idr') {
$v = parse_idr($raw);
if ($v === null) {
$errors["s.$key"] = $row['label'] . ' wajib diisi.';
continue;
}
if ($e = nominal_error($v, $row['label'])) {
$errors["s.$key"] = $e;
continue;
}
} else {
$v = is_numeric(str_replace(',', '.', $raw)) ? (float)str_replace(',', '.', $raw) : null;
if ($v === null || $v <= 0 || $v > 1000) {
$errors["s.$key"] = $row['label'] . ' harus angka lebih dari 0.';
continue;
}
}
$values[$key] = $v;
}
if (isset($values['hari_setahun']) && ($values['hari_setahun'] < 360 || $values['hari_setahun'] > 366)) {
$errors['s.hari_setahun'] = 'Hari bunga setahun antara 360 dan 366.';
}
if (isset($values['hari_sebulan']) && ($values['hari_sebulan'] < 28 || $values['hari_sebulan'] > 31)) {
$errors['s.hari_sebulan'] = 'Hari bunga sebulan antara 28 dan 31.';
}
if (isset($values['porsi_puab'], $values['porsi_kredit']) && abs($values['porsi_puab'] + $values['porsi_kredit'] - 1) > 1e-9) {
$errors['s.porsi_kredit'] = 'Porsi PUAB + porsi kredit harus 100%.';
}
if (isset($values['gwm'], $values['kas']) && $values['gwm'] + $values['kas'] >= 1) {
$errors['s.kas'] = 'GWM + kas harus kurang dari 100%.';
}
if ($errors) {
return ['errors' => $errors, 'draft' => $input];
}
$diff = changed_settings($rows, $values);
if (!$diff) {
flash('info', 'Tidak ada perubahan parameter.');
redirect(base_url('admin/?tab=parameter'));
}
$pdo = db();
$pdo->beginTransaction();
$st = $pdo->prepare('UPDATE settings SET value = ? WHERE skey = ?');
foreach ($diff as $k => $d) {
$st->execute([$d['new'], $k]);
}
audit('param.settings', 'Mengubah ' . count($diff) . ' parameter: ' . implode(', ', array_column($diff, 'label')), $diff);
$pdo->commit();
flash('ok', count($diff) . ' parameter disimpan. Kalkulator langsung memakai nilai baru.');
redirect(base_url('admin/?tab=parameter'));
}
function action_save_products(): ?array
{
$input = (array)($_POST['tiers'] ?? []);
$errors = [];
$parsed = [];
foreach (SERUBI_PRODUCTS as $key => $label) {
$parsed[$key] = parse_tier_rows((array)($input[$key] ?? []), $label, $errors, "tiers.$key");
}
if ($errors) {
return ['errors' => $errors, 'draft' => $input];
}
$before = load_params()['productRates'];
$after = [];
foreach ($parsed as $k => $rows) {
$after[$k] = array_map(fn($r) => ['max' => $r['max'], 'rate' => $r['rate']], $rows);
}
$changed = array_keys(array_filter($after, fn($rows, $k) => json_encode($rows) !== json_encode($before[$k]), ARRAY_FILTER_USE_BOTH));
if (!$changed) {
flash('info', 'Tidak ada perubahan rate produk.');
redirect(base_url('admin/?tab=produk'));
}
$pdo = db();
$pdo->beginTransaction();
$del = $pdo->prepare('DELETE FROM product_rates WHERE product = ?');
$ins = $pdo->prepare('INSERT INTO product_rates (product, max_nominal, rate, sort) VALUES (?,?,?,?)');
foreach ($changed as $k) {
$del->execute([$k]);
foreach ($after[$k] as $i => $r) {
$ins->execute([$k, $r['max'], $r['rate'], $i]);
}
}
$names = array_map(fn($k) => SERUBI_PRODUCTS[$k], $changed);
audit('param.produk', 'Mengubah rate ' . implode(', ', $names),
array_combine($changed, array_map(fn($k) => ['old' => $before[$k], 'new' => $after[$k]], $changed)));
$pdo->commit();
flash('ok', 'Rate ' . implode(', ', $names) . ' disimpan.');
redirect(base_url('admin/?tab=produk'));
}
function action_save_deposito(): ?array
{
$params = load_params();
$tenors = $params['tenors'];
$bandsIn = array_values((array)($_POST['bands'] ?? []));
$ratesIn = array_values((array)($_POST['rates'] ?? []));
$errors = [];
if (count($bandsIn) > MAX_TIER_ROWS || count($ratesIn) > MAX_TIER_ROWS) {
return ['errors' => ['deposito' => 'Maksimal ' . MAX_TIER_ROWS . ' kelompok nominal.'], 'draft' => null];
}
$bandsIn = array_map(fn($b) => is_array($b) ? '' : (string)$b, $bandsIn);
$ratesIn = array_map(fn($r) => is_array($r) ? $r : [], $ratesIn);
$count = count($bandsIn);
if ($count === 0) {
$errors['deposito'] = 'Minimal satu kelompok nominal.';
}
$bands = [];
$matrix = [];
$prev = -1;
foreach ($bandsIn as $i => $raw) {
$max = $i === $count - 1 ? null : parse_idr($raw);
if ($e = nominal_error($max, 'Baris ' . ($i + 1))) {
$errors['deposito'] = $e;
} elseif ($i !== $count - 1 && $max === null) {
$errors['deposito'] = 'Baris ' . ($i + 1) . ': isi batas nominal (hanya baris terakhir yang tanpa batas).';
} elseif ($max !== null && $max <= $prev) {
$errors['deposito'] = 'Baris ' . ($i + 1) . ': batas nominal harus lebih besar dari baris sebelumnya.';
}
if ($max !== null) {
$prev = $max;
}
$bands[] = $max;
foreach ($tenors as $t) {
$v = parse_pct($ratesIn[$i][$t] ?? '');
if ($e = pct_bounds_error($v, 'Rate ' . $t . ' bulan baris ' . ($i + 1))) {
$errors['deposito'] = $e;
}
$matrix[$i][$t] = $v === false ? null : $v;
}
}
if ($errors) {
return ['errors' => $errors, 'draft' => ['bands' => $bandsIn, 'rates' => $ratesIn]];
}
$before = ['bands' => $params['depositoBands'], 'rates' => $params['depositoRates']];
$afterRates = [];
foreach ($tenors as $t) {
foreach ($bands as $i => $max) {
$afterRates[$t][] = ['max' => $max, 'rate' => $matrix[$i][$t]];
}
}
if (json_encode($before['rates']) === json_encode($afterRates)) {
flash('info', 'Tidak ada perubahan rate deposito.');
redirect(base_url('admin/?tab=deposito'));
}
$pdo = db();
$pdo->beginTransaction();
$pdo->exec('DELETE FROM deposito_bands');
$insB = $pdo->prepare('INSERT INTO deposito_bands (max_nominal, sort) VALUES (?,?)');
$insR = $pdo->prepare('INSERT INTO deposito_rates (band_id, tenor, rate) VALUES (?,?,?)');
foreach ($bands as $i => $max) {
$insB->execute([$max, $i]);
$id = (int)$pdo->lastInsertId();
foreach ($tenors as $t) {
$insR->execute([$id, $t, $matrix[$i][$t]]);
}
}
audit('param.deposito', 'Mengubah tabel rate deposito', ['old' => $before['rates'], 'new' => $afterRates]);
$pdo->commit();
flash('ok', 'Rate deposito disimpan.');
redirect(base_url('admin/?tab=deposito'));
}
function action_save_cashback(): ?array
{
$params = load_params();
$errors = [];
$rowsIn = guard_rows((array)($_POST['cb'] ?? []), $errors, 'cb', 'Porsi cashback');
if ($errors) {
return ['errors' => $errors, 'draft' => null];
}
$tenorIn = array_map('intval', array_keys((array)($_POST['cbtenor'] ?? [])));
$errors = [];
$tiers = [];
$seen = [];
foreach ($rowsIn as $i => $r) {
if (trim(input_str($r['min'] ?? '')) === '' && trim(input_str($r['share'] ?? '')) === '') {
continue;
}
$min = parse_idr($r['min'] ?? '');
$share = parse_pct($r['share'] ?? '');
$n = $i + 1;
if ($min === null || $min <= 0) {
$errors['cb'] = "Baris $n: nominal minimal wajib diisi.";
} elseif ($e = nominal_error($min, "Baris $n")) {
$errors['cb'] = $e;
} elseif (isset($seen[(string)$min])) {
$errors['cb'] = "Baris $n: nominal minimal sama dengan baris lain.";
} elseif ($e = pct_bounds_error($share, "Porsi nasabah baris $n")) {
$errors['cb'] = $e;
}
$seen[(string)$min] = true;
$tiers[] = ['min' => $min, 'nasabah' => $share === false ? null : $share];
}
if (!$tiers && !$errors) {
$errors['cb'] = 'Minimal satu tingkat porsi cashback.';
}
$tenorIn = array_values(array_intersect($params['tenors'], $tenorIn));
if ($errors) {
return ['errors' => $errors, 'draft' => ['cb' => $rowsIn, 'cbtenor' => $tenorIn]];
}
usort($tiers, fn($a, $b) => $a['min'] <=> $b['min']);
$before = ['tiers' => $params['cashbackTiers'], 'tenors' => $params['cashbackTenors']];
$after = ['tiers' => $tiers, 'tenors' => $tenorIn];
if (json_encode($before) === json_encode($after)) {
flash('info', 'Tidak ada perubahan porsi cashback.');
redirect(base_url('admin/?tab=cashback'));
}
$pdo = db();
$pdo->beginTransaction();
$pdo->exec('DELETE FROM cashback_tiers');
$ins = $pdo->prepare('INSERT INTO cashback_tiers (min_nominal, share_nasabah) VALUES (?,?)');
foreach ($tiers as $t) {
$ins->execute([$t['min'], $t['nasabah']]);
}
$up = $pdo->prepare('UPDATE tenors SET cashback = ? WHERE months = ?');
foreach ($params['tenors'] as $m) {
$up->execute([in_array($m, $tenorIn, true) ? 1 : 0, $m]);
}
audit('param.cashback', 'Mengubah porsi cashback / tenor program', ['old' => $before, 'new' => $after]);
$pdo->commit();
flash('ok', 'Porsi cashback disimpan.');
redirect(base_url('admin/?tab=cashback'));
}
function action_reset_defaults(): ?array
{
if (($_POST['confirm'] ?? '') !== 'RESET') {
return ['errors' => ['reset' => 'Ketik RESET untuk konfirmasi.'], 'draft' => []];
}
$before = load_params();
db_seed(db(), true);
audit('param.reset', 'Mengembalikan semua parameter ke nilai file Excel', ['old' => $before]);
flash('ok', 'Semua parameter dikembalikan ke nilai file Excel 22092026.');
redirect(base_url('admin/?tab=parameter'));
}
/**
* Managing other admins is reserved for super admins and needs the acting
* admin's own password (re-authentication). Returns an error response or null.
*/
function user_admin_guard(array $me, string $errKey, int $targetId = 0): ?array
{
if (!is_super_admin($me)) {
http_response_code(403);
return ['errors' => [$errKey => 'Hanya super admin yang dapat mengelola akun admin lain.', 'user_id' => $targetId], 'draft' => []];
}
if (!reauth_ok($me, input_str($_POST['confirm_password'] ?? ''))) {
return ['errors' => [$errKey => 'Password Anda (konfirmasi) salah.', 'user_id' => $targetId], 'draft' => []];
}
return null;
}
function admin_target(int $id): ?array
{
$st = db()->prepare('SELECT * FROM admins WHERE id = ?');
$st->execute([$id]);
return $st->fetch() ?: null;
}
function active_super_admins(): int
{
return (int)db()->query("SELECT COUNT(*) FROM admins WHERE role = 'super_admin' AND is_active = 1")->fetchColumn();
}
function action_user_create(array $me): ?array
{
$u = strtolower(trim(input_str($_POST['username'] ?? '')));
$n = trim(input_str($_POST['full_name'] ?? ''));
if ($denied = user_admin_guard($me, 'user_new')) {
$denied['draft'] = ['username' => $u, 'full_name' => $n];
return $denied;
}
$p = input_str($_POST['password'] ?? '');
$role = input_str($_POST['role'] ?? '') === 'super_admin' ? 'super_admin' : 'admin';
$err = validate_new_admin($u, $n, $p, input_str($_POST['password_confirm'] ?? ''));
if (!$err) {
$st = db()->prepare('SELECT COUNT(*) FROM admins WHERE username = ?');
$st->execute([$u]);
if ((int)$st->fetchColumn() > 0) {
$err = 'Username sudah dipakai.';
}
}
if ($err) {
return ['errors' => ['user_new' => $err], 'draft' => ['username' => $u, 'full_name' => $n]];
}
// The new admin replaces this initial password and sets up OTP at the first login.
db()->prepare('INSERT INTO admins (username, full_name, role, password_hash, must_change_password) VALUES (?,?,?,?,1)')
->execute([$u, $n, $role, password_hash($p, PASSWORD_DEFAULT)]);
audit('user.create', "Menambah admin $u (" . ($role === 'super_admin' ? 'super admin' : 'admin') . ')');
flash('ok', "Admin $u ditambahkan. Password awal wajib diganti dan OTP diaktifkan saat login pertama.");
redirect(base_url('admin/?tab=pengguna'));
}
function action_user_toggle(array $me): ?array
{
$id = (int)($_POST['id'] ?? 0);
if ($denied = user_admin_guard($me, 'user', $id)) {
return $denied;
}
$row = admin_target($id);
if (!$row) {
return ['errors' => ['user' => 'Admin tidak ditemukan.'], 'draft' => []];
}
if ($id === $me['id']) {
return ['errors' => ['user' => 'Anda tidak dapat menonaktifkan akun sendiri.'], 'draft' => []];
}
$active = (int)$row['is_active'] ? 0 : 1;
if (!$active && $row['role'] === 'super_admin' && active_super_admins() <= 1) {
return ['errors' => ['user' => 'Super admin aktif terakhir tidak dapat dinonaktifkan.', 'user_id' => $id], 'draft' => []];
}
db()->prepare('UPDATE admins SET is_active = ? WHERE id = ?')->execute([$active, $id]);
audit('user.toggle', ($active ? 'Mengaktifkan' : 'Menonaktifkan') . ' admin ' . $row['username']);
flash('ok', 'Admin ' . $row['username'] . ($active ? ' diaktifkan.' : ' dinonaktifkan.'));
redirect(base_url('admin/?tab=pengguna'));
}
function action_user_password(array $me): ?array
{
$id = (int)($_POST['id'] ?? 0);
if ($denied = user_admin_guard($me, 'user', $id)) {
return $denied;
}
$p = input_str($_POST['password'] ?? '');
$row = admin_target($id);
$err = $row ? validate_password($p, input_str($_POST['password_confirm'] ?? ''), (string)$row['username']) : 'Admin tidak ditemukan.';
if ($err) {
return ['errors' => ['user' => $err, 'user_id' => $id], 'draft' => []];
}
// Temporary password: the owner must replace it at the next login (the new hash also ends their sessions).
db()->prepare('UPDATE admins SET password_hash = ?, must_change_password = 1 WHERE id = ?')->execute([password_hash($p, PASSWORD_DEFAULT), $id]);
audit('user.password', 'Me-reset password admin ' . $row['username']);
flash('ok', 'Password ' . $row['username'] . ' di-reset. Pemilik akun wajib menggantinya saat login berikutnya.');
redirect(base_url('admin/?tab=pengguna'));
}
function action_user_role(array $me): ?array
{
$id = (int)($_POST['id'] ?? 0);
if ($denied = user_admin_guard($me, 'user', $id)) {
return $denied;
}
$row = admin_target($id);
if (!$row || $id === $me['id']) {
return ['errors' => ['user' => $row ? 'Anda tidak dapat mengubah peran akun sendiri.' : 'Admin tidak ditemukan.', 'user_id' => $id], 'draft' => []];
}
$role = $row['role'] === 'super_admin' ? 'admin' : 'super_admin';
if ($role === 'admin' && (int)$row['is_active'] && active_super_admins() <= 1) {
return ['errors' => ['user' => 'Harus ada minimal satu super admin aktif.', 'user_id' => $id], 'draft' => []];
}
db()->prepare('UPDATE admins SET role = ? WHERE id = ?')->execute([$role, $id]);
$label = $role === 'super_admin' ? 'super admin' : 'admin';
audit('user.role', 'Mengubah peran ' . $row['username'] . ' menjadi ' . $label);
flash('ok', 'Peran ' . $row['username'] . ' diubah menjadi ' . $label . '.');
redirect(base_url('admin/?tab=pengguna'));
}
function action_user_reset_otp(array $me): ?array
{
$id = (int)($_POST['id'] ?? 0);
if ($denied = user_admin_guard($me, 'user', $id)) {
return $denied;
}
$row = admin_target($id);
if (!$row || $id === $me['id']) {
return ['errors' => ['user' => $row ? 'OTP akun sendiri tidak dapat di-reset dari sini.' : 'Admin tidak ditemukan.', 'user_id' => $id], 'draft' => []];
}
// Lost phone: the owner sets up a new authenticator at the next login.
db()->prepare('UPDATE admins SET otp_secret = NULL, otp_enabled = 0, otp_last_step = NULL, recovery_codes = NULL WHERE id = ?')->execute([$id]);
audit('user.otp', 'Me-reset OTP admin ' . $row['username']);
flash('ok', 'OTP ' . $row['username'] . ' di-reset. Authenticator baru dipasang saat login berikutnya.');
redirect(base_url('admin/?tab=pengguna'));
}
function action_own_password(array $me): ?array
{
$st = db()->prepare('SELECT password_hash FROM admins WHERE id = ?');
$st->execute([$me['id']]);
$hash = (string)$st->fetchColumn();
$err = null;
if (!password_verify(input_str($_POST['current'] ?? ''), $hash)) {
$err = 'Password saat ini salah.';
} else {
$err = validate_password(input_str($_POST['password'] ?? ''), input_str($_POST['password_confirm'] ?? ''), $me['username']);
}
if ($err) {
return ['errors' => ['own' => $err], 'draft' => []];
}
$newHash = password_hash(input_str($_POST['password']), PASSWORD_DEFAULT);
db()->prepare('UPDATE admins SET password_hash = ?, must_change_password = 0 WHERE id = ?')->execute([$newHash, $me['id']]);
// keep this session, end every other session of this account
session_regenerate_id(true);
$_SESSION['pwv'] = password_fingerprint($newHash);
audit('user.password', 'Mengganti password sendiri');
flash('ok', 'Password Anda diganti.');
redirect(base_url('admin/?tab=pengguna'));
}
+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;
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
/*
* Self-hosted CAPTCHA for the admin login: the image is drawn by the server
* (GD), so no third-party service is called and the Content-Security-Policy
* stays strict. The answer lives only in the session, as a hash, and is valid
* once, for CAPTCHA_TTL seconds.
*/
const CAPTCHA_TTL = 300;
const CAPTCHA_LENGTH = 5;
const CAPTCHA_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; // no 0/O, 1/I/L
function captcha_available(): bool
{
return function_exists('imagecreatetruecolor') && function_exists('imagepng');
}
/** Starts a new challenge (called when the login form is rendered). */
function captcha_new(): void
{
start_session();
if (!captcha_available()) {
// Without GD: a text question, weaker than the image, so login never becomes impossible.
error_log('[serubi] Ekstensi GD tidak tersedia: CAPTCHA memakai soal teks.');
$a = random_int(2, 9);
$b = random_int(2, 9);
$_SESSION['captcha'] = ['code' => (string)($a + $b), 'question' => "Berapa $a + $b?", 'exp' => time() + CAPTCHA_TTL];
return;
}
$code = '';
for ($i = 0; $i < CAPTCHA_LENGTH; $i++) {
$code .= CAPTCHA_ALPHABET[random_int(0, strlen(CAPTCHA_ALPHABET) - 1)];
}
$_SESSION['captcha'] = ['code' => $code, 'exp' => time() + CAPTCHA_TTL];
}
/** Text question when the image cannot be drawn, else null. */
function captcha_question(): ?string
{
return $_SESSION['captcha']['question'] ?? null;
}
/** Checks the answer and always consumes the challenge (one attempt per image). */
function captcha_check(string $answer): bool
{
start_session();
$c = $_SESSION['captcha'] ?? null;
unset($_SESSION['captcha']);
if (!$c || time() > $c['exp']) {
return false;
}
$answer = strtoupper(preg_replace('/\s+/', '', $answer));
return $answer !== '' && hash_equals($c['code'], $answer);
}
/** Outputs the PNG for the current challenge. */
function captcha_render(): void
{
start_session();
$code = (string)($_SESSION['captcha']['code'] ?? '');
$w = 200;
$h = 64;
$img = imagecreatetruecolor($w, $h);
imagefilledrectangle($img, 0, 0, $w, $h, imagecolorallocate($img, 244, 246, 250));
// background noise
for ($i = 0; $i < 9; $i++) {
$c = imagecolorallocate($img, random_int(140, 200), random_int(150, 205), random_int(170, 215));
imageline($img, random_int(0, $w), random_int(0, $h), random_int(0, $w), random_int(0, $h), $c);
}
// each glyph is drawn small (built-in font), then scaled and jittered
$x = 14;
foreach (str_split($code) as $ch) {
$g = imagecreatetruecolor(10, 16);
$bg = imagecolorallocate($g, 244, 246, 250);
imagefill($g, 0, 0, $bg);
imagecolortransparent($g, $bg);
imagestring($g, 5, 1, 0, $ch, imagecolorallocate($g, random_int(10, 60), random_int(30, 70), random_int(90, 140)));
$size = random_int(30, 38);
imagecopyresized($img, $g, $x, random_int(4, $h - $size - 10) + 6, 0, 0, (int)($size * 0.66), $size, 10, 16);
$x += random_int(31, 36);
}
// foreground noise over the text
for ($i = 0; $i < 3; $i++) {
imagesetthickness($img, 2);
imageline($img, 0, random_int(10, $h - 10), $w, random_int(10, $h - 10), imagecolorallocate($img, 60, 80, 130));
}
for ($i = 0; $i < 220; $i++) {
imagesetpixel($img, random_int(0, $w - 1), random_int(0, $h - 1), imagecolorallocate($img, random_int(60, 160), random_int(60, 160), random_int(60, 160)));
}
header('Content-Type: image/png');
header('Cache-Control: no-store, max-age=0');
imagepng($img);
}
+128
View File
@@ -0,0 +1,128 @@
<?php
declare(strict_types=1);
function app_config(): array
{
static $config = null;
if ($config === null) {
$config = require __DIR__ . '/../config.php';
date_default_timezone_set($config['app']['timezone']);
}
return $config;
}
/**
* Shared PDO connection. Web requests only connect: the schema is created and
* upgraded explicitly (database/migrate.php or the SQL files in database/),
* never on each request.
*/
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$c = app_config()['db'];
if ($c['user'] === '') {
throw new RuntimeException('Kredensial database belum dikonfigurasi (lihat database/secrets.example.php).');
}
$pdo = db_connect($c, true);
$pdo->exec("SET time_zone = '" . (new DateTime())->format('P') . "'");
return $pdo;
}
function db_connect(array $c, bool $withDatabase): PDO
{
$dsn = sprintf('mysql:host=%s;port=%d;charset=utf8mb4', $c['host'], $c['port']);
if ($withDatabase) {
$dsn .= ';dbname=' . str_replace('`', '', $c['name']);
}
return new PDO($dsn, $c['user'], $c['password'], [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
/** Runs a trusted SQL file; statements end with ";" at the end of a line (no splitting inside literals). */
function db_run_sql_file(PDO $pdo, string $file): void
{
$sql = preg_replace('/^\s*--.*$/m', '', (string)file_get_contents($file));
foreach (preg_split('/;\s*$/m', $sql) as $stmt) {
if (trim($stmt) !== '') {
$pdo->exec($stmt);
}
}
}
/** CLI only (database/migrate.php): creates the tables and seed data on an empty database. */
function db_migrate(PDO $pdo): void
{
$exists = $pdo->query("SHOW TABLES LIKE 'login_attempts'")->fetchColumn();
if (!$exists) {
db_run_sql_file($pdo, __DIR__ . '/../database/schema.sql');
}
if ((int)$pdo->query('SELECT COUNT(*) FROM settings')->fetchColumn() === 0) {
db_seed($pdo);
}
db_clean_descriptions($pdo);
}
/** Removes workbook cell references like " (COB!F28)" from older seeded descriptions (runs until none are left). */
function db_clean_descriptions(PDO $pdo): void
{
$rows = $pdo->query("SELECT skey, description FROM settings WHERE description LIKE '%COB!%' OR description LIKE '%CALC!%'")->fetchAll();
if (!$rows) {
return;
}
$st = $pdo->prepare('UPDATE settings SET description = ? WHERE skey = ?');
foreach ($rows as $r) {
$st->execute([trim(preg_replace('/\s*\((?:COB|CALC)![A-Z0-9:]+\)/', '', (string)$r['description'])), $r['skey']]);
}
}
/** Seeds parameters from database/defaults.php (the values of the reference workbook). */
function db_seed(PDO $pdo, bool $wipe = false): void
{
$d = require __DIR__ . '/../database/defaults.php';
$pdo->beginTransaction();
try {
if ($wipe) {
foreach (['deposito_rates', 'deposito_bands', 'product_rates', 'cashback_tiers', 'tenors', 'settings'] as $t) {
$pdo->exec("DELETE FROM $t");
}
}
$st = $pdo->prepare('INSERT INTO settings (skey, value, label, unit, grp, description, sort) VALUES (?,?,?,?,?,?,?)');
$i = 0;
foreach ($d['settings'] as $key => [$value, $label, $unit, $grp, $desc]) {
$st->execute([$key, $value, $label, $unit, $grp, $desc, ++$i]);
}
$st = $pdo->prepare('INSERT INTO tenors (months, cashback) VALUES (?,?)');
foreach ($d['tenors'] as $m => $cb) {
$st->execute([$m, $cb ? 1 : 0]);
}
$st = $pdo->prepare('INSERT INTO product_rates (product, max_nominal, rate, sort) VALUES (?,?,?,?)');
foreach ($d['product_rates'] as $product => $tiers) {
foreach ($tiers as $i => [$max, $rate]) {
$st->execute([$product, $max, $rate, $i]);
}
}
$band = $pdo->prepare('INSERT INTO deposito_bands (max_nominal, sort) VALUES (?,?)');
$rate = $pdo->prepare('INSERT INTO deposito_rates (band_id, tenor, rate) VALUES (?,?,?)');
foreach ($d['deposito_bands'] as $i => $max) {
$band->execute([$max, $i]);
$bandId = (int)$pdo->lastInsertId();
foreach ($d['deposito_rates'] as $tenor => $rates) {
$rate->execute([$bandId, $tenor, $rates[$i]]);
}
}
$st = $pdo->prepare('INSERT INTO cashback_tiers (min_nominal, share_nasabah) VALUES (?,?)');
foreach ($d['cashback_tiers'] as [$min, $share]) {
$st->execute([$min, $share]);
}
$pdo->commit();
} catch (Throwable $e) {
$pdo->rollBack();
throw $e;
}
}
+215
View File
@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
/*
* SERUBI cashback engine, server side. Line-by-line port of assets/js/engine.js
* (sheet "COB" and "CALCULATOR CASHBACK SERUBI" of the reference workbook), so
* the public calculator gets results without receiving the bank's internal
* parameters. Both engines are validated against tests/excel-fixtures.json.
*/
const ENGINE_PRODUCTS = ['giro' => 'Giro Umum', 'simanja' => 'Tabungan SIMANJA', 'simpeda' => 'Tabungan SIMPEDA'];
/** Excel ROUNDDOWN(x, 0) on 15 significant digits (same as Number(x.toPrecision(15)) in JS). */
function engine_rounddown(float $x): float
{
$v = (float)sprintf('%.14e', $x);
return $v < 0 ? ceil($v) : floor($v);
}
/** Tiers [{max: float|null, rate}], ascending; first tier with n <= max wins, null max = "di atas". */
function engine_tier_rate(?array $tiers, float $n): float
{
foreach ($tiers ?? [] as $t) {
if (!isset($t['max']) || $n <= $t['max']) {
return (float)($t['rate'] ?? 0);
}
}
return 0.0;
}
/** Cashback sharing [{min, nasabah}]: highest min that n reaches wins (COB!I34:I35). */
function engine_share(array $params, float $n, int $tenor): array
{
if (!in_array($tenor, $params['cashbackTenors'] ?? [], true)) {
return ['share' => null, 'reason' => 'tenor'];
}
$tiers = $params['cashbackTiers'] ?? [];
usort($tiers, fn($a, $b) => $b['min'] <=> $a['min']);
foreach ($tiers as $t) {
if ($n >= $t['min']) {
return ['share' => (float)$t['nasabah'], 'reason' => null];
}
}
return ['share' => null, 'reason' => 'nominal'];
}
function engine_min_cashback_nominal(array $params): float
{
$mins = array_column($params['cashbackTiers'] ?? [], 'min');
return $mins ? (float)min($mins) : 0.0;
}
function engine_deposito_rate(array $params, float $n, int $tenor): float
{
if ($n < $params['settings']['deposito_min']) { // COB!K14: F4<1000000 -> 0
return 0.0;
}
return engine_tier_rate($params['depositoRates'][$tenor] ?? null, $n);
}
function engine_calculate(array $params, float $nominal, int $tenor, string $customerType): array
{
$p = $params['settings'];
$n = max(0.0, $nominal);
$t = $tenor;
$customerType = $customerType === 'badan' ? 'badan' : 'perorangan';
$year = $p['hari_setahun'];
$month = $p['hari_sebulan'];
// Pengelolaan dana (COB!F23:F38)
$gwm = $n * $p['gwm'];
$kas = $n * $p['kas'];
$managed = $n - ($gwm + $kas);
$puabBase = $managed * $p['porsi_puab'];
$puabIncome = $puabBase * $p['puab'] * ($month / $year) * $t;
$creditBase = $managed * $p['porsi_kredit'];
$creditIncome = $creditBase * $p['kredit'] * ($t / 12);
$income = $puabIncome + $creditIncome;
$sh = engine_share($params, $n, $t);
$eligible = $sh['share'] !== null;
$share = $eligible ? $sh['share'] : 0.0;
// Biaya & spread per produk (COB!H22:R37)
$lps = min($n, $p['lps_batas']) * $p['premi_lps'];
$ohc = $puabIncome * $p['ohc'];
$products = [];
foreach (ENGINE_PRODUCTS as $key => $name) {
$rate = engine_tier_rate($params['productRates'][$key] ?? null, $n);
$bunga = engine_rounddown((($n * $rate * $month) / $year) * $t);
$program = $key === 'simanja' ? $p['biaya_program'] : 0.0;
$cost = $bunga + $program + $ohc + $lps;
$spread = $income - $cost;
$bungaTax = $bunga * $p['pajak_tabungan'];
$products[$key] = [
'key' => $key, 'name' => $name, 'rate' => $rate,
'bungaGross' => $bunga, 'bungaTax' => $bungaTax, 'bungaNet' => $bunga - $bungaTax,
'program' => $program, 'ohc' => $ohc, 'lps' => $lps, 'cost' => $cost, 'spread' => $spread,
'cashbackGross' => $spread * $share,
'costOfFund' => $managed > 0 ? ($cost + $spread * $share) / $managed : 0.0,
];
}
// Cashback = rata-rata 3 produk (COB!H7:H10)
$gross = $eligible ? ($products['giro']['cashbackGross'] + $products['simanja']['cashbackGross'] + $products['simpeda']['cashbackGross']) / 3 : 0.0;
$negative = $gross < 0;
if ($negative) {
$gross = 0.0;
}
$taxRate = $customerType === 'badan' ? $p['pajak_cashback_badan'] : $p['pajak_cashback_perorangan'];
$tax = $gross * $taxRate;
$net = $gross - $tax;
$equiv = $n > 0 && $t > 0 ? ($net / $n) * ($year / ($month * $t)) : 0.0;
// Deposito pembanding (COB!K14:L17)
$dRate = engine_deposito_rate($params, $n, $t);
$dGross = engine_rounddown((($n * $dRate * $month) / $year) * $t);
$dTax = $dGross * $p['pajak_deposito'];
$dNet = $dGross - $dTax;
$deposito = ['rate' => $dRate, 'gross' => $dGross, 'tax' => $dTax, 'net' => $dNet, 'monthly' => $t ? $dNet / $t : 0.0];
// Perbandingan (CALC!A10:F13)
$comparison = [];
foreach (ENGINE_PRODUCTS as $key => $name) {
$pr = $products[$key];
$total = $pr['bungaNet'] + $net;
$comparison[] = [
'key' => $key, 'name' => $name, 'rate' => $pr['rate'], 'bungaNet' => $pr['bungaNet'],
'cashbackNet' => $net, 'total' => $total, 'totalRate' => $pr['rate'] + $equiv, 'diff' => $total - $dNet,
];
}
$best = array_reduce($comparison, fn($a, $b) => $a === null || $b['total'] > $a['total'] ? $b : $a);
// Pinalti pencairan sebelum jatuh tempo (CALC!A17:B22)
$penalty = ['refund' => $net, 'tax' => $gross * $taxRate, 'fee' => $net * $p['pinalti']];
$penalty['total'] = $penalty['refund'] + $penalty['tax'] + $penalty['fee'];
return [
'input' => ['nominal' => $n, 'tenor' => $t, 'customerType' => $customerType],
'eligible' => $eligible && !$negative,
'reason' => $negative ? 'spread' : $sh['reason'],
'minNominal' => engine_min_cashback_nominal($params),
'share' => $share, 'shareBank' => $eligible ? 1 - $share : 0.0,
'fund' => [
'gwm' => $gwm, 'kas' => $kas, 'managed' => $managed, 'puabBase' => $puabBase, 'puabIncome' => $puabIncome,
'creditBase' => $creditBase, 'creditIncome' => $creditIncome, 'income' => $income,
],
'products' => $products,
'cashback' => ['gross' => $gross, 'taxRate' => $taxRate, 'tax' => $tax, 'net' => $net, 'monthly' => $t ? $net / $t : 0.0, 'equiv' => $equiv],
'deposito' => $deposito,
'comparison' => $comparison,
'best' => $best,
'penalty' => $penalty,
];
}
/**
* What a visitor without an admin session may see: the customer-facing result
* only. Cost structure, spread, sharing tiers and the deposito comparison stay
* on the server.
*/
function engine_public_result(array $r, array $params): array
{
return [
'input' => $r['input'],
'eligible' => $r['eligible'],
'reason' => $r['reason'],
'minNominal' => $r['minNominal'],
'share' => $r['share'],
'cashback' => $r['cashback'],
'comparison' => array_map(fn($c) => array_diff_key($c, ['diff' => 1]), $r['comparison']),
'penalty' => $r['penalty'] + ['rate' => $params['settings']['pinalti']],
];
}
/**
* Model for the public in-browser engine (assets/js/engine-public.js), so the
* calculator answers instantly and offline. The bank's cost structure is folded
* into one margin per tenor:
*
* pendapatan PUAB − OHC + pendapatan kredit = nominal × margin[tenor]
* margin = (1 − GWM − kas) × (porsi PUAB × rate PUAB × hari sebulan/hari setahun × tenor × (1 − OHC)
* + porsi kredit × bunga kredit × tenor/12)
*
* GWM, kas, porsi PUAB/kredit, rate PUAB, bunga kredit, OHC and the deposito
* rates are not sent. Everything else is customer-facing (product rates, taxes,
* penalty, LPS rules) or, like the sharing tiers, can be read off the results.
*/
function engine_client_model(array $params): array
{
$p = $params['settings'];
$year = $p['hari_setahun'];
$month = $p['hari_sebulan'];
$margin = [];
foreach ($params['tenors'] as $t) {
$margin[(string)$t] = (1 - ($p['gwm'] + $p['kas']))
* ($p['porsi_puab'] * $p['puab'] * ($month / $year) * $t * (1 - $p['ohc'])
+ $p['porsi_kredit'] * $p['kredit'] * ($t / 12));
}
return [
'tenors' => $params['tenors'],
'cashbackTenors' => $params['cashbackTenors'],
'minNominal' => engine_min_cashback_nominal($params),
'days' => ['month' => $month, 'year' => $year],
'margin' => $margin,
'programAvg' => $p['biaya_program'] / 3, // SIMANJA programme fee, averaged over the 3 products
'lps' => ['rate' => $p['premi_lps'], 'cap' => $p['lps_batas']],
'productRates' => $params['productRates'],
'cashbackTiers' => $params['cashbackTiers'],
'tax' => ['bunga' => $p['pajak_tabungan'], 'perorangan' => $p['pajak_cashback_perorangan'], 'badan' => $p['pajak_cashback_badan']],
'pinalti' => $p['pinalti'],
'paramsAt' => $params['updatedAt'],
'generatedAt' => date('c'),
];
}
+244
View File
@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/security.php';
require_once __DIR__ . '/params.php';
security_bootstrap();
function e($v): string
{
return htmlspecialchars(input_str($v), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
/** Request values may be arrays when tampered with; anything non-scalar becomes ''. */
function input_str($v): string
{
return is_scalar($v) ? (string)$v : '';
}
/** Root-relative URL of the app, so it works under /calcserubi/ or a vhost root. */
function base_url(string $path = ''): string
{
static $base = null;
if ($base === null) {
$script = str_replace('\\', '/', $_SERVER['SCRIPT_NAME'] ?? '/');
$appRoot = str_replace('\\', '/', realpath(__DIR__ . '/..'));
$file = str_replace('\\', '/', realpath($_SERVER['SCRIPT_FILENAME'] ?? '') ?: '');
$rel = $file && strpos($file, $appRoot) === 0 ? substr($file, strlen($appRoot)) : '';
$base = rtrim($rel ? substr($script, 0, strlen($script) - strlen($rel)) : dirname($script), '/') . '/';
}
return $base . ltrim($path, '/');
}
function app_version(): string
{
return (string)(app_config()['app']['version'] ?? '1');
}
/**
* Versioned asset URL: ?v=<app version>-<file mtime>. Any file updated on the
* server gets a new URL, so clients never run a stale cached JS/CSS file.
*/
function asset(string $path): string
{
$file = __DIR__ . '/../' . $path;
$v = app_version() . (is_file($file) ? '-' . base_convert((string)filemtime($file), 10, 36) : '');
return base_url($path) . '?v=' . rawurlencode($v);
}
/** Public pages: security headers + always revalidate (so the newest versioned assets load). */
function send_no_cache_headers(): void
{
send_security_headers(false);
}
const SESSION_COOKIE = 'serubi_admin';
function start_session(): void
{
if (session_status() === PHP_SESSION_ACTIVE) {
return;
}
session_name(SESSION_COOKIE);
session_set_cookie_params([
'lifetime' => 0,
'path' => base_url(),
'httponly' => true,
'samesite' => 'Strict',
'secure' => is_https(),
]);
session_start();
}
function csrf_token(): string
{
start_session();
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="csrf" value="' . e(csrf_token()) . '">';
}
function csrf_check(): void
{
start_session();
$sent = $_POST['csrf'] ?? '';
if (!is_string($sent) || empty($_SESSION['csrf']) || !hash_equals($_SESSION['csrf'], $sent)) {
http_response_code(403);
header('Cache-Control: no-store');
exit('Sesi formulir kedaluwarsa. Muat ulang halaman lalu coba lagi.');
}
}
function flash(?string $type = null, ?string $message = null): ?array
{
start_session();
if ($type !== null) {
$_SESSION['flash'] = ['type' => $type, 'message' => $message];
return null;
}
$f = $_SESSION['flash'] ?? null;
unset($_SESSION['flash']);
return $f;
}
function redirect(string $url): void
{
header('Location: ' . $url, true, 303);
exit;
}
function audit(string $action, string $summary, ?array $changes = null): void
{
$admin = $_SESSION['admin'] ?? null;
db()->prepare('INSERT INTO audit_log (admin_id, username, action, summary, changes, ip) VALUES (?,?,?,?,?,?)')
->execute([$admin['id'] ?? null, $admin['username'] ?? null, $action, $summary,
$changes ? json_encode($changes, JSON_UNESCAPED_UNICODE) : null, client_ip()]);
// Every parameter change is audited after it is written: drop the cached parameters here.
if (strpos($action, 'param') === 0) {
params_cache_clear();
}
}
// ---- Number formatting / parsing (Indonesian conventions) -------------------
function fmt_idr(?float $v): string
{
return $v === null ? '' : number_format($v, 0, ',', '.');
}
/** 0.0075 -> "0,75" */
function fmt_pct(?float $v): string
{
if ($v === null) {
return '';
}
$s = rtrim(rtrim(number_format($v * 100, 4, ',', ''), '0'), ',');
return $s === '' || $s === '-0' ? '0' : $s;
}
/** "1.000.000" -> 1000000.0; "" -> null */
function parse_idr($s): ?float
{
$s = preg_replace('/[^0-9]/', '', input_str($s));
return $s === '' ? null : (float)$s;
}
/** "0,75" or "0.75" -> 0.0075; "" -> null; invalid -> false */
function parse_pct($s)
{
$s = trim(str_replace([' ', '%'], '', input_str($s)));
if ($s === '') {
return null;
}
$s = str_replace(',', '.', $s);
if (!preg_match('/^-?\d+(\.\d+)?$/', $s)) {
return false;
}
return round(((float)$s) / 100, 8);
}
function fmt_setting(array $row): string
{
$v = (float)$row['value'];
switch ($row['unit']) {
case 'pct':
return fmt_pct($v);
case 'idr':
return fmt_idr($v);
default:
return rtrim(rtrim(number_format($v, 2, ',', ''), '0'), ',');
}
}
function fmt_datetime(?string $s): string
{
if (!$s) {
return '—';
}
$months = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des'];
$t = strtotime($s);
return date('j', $t) . ' ' . $months[(int)date('n', $t)] . ' ' . date('Y, H.i', $t);
}
// ---- Icons (Lucide, ISC licence) --------------------------------------------
function icon(string $name, string $class = 'icon'): string
{
static $paths = [
'printer' => '<path d="M6 9V2h12v7"/><path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"/><rect x="6" y="14" width="12" height="8"/>',
'copy' => '<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>',
'reset' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
'info' => '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
'alert' => '<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
'check' => '<path d="M20 6 9 17l-5-5"/>',
'lock' => '<rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
'logout' => '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="m16 17 5-5-5-5"/><path d="M21 12H9"/>',
'plus' => '<path d="M5 12h14"/><path d="M12 5v14"/>',
'trash' => '<path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>',
'users' => '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
'history' => '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l4 2"/>',
'sliders' => '<path d="M21 4h-7"/><path d="M10 4H3"/><path d="M21 12h-9"/><path d="M8 12H3"/><path d="M21 20h-5"/><path d="M12 20H3"/><path d="M14 2v4"/><path d="M8 10v4"/><path d="M16 18v4"/>',
'wallet' => '<path d="M19 7V4a1 1 0 0 0-1-1H5a2 2 0 0 0 0 4h15a1 1 0 0 1 1 1v4h-3a2 2 0 0 0 0 4h3a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1"/><path d="M3 5v14a2 2 0 0 0 2 2h15a1 1 0 0 0 1-1v-4"/>',
'grid' => '<rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/>',
'pie' => '<path d="M21.21 15.89A10 10 0 1 1 8 2.83"/><path d="M22 12A10 10 0 0 0 12 2v10z"/>',
'calc' => '<rect width="16" height="20" x="4" y="2" rx="2"/><path d="M8 6h8"/><path d="M16 14v4"/><path d="M16 10h.01"/><path d="M12 10h.01"/><path d="M8 10h.01"/><path d="M12 14h.01"/><path d="M8 14h.01"/><path d="M12 18h.01"/><path d="M8 18h.01"/>',
'sun' => '<circle cx="12" cy="12" r="4"/><path d="M12 2v2"/><path d="M12 20v2"/><path d="m4.93 4.93 1.41 1.41"/><path d="m17.66 17.66 1.41 1.41"/><path d="M2 12h2"/><path d="M20 12h2"/><path d="m6.34 17.66-1.41 1.41"/><path d="m19.07 4.93-1.41 1.41"/>',
'moon' => '<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z"/>',
'chevron' => '<path d="m6 9 6 6 6-6"/>',
'save' => '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8"/><path d="M7 3v5h8"/>',
'key' => '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
'external' => '<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h3"/>',
'menu' => '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h16"/>',
'x' => '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
];
return '<svg class="' . e($class) . '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' . ($paths[$name] ?? '') . '</svg>';
}
/**
* Official Bank Papua logo. 'auto' swaps the color logo for the white one in
* dark mode; 'white' is for navy surfaces; 'color' for paper/print.
* The images are decorative (alt=""); the surrounding link or heading carries the name.
*/
function bank_logo(string $variant = 'auto', string $class = ''): string
{
$img = function (string $file, string $cls): string {
return '<img class="logo ' . $cls . '" src="' . e(asset('assets/img/' . $file)) . '" alt="" width="589" height="120" decoding="async">';
};
$color = $img('logo-bank-papua.png', 'logo-color');
$white = $img('logo-bank-papua-white.png', 'logo-white');
$html = $variant === 'white' ? $white : ($variant === 'color' ? $color : $color . $white);
return '<span class="logo-wrap logo-' . e($variant) . ($class ? ' ' . e($class) : '') . '">' . $html . '</span>';
}
/** Inline script that applies the saved theme before first paint. */
function theme_boot_script(): string
{
return '<script nonce="' . csp_nonce() . '">' . "try{var t=localStorage.getItem('serubi-theme');if(t==='light'||t==='dark')document.documentElement.dataset.theme=t}catch(e){}</script>";
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/db.php';
const SERUBI_PRODUCTS = ['giro' => 'Giro Umum', 'simanja' => 'Tabungan SIMANJA', 'simpeda' => 'Tabungan SIMPEDA'];
const SERUBI_GROUPS = [
'dana' => ['Struktur dana', 'Bagaimana dana nasabah dibagi sebelum menghasilkan pendapatan.'],
'bunga' => ['Suku bunga pendapatan', 'Rate yang menghasilkan pendapatan bank dari dana kelolaan.'],
'biaya' => ['Biaya', 'Biaya yang mengurangi spread sebelum dibagi ke nasabah.'],
'pajak' => ['Pajak', 'Tarif pajak atas bunga dan cashback.'],
'konvensi' => ['Konvensi perhitungan', 'Hari bunga, pinalti, dan batas minimal.'],
];
/** Raw settings rows keyed by skey. */
function settings_rows(): array
{
$rows = db()->query('SELECT * FROM settings ORDER BY sort')->fetchAll();
return array_column($rows, null, 'skey');
}
function to_num($v): ?float
{
return $v === null ? null : (float)$v;
}
const PARAMS_CACHE_TTL = 300; // seconds; the cache is also cleared whenever an admin saves
function params_cache_file(): string
{
$dir = (string)(app_config()['app']['cache_dir'] ?? '');
if ($dir === '') {
$dir = sys_get_temp_dir() . '/serubi-' . substr(md5(__DIR__), 0, 12);
}
return rtrim($dir, '/\\') . '/params.json';
}
/** Parameters in the shape assets/js/engine.js expects (cached to spare the database). */
function load_params(): array
{
$file = params_cache_file();
if (is_file($file) && time() - (int)@filemtime($file) < PARAMS_CACHE_TTL) {
$cached = json_decode((string)@file_get_contents($file), true);
if (is_array($cached) && isset($cached['settings'])) {
return $cached;
}
}
$params = load_params_db();
$dir = dirname($file);
if (is_dir($dir) || @mkdir($dir, 0700, true)) {
$tmp = $file . '.' . bin2hex(random_bytes(4));
if (@file_put_contents($tmp, json_encode($params, JSON_PRESERVE_ZERO_FRACTION)) !== false) {
@rename($tmp, $file);
}
@unlink($tmp);
}
return $params;
}
function params_cache_clear(): void
{
@unlink(params_cache_file());
}
function load_params_db(): array
{
$pdo = db();
$settings = [];
$updated = null;
foreach (settings_rows() as $key => $row) {
$settings[$key] = (float)$row['value'];
$updated = max($updated ?? '', $row['updated_at']);
}
$tenors = $pdo->query('SELECT months, cashback FROM tenors ORDER BY months')->fetchAll();
$productRates = array_fill_keys(array_keys(SERUBI_PRODUCTS), []);
foreach ($pdo->query('SELECT product, max_nominal, rate FROM product_rates ORDER BY product, sort, id') as $r) {
$productRates[$r['product']][] = ['max' => to_num($r['max_nominal']), 'rate' => (float)$r['rate']];
}
$bands = $pdo->query('SELECT id, max_nominal FROM deposito_bands ORDER BY sort, id')->fetchAll();
$rates = [];
foreach ($pdo->query('SELECT band_id, tenor, rate FROM deposito_rates') as $r) {
$rates[$r['band_id']][$r['tenor']] = (float)$r['rate'];
}
$depositoRates = [];
foreach ($tenors as $t) {
$m = (int)$t['months'];
$depositoRates[$m] = array_map(fn($b) => [
'max' => to_num($b['max_nominal']),
'rate' => $rates[$b['id']][$m] ?? 0.0,
], $bands);
}
$tiers = array_map(fn($r) => ['min' => (float)$r['min_nominal'], 'nasabah' => (float)$r['share_nasabah']],
$pdo->query('SELECT min_nominal, share_nasabah FROM cashback_tiers ORDER BY min_nominal')->fetchAll());
$last = $pdo->query("SELECT username, created_at FROM audit_log WHERE action LIKE 'param%' ORDER BY id DESC LIMIT 1")->fetch();
return [
'settings' => $settings,
'tenors' => array_map(fn($t) => (int)$t['months'], $tenors),
'cashbackTenors' => array_values(array_map(fn($t) => (int)$t['months'], array_filter($tenors, fn($t) => (int)$t['cashback'] === 1))),
'productRates' => $productRates,
'depositoBands' => array_map(fn($b) => to_num($b['max_nominal']), $bands),
'depositoRates' => $depositoRates,
'cashbackTiers' => $tiers,
'updatedAt' => $last['created_at'] ?? $updated,
'updatedBy' => $last['username'] ?? null,
];
}
+153
View File
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
/*
* Security baseline, loaded first by every web entry point (via helpers.php):
* error handling, request-method guard, security headers, HTTPS detection,
* and hardened session settings.
*/
require_once __DIR__ . '/db.php';
function security_config(): array
{
return (app_config()['security'] ?? []) + [
'force_https' => false,
'trusted_proxies' => [],
'setup_key' => '',
];
}
/** True when the client reached us over HTTPS (directly or via a trusted reverse proxy). */
function is_https(): bool
{
if (!empty($_SERVER['HTTPS']) && strtolower((string)$_SERVER['HTTPS']) !== 'off') {
return true;
}
if ((int)($_SERVER['SERVER_PORT'] ?? 0) === 443) {
return true;
}
$proxies = security_config()['trusted_proxies'];
if ($proxies && in_array($_SERVER['REMOTE_ADDR'] ?? '', $proxies, true)) {
return strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https';
}
return false;
}
/** Client IP; X-Forwarded-For is honoured only when the request came from a trusted proxy. */
function client_ip(): string
{
$remote = (string)($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0');
$proxies = security_config()['trusted_proxies'];
if ($proxies && in_array($remote, $proxies, true) && !empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
$chain = array_map('trim', explode(',', (string)$_SERVER['HTTP_X_FORWARDED_FOR']));
// right-most address that is not one of our proxies
foreach (array_reverse($chain) as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP) && !in_array($ip, $proxies, true)) {
return substr($ip, 0, 45);
}
}
}
return substr($remote, 0, 45);
}
/** Per-request nonce for the few inline scripts (Content-Security-Policy). */
function csp_nonce(): string
{
static $nonce = null;
if ($nonce === null) {
$nonce = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '=');
}
return $nonce;
}
function send_security_headers(bool $sensitive = false): void
{
if (headers_sent()) {
return;
}
header_remove('X-Powered-By');
$csp = [
"default-src 'none'",
"script-src 'self' 'nonce-" . csp_nonce() . "'",
"style-src 'self'",
"img-src 'self'",
"font-src 'self'",
"connect-src 'self'",
"manifest-src 'self'",
"form-action 'self'",
"frame-ancestors 'none'",
"base-uri 'none'",
"object-src 'none'",
];
if (is_https()) {
$csp[] = 'upgrade-insecure-requests';
header('Strict-Transport-Security: max-age=31536000; includeSubDomains');
}
header('Content-Security-Policy: ' . implode('; ', $csp));
header('X-Frame-Options: DENY');
header('X-Content-Type-Options: nosniff');
header('Referrer-Policy: same-origin');
header('Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()');
header('Cross-Origin-Opener-Policy: same-origin');
header('Cross-Origin-Resource-Policy: same-origin');
header('X-Permitted-Cross-Domain-Policies: none');
// Pages always revalidate so they reference the newest versioned assets;
// admin pages are never stored at all.
header($sensitive ? 'Cache-Control: no-store, max-age=0' : 'Cache-Control: no-cache, must-revalidate');
if ($sensitive) {
header('Pragma: no-cache');
}
}
function security_bootstrap(): void
{
static $done = false;
if ($done || PHP_SAPI === 'cli') {
return;
}
$done = true;
// Never leak errors or stack traces to the client.
ini_set('display_errors', '0');
ini_set('display_startup_errors', '0');
ini_set('log_errors', '1');
ini_set('expose_php', '0');
error_reporting(E_ALL);
set_exception_handler(function (Throwable $e): void {
error_log('[serubi] ' . get_class($e) . ': ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
if (!headers_sent()) {
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
header('Cache-Control: no-store');
}
echo '<!doctype html><meta charset="utf-8"><title>Terjadi kesalahan</title><p>Terjadi kesalahan pada server. Silakan coba lagi beberapa saat lagi.</p>';
});
// Hardened session settings (before any session_start()).
ini_set('session.use_strict_mode', '1');
ini_set('session.use_only_cookies', '1');
ini_set('session.use_trans_sid', '0');
ini_set('session.cookie_httponly', '1');
ini_set('session.sid_length', '48');
ini_set('session.sid_bits_per_character', '5');
ini_set('session.gc_maxlifetime', '28800');
// Only GET, HEAD and POST are meaningful here (blocks TRACE/PUT/DELETE/OPTIONS...).
$method = strtoupper((string)($_SERVER['REQUEST_METHOD'] ?? 'GET'));
if (!in_array($method, ['GET', 'HEAD', 'POST'], true)) {
header('Allow: GET, HEAD, POST');
http_response_code(405);
exit;
}
// Optional HTTP -> HTTPS redirect for production.
if (security_config()['force_https'] && !is_https()) {
$host = preg_replace('/[^A-Za-z0-9.\-:\[\]]/', '', (string)($_SERVER['HTTP_HOST'] ?? ''));
$uri = (string)($_SERVER['REQUEST_URI'] ?? '/');
if ($host !== '' && $uri !== '' && $uri[0] === '/') {
header('Location: https://' . $host . $uri, true, 301);
exit;
}
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
/*
* Two-factor authentication for admins: TOTP (RFC 6238, SHA-1, 6 digits, 30 s),
* compatible with Google/Microsoft Authenticator. The shared secret is stored
* encrypted (AES-256-GCM) with security.app_key, which lives outside the
* document root. Recovery codes are stored as HMACs keyed with the same key.
*/
const TOTP_PERIOD = 30;
const TOTP_DIGITS = 6;
const TOTP_WINDOW = 1; // accept one step before/after for clock drift
const OTP_MAX_FAILS = 5; // wrong codes within LOGIN_WINDOW_MIN before the login is dropped
const RECOVERY_CODE_COUNT = 10;
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function app_key(): string
{
$key = base64_decode((string)(security_config()['app_key'] ?? ''), true);
if ($key === false || strlen($key) !== 32) {
throw new RuntimeException('security.app_key belum diisi (32 byte base64); dibutuhkan untuk OTP.');
}
return $key;
}
function base32_encode(string $bin): string
{
$bits = '';
foreach (str_split($bin) as $ch) {
$bits .= str_pad(decbin(ord($ch)), 8, '0', STR_PAD_LEFT);
}
$out = '';
foreach (str_split($bits, 5) as $chunk) {
$out .= BASE32_ALPHABET[bindec(str_pad($chunk, 5, '0'))];
}
return $out;
}
function base32_decode(string $b32): string
{
$bits = '';
foreach (str_split(strtoupper(preg_replace('/[^A-Za-z2-7]/', '', $b32))) as $ch) {
$bits .= str_pad(decbin(strpos(BASE32_ALPHABET, $ch)), 5, '0', STR_PAD_LEFT);
}
$out = '';
foreach (str_split($bits, 8) as $byte) {
if (strlen($byte) === 8) {
$out .= chr(bindec($byte));
}
}
return $out;
}
function totp_new_secret(): string
{
return base32_encode(random_bytes(20)); // 160-bit, as recommended by RFC 4226
}
function totp_code(string $secret, int $step): string
{
$hash = hash_hmac('sha1', pack('J', $step), base32_decode($secret), true);
$offset = ord($hash[19]) & 0x0f;
$bin = ((ord($hash[$offset]) & 0x7f) << 24) | (ord($hash[$offset + 1]) << 16)
| (ord($hash[$offset + 2]) << 8) | ord($hash[$offset + 3]);
return str_pad((string)($bin % (10 ** TOTP_DIGITS)), TOTP_DIGITS, '0', STR_PAD_LEFT);
}
/** The matching time step, or null. Steps at or before $lastStep are refused (no replay). */
function totp_verify(string $secret, string $code, ?int $lastStep): ?int
{
$code = preg_replace('/\D/', '', $code);
if (strlen($code) !== TOTP_DIGITS) {
return null;
}
$now = intdiv(time(), TOTP_PERIOD);
for ($s = $now - TOTP_WINDOW; $s <= $now + TOTP_WINDOW; $s++) {
if (($lastStep === null || $s > $lastStep) && hash_equals(totp_code($secret, $s), $code)) {
return $s;
}
}
return null;
}
function totp_uri(string $secret, string $username): string
{
$label = rawurlencode('SERUBI Bank Papua:' . $username);
return "otpauth://totp/$label?secret=$secret&issuer=" . rawurlencode('SERUBI Bank Papua') . '&period=' . TOTP_PERIOD . '&digits=' . TOTP_DIGITS;
}
function secret_encrypt(string $plain): string
{
$iv = random_bytes(12);
$tag = '';
$ct = openssl_encrypt($plain, 'aes-256-gcm', app_key(), OPENSSL_RAW_DATA, $iv, $tag);
return base64_encode($iv . $tag . $ct);
}
function secret_decrypt(string $stored): string
{
$raw = (string)base64_decode($stored, true);
$plain = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', app_key(), OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16));
if ($plain === false) {
throw new RuntimeException('OTP secret tidak dapat dibuka (app_key berubah?).');
}
return $plain;
}
function recovery_hash(string $code): string
{
return hash_hmac('sha256', strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code)), app_key());
}
/** Fresh one-time codes like "K7QD-M2XP": [plain codes to show once, hashes to store]. */
function recovery_codes_new(): array
{
$plain = [];
for ($i = 0; $i < RECOVERY_CODE_COUNT; $i++) {
$c = substr(base32_encode(random_bytes(5)), 0, 8);
$plain[] = substr($c, 0, 4) . '-' . substr($c, 4);
}
return [$plain, array_map('recovery_hash', $plain)];
}