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 제외.
145 lines
4.5 KiB
PHP
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 !== '';
|
|
}
|