POSA_LEAKSMS/php/admin_security.php
유창욱 90f121e14c chore: import codebase with security hardening
SHT30 온습도 모니터링 시스템 전체 소스(서버 PHP, STM32 펌웨어, SQL, 테스트).
전체 코드리뷰에서 도출된 보안 하드닝 10건 반영:
- 요청 서명 HMAC-SHA256 전환(펌웨어 sig.c/서버 config.php/호스트 패리티 동시)
- 재전송 방어 + 기본 API_KEY fail-closed + 디바이스 문자열 정제(api/sensor_data.php)
- 오프라인 SMS 중복 발송 경합 제거(cron_heartbeat.php, 원자적 선점)
- CSV 수식 주입 방지(monthly_report.php), 감사로그 회전 락(retention_cleanup.php)
- 브루트포스 카운터 원자화(login.php), 예시 TOTP 비밀키 무효화, 마이그레이션 멱등화

_backup/(하드코딩 실 비밀값 포함)·config.local.php·런타임 상태는 .gitignore 제외.
2026-06-20 09:37:40 +09:00

145 lines
4.5 KiB
PHP

<?php
// =============================================================================
// admin_security.php - shared administrator MFA, session, and audit helpers
// =============================================================================
function admin_base32_decode(string $secret): string {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$secret = strtoupper(preg_replace('/[^A-Z2-7]/i', '', $secret));
$bits = '';
$output = '';
for ($i = 0, $len = strlen($secret); $i < $len; $i++) {
$value = strpos($alphabet, $secret[$i]);
if ($value === false) {
continue;
}
$bits .= str_pad(decbin($value), 5, '0', STR_PAD_LEFT);
}
for ($i = 0, $len = strlen($bits); $i + 8 <= $len; $i += 8) {
$output .= chr(bindec(substr($bits, $i, 8)));
}
return $output;
}
function admin_base32_encode(string $bytes): string {
$alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
$bits = '';
$output = '';
for ($i = 0, $len = strlen($bytes); $i < $len; $i++) {
$bits .= str_pad(decbin(ord($bytes[$i])), 8, '0', STR_PAD_LEFT);
}
for ($i = 0, $len = strlen($bits); $i < $len; $i += 5) {
$chunk = substr($bits, $i, 5);
if (strlen($chunk) < 5) {
$chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
}
$output .= $alphabet[bindec($chunk)];
}
return $output;
}
function admin_totp_secret(int $bytes = 20): string {
return admin_base32_encode(random_bytes($bytes));
}
function admin_totp_uri(string $issuer, string $account, string $secret): string {
$issuer = trim($issuer) !== '' ? trim($issuer) : 'SHT30 SMS';
$account = trim($account) !== '' ? trim($account) : ADMIN_USER;
$label = rawurlencode($issuer . ':' . $account);
return 'otpauth://totp/' . $label
. '?secret=' . rawurlencode(strtoupper(preg_replace('/[^A-Z2-7]/i', '', $secret)))
. '&issuer=' . rawurlencode($issuer)
. '&algorithm=SHA1'
. '&digits=6'
. '&period=30';
}
function admin_hotp(string $secret, int $counter, int $digits = 6): string {
$key = admin_base32_decode($secret);
if ($key === '') {
return '';
}
$binaryCounter = pack('N2', intdiv($counter, 0x100000000), $counter & 0xffffffff);
$hash = hash_hmac('sha1', $binaryCounter, $key, true);
$offset = ord($hash[19]) & 0x0f;
$value = (
((ord($hash[$offset]) & 0x7f) << 24) |
((ord($hash[$offset + 1]) & 0xff) << 16) |
((ord($hash[$offset + 2]) & 0xff) << 8) |
(ord($hash[$offset + 3]) & 0xff)
);
return str_pad((string)($value % (10 ** $digits)), $digits, '0', STR_PAD_LEFT);
}
function admin_totp_code(string $secret, ?int $time = null, int $step = 30, int $digits = 6): string {
$time = $time ?? time();
return admin_hotp($secret, intdiv($time, $step), $digits);
}
function admin_totp_verify(string $secret, string $code, ?int $time = null, int $window = 1): bool {
$code = preg_replace('/\D+/', '', $code);
if ($secret === '' || !preg_match('/^\d{6}$/', $code)) {
return false;
}
$time = $time ?? time();
for ($i = -$window; $i <= $window; $i++) {
$candidate = admin_totp_code($secret, $time + ($i * 30));
if ($candidate !== '' && hash_equals($candidate, $code)) {
return true;
}
}
return false;
}
function admin_client_ip(): string {
return filter_var($_SERVER['REMOTE_ADDR'] ?? '0.0.0.0', FILTER_VALIDATE_IP) ?: '0.0.0.0';
}
function admin_audit_log(string $event, string $username = '', bool $success = true, array $detail = []): void {
$dir = __DIR__ . '/var';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
$safeDetail = [];
foreach ($detail as $key => $value) {
if (is_scalar($value) || $value === null) {
$safeDetail[(string)$key] = $value;
}
}
$record = [
'time' => date('c'),
'event' => $event,
'username' => substr($username, 0, 100),
'success' => $success,
'ip' => admin_client_ip(),
'user_agent' => substr($_SERVER['HTTP_USER_AGENT'] ?? '', 0, 255),
'detail' => $safeDetail,
];
@file_put_contents(
$dir . '/admin_audit.log',
json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . PHP_EOL,
FILE_APPEND | LOCK_EX
);
}
function admin_audit_log_path(): string {
return __DIR__ . '/var/admin_audit.log';
}
function admin_mfa_configured(): bool {
return defined('ADMIN_TOTP_SECRET') && ADMIN_TOTP_SECRET !== '';
}