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

+172
View File
@@ -0,0 +1,172 @@
<?php
declare(strict_types=1);
// Second login step (mandatory 2FA): verify the authenticator code, or set the
// authenticator up on the first login. Recovery codes are shown once afterwards.
require __DIR__ . '/../includes/auth.php';
send_security_headers(true);
// Recovery codes right after activation (already logged in, shown once).
if (current_admin() && isset($_GET['done'])) {
$codes = $_SESSION['recovery_once'] ?? null;
unset($_SESSION['recovery_once']);
if (!$codes) {
redirect(base_url('admin/'));
}
$mode = 'done';
} elseif (current_admin()) {
redirect(base_url('admin/'));
} else {
$pending = pending_login();
if (!$pending) {
redirect(base_url('admin/login.php?reason=expired'));
}
[$row, $mode] = $pending;
}
$error = null;
$configError = null;
try {
if ($mode === 'verify' && $_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
$code = strtoupper(trim(input_str($_POST['code'] ?? '')));
if (otp_failures($row['username']) >= OTP_MAX_FAILS || login_locked()) {
unset($_SESSION['pre_auth']);
redirect(base_url('admin/login.php?reason=otp'));
}
if (preg_match('/^[A-Z2-7]{4}-?[A-Z2-7]{4}$/', $code)) {
// One-time recovery code (lost phone)
$hashes = json_decode((string)$row['recovery_codes'], true) ?: [];
$i = array_search(recovery_hash($code), $hashes, true);
if ($i !== false) {
array_splice($hashes, (int)$i, 1);
db()->prepare('UPDATE admins SET recovery_codes = ? WHERE id = ?')->execute([json_encode($hashes), $row['id']]);
record_otp_attempt($row['username'], true);
complete_login($row, 'kode pemulihan');
flash('info', 'Anda masuk dengan kode pemulihan. Sisa kode: ' . count($hashes) . '. Minta super admin me-reset OTP bila ponsel hilang.');
redirect(base_url('admin/'));
}
} elseif ($step = totp_verify(secret_decrypt((string)$row['otp_secret']), $code, $row['otp_last_step'] !== null ? (int)$row['otp_last_step'] : null)) {
db()->prepare('UPDATE admins SET otp_last_step = ? WHERE id = ?')->execute([$step, $row['id']]);
record_otp_attempt($row['username'], true);
complete_login($row, 'OTP');
redirect(base_url('admin/'));
}
record_otp_attempt($row['username'], false);
$left = OTP_MAX_FAILS - otp_failures($row['username']);
$error = $left > 0 ? "Kode salah atau sudah dipakai. Sisa percobaan: $left." : 'Terlalu banyak kode salah.';
if ($left <= 0) {
unset($_SESSION['pre_auth']);
redirect(base_url('admin/login.php?reason=otp'));
}
}
if ($mode === 'enroll') {
app_key(); // fail early when the encryption key is not configured
$_SESSION['pre_auth']['exp'] = time() + 2 * PRE_AUTH_TTL; // time to install the app
if (empty($_SESSION['pre_auth']['secret'])) {
$_SESSION['pre_auth']['secret'] = totp_new_secret();
}
$secret = $_SESSION['pre_auth']['secret'];
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
csrf_check();
if (otp_failures($row['username']) >= OTP_MAX_FAILS || login_locked()) {
unset($_SESSION['pre_auth']);
redirect(base_url('admin/login.php?reason=otp'));
}
$step = totp_verify($secret, input_str($_POST['code'] ?? ''), null);
if ($step) {
[$plain, $hashes] = recovery_codes_new();
db()->prepare('UPDATE admins SET otp_secret = ?, otp_enabled = 1, otp_last_step = ?, recovery_codes = ? WHERE id = ?')
->execute([secret_encrypt($secret), $step, json_encode($hashes), $row['id']]);
record_otp_attempt($row['username'], true);
complete_login($row, 'OTP, aktivasi');
audit('user.otp', 'Mengaktifkan autentikasi dua faktor (OTP)');
$_SESSION['recovery_once'] = $plain;
redirect(base_url('admin/2fa.php?done=1'));
}
record_otp_attempt($row['username'], false);
$error = 'Kode tidak cocok. Pastikan jam ponsel sudah otomatis, lalu ketik kode yang sedang tampil.';
}
}
} catch (RuntimeException $ex) {
error_log('[serubi] 2fa: ' . $ex->getMessage());
$configError = 'Autentikasi dua faktor belum dapat dipakai karena konfigurasi server belum lengkap (security.app_key). Hubungi pengelola server.';
}
?>
<!doctype html>
<html lang="id">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark">
<meta name="robots" content="noindex">
<title>Verifikasi dua langkah · SERUBI</title>
<?= theme_boot_script() ?>
<link rel="icon" href="<?= e(asset('assets/favicon.svg')) ?>" type="image/svg+xml">
<link rel="stylesheet" href="<?= e(asset('assets/css/base.css')) ?>">
<link rel="stylesheet" href="<?= e(asset('assets/css/admin.css')) ?>">
</head>
<body class="page-login">
<main class="login">
<a class="brand" href="<?= e(base_url()) ?>" aria-label="Bank Papua SERUBI, kembali ke kalkulator"><?= bank_logo('auto', 'logo-lg') ?><span class="brand-prog">SERUBI</span></a>
<?php if ($mode === 'done'): ?>
<section class="login-card">
<h1>Simpan kode pemulihan</h1>
<p class="muted">Autentikasi dua faktor aktif. Bila ponsel hilang, masuk dengan salah satu kode di bawah (masing-masing hanya bisa dipakai sekali). Kode ini <strong>hanya ditampilkan sekarang</strong>: cetak atau simpan di tempat aman.</p>
<ol class="recovery-codes">
<?php foreach ($codes as $c): ?><li><code><?= e($c) ?></code></li><?php endforeach; ?>
</ol>
<a class="btn btn-primary btn-block" href="<?= e(base_url('admin/')) ?>"><?= icon('check') ?><span>Sudah saya simpan, lanjut ke admin</span></a>
</section>
<?php else: ?>
<form class="login-card" method="post" novalidate>
<?= csrf_field() ?>
<?php if ($mode === 'enroll'): ?>
<h1>Aktifkan autentikasi dua faktor</h1>
<p class="muted">Login admin wajib memakai kode OTP dari aplikasi authenticator (Google Authenticator, Microsoft Authenticator, atau sejenisnya).</p>
<?php else: ?>
<h1>Verifikasi dua langkah</h1>
<p class="muted">Ketik 6 digit kode dari aplikasi authenticator untuk akun <strong>@<?= e($row['username']) ?></strong>.</p>
<?php endif; ?>
<?php if ($configError): ?>
<p class="alert alert-error" role="alert"><?= icon('alert') ?><span><?= e($configError) ?></span></p>
<?php else: ?>
<?php if ($error): ?>
<p class="alert alert-error" role="alert"><?= icon('alert') ?><span><?= e($error) ?></span></p>
<?php endif; ?>
<?php if ($mode === 'enroll'): ?>
<ol class="otp-steps">
<li>Buka aplikasi authenticator, pilih tambah akun, lalu pindai QR code ini.
<div class="otp-qr" data-otpauth="<?= e(totp_uri($secret, $row['username'])) ?>" role="img" aria-label="QR code untuk aplikasi authenticator"></div>
</li>
<li>Tidak bisa memindai? Masukkan kunci ini secara manual (tipe: berbasis waktu):
<code class="otp-secret"><?= e(trim(chunk_split($secret, 4, ' '))) ?></code>
</li>
<li>Ketik 6 digit kode yang muncul di aplikasi.</li>
</ol>
<?php endif; ?>
<div class="form-field">
<label for="code"><?= $mode === 'enroll' ? 'Kode dari aplikasi' : 'Kode OTP' ?></label>
<input id="code" name="code" required autofocus autocomplete="one-time-code" inputmode="<?= $mode === 'enroll' ? 'numeric' : 'text' ?>" maxlength="9" spellcheck="false" autocapitalize="characters">
<?php if ($mode === 'verify'): ?><p class="hint">Ponsel hilang? Ketik salah satu kode pemulihan (format XXXX-XXXX).</p><?php endif; ?>
</div>
<button class="btn btn-primary btn-block" type="submit"><?= icon('lock') ?><span><?= $mode === 'enroll' ? 'Aktifkan & masuk' : 'Verifikasi' ?></span></button>
<?php endif; ?>
</form>
<form method="post" action="<?= e(base_url('admin/logout.php')) ?>">
<?= csrf_field() ?>
<button type="submit" class="back-link text-btn"><?= icon('x') ?><span>Batal</span></button>
</form>
<?php endif; ?>
</main>
<script src="<?= e(asset('assets/js/lib/qrcode.js')) ?>"></script>
<script src="<?= e(asset('assets/js/otp.js')) ?>"></script>
<script src="<?= e(asset('assets/js/theme.js')) ?>"></script>
</body>
</html>