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 제외.
152 lines
8.6 KiB
PHP
152 lines
8.6 KiB
PHP
<?php
|
|
// =============================================================================
|
|
// config.php - 데이터베이스 및 공통 설정 (v2605)
|
|
//
|
|
// 운영 비밀값은 config.local.php 또는 환경 변수로 주입합니다.
|
|
// config.local.php는 이 파일과 같은 디렉터리에 두며, 배열을 return 합니다.
|
|
// 예: return ['DB_PASS' => '...', 'API_KEY' => '...'];
|
|
// =============================================================================
|
|
|
|
define('APP_VERSION', 'v2606');
|
|
|
|
$local_config_file = __DIR__ . '/config.local.php';
|
|
$local_config = is_file($local_config_file) ? require $local_config_file : [];
|
|
|
|
function cfg(string $key, $default = '') {
|
|
global $local_config;
|
|
if (array_key_exists($key, $local_config)) {
|
|
return $local_config[$key];
|
|
}
|
|
|
|
$env = getenv($key);
|
|
return $env !== false ? $env : $default;
|
|
}
|
|
|
|
// ─── DB 설정 ───────────────────────────────────────────────
|
|
define('DB_HOST', cfg('DB_HOST', 'localhost'));
|
|
define('DB_PORT', (int)cfg('DB_PORT', 3306));
|
|
define('DB_NAME', cfg('DB_NAME', 'your_db_name'));
|
|
define('DB_USER', cfg('DB_USER', 'your_db_user'));
|
|
define('DB_PASS', cfg('DB_PASS', 'change-me'));
|
|
define('DB_CHARSET', cfg('DB_CHARSET', 'utf8mb4'));
|
|
|
|
// ─── API 보안 키 (STM32 펌웨어 secrets.h의 API_KEY와 동일하게 설정) ───
|
|
define('API_KEY', cfg('API_KEY', 'change-this-api-key'));
|
|
|
|
// 운영 비밀값이 예시 기본값 그대로인지(=교체되지 않았는지) 판정. 네트워크 경계
|
|
// (api/sensor_data.php)가 부팅/요청 시 기본 API_KEY 로는 수신을 거부하도록 한다.
|
|
define('API_KEY_DEFAULT', 'change-this-api-key');
|
|
function api_key_is_default(): bool {
|
|
return !defined('API_KEY') || API_KEY === '' || API_KEY === API_KEY_DEFAULT;
|
|
}
|
|
|
|
// 수신 본문 timestamp 허용 시계 오차(초). 이 범위를 벗어난(과거/미래) 서명 요청은
|
|
// 재전송(replay) 방어를 위해 거부한다. SHT30 보고 주기(5분) 대비 넉넉히 둔다.
|
|
define('INGEST_MAX_CLOCK_SKEW_SEC', (int)cfg('INGEST_MAX_CLOCK_SKEW_SEC', 600));
|
|
|
|
// ─── SMS 설정 (Cafe24 SMS API) ──────────────────────────────
|
|
define('SMS_USER_ID', cfg('SMS_USER_ID', 'your-cafe24-sms-user-id'));
|
|
define('SMS_SECURE', cfg('SMS_SECURE', 'your-cafe24-sms-secure-key'));
|
|
define('SMS_SENDER', cfg('SMS_SENDER', '01000000000'));
|
|
define('SMS_ENDPOINT', cfg('SMS_ENDPOINT', 'https://sslsms.cafe24.com/sms_sender.php'));
|
|
|
|
// SMS 수신자 목록 (복수 가능)
|
|
$sms_recipients = cfg('SMS_RECIPIENTS', ['01000000000']);
|
|
if (is_string($sms_recipients)) {
|
|
$sms_recipients = array_filter(array_map('trim', explode(',', $sms_recipients)));
|
|
}
|
|
define('SMS_RECIPIENTS', $sms_recipients);
|
|
|
|
// ─── 온습도 임계 경보 (고온/저온/고습/저습 → SMS) ──────────────────────────
|
|
// 측정값이 아래 운영 임계를 벗어나면 api/sensor_data.php 가 종류별 SMS를 보냅니다.
|
|
// 임계 판정은 펌웨어가 아니라 서버에서 하므로, 폐쇄망에서도 재플래시 없이
|
|
// config.local.php 로 값을 조정할 수 있습니다.
|
|
define('METRIC_TEMP_HIGH_C', (float)cfg('METRIC_TEMP_HIGH_C', 30)); // 고온 임계(℃)
|
|
define('METRIC_TEMP_LOW_C', (float)cfg('METRIC_TEMP_LOW_C', 10)); // 저온 임계(℃)
|
|
define('METRIC_RH_HIGH', (float)cfg('METRIC_RH_HIGH', 70)); // 고습 임계(%)
|
|
define('METRIC_RH_LOW', (float)cfg('METRIC_RH_LOW', 20)); // 저습 임계(%)
|
|
// 복구(정상 회복) 히스테리시스: 임계 안쪽으로 이 여유만큼 들어와야 복구로 간주(경계 채터링 방지)
|
|
define('METRIC_TEMP_HYSTERESIS_C', (float)cfg('METRIC_TEMP_HYSTERESIS_C', 1.0));
|
|
define('METRIC_RH_HYSTERESIS', (float)cfg('METRIC_RH_HYSTERESIS', 3.0));
|
|
// 동일 종류 경보 재발송 억제 쿨다운(초). 기본 30분.
|
|
define('METRIC_ALERT_COOLDOWN_SEC', (int)cfg('METRIC_ALERT_COOLDOWN_SEC', 1800));
|
|
|
|
// ─── Heartbeat (장비 오프라인 감지) ────────────────────────
|
|
// SHT30 측정 보고 주기는 5분(펌웨어 APP_SHT30_REPORT_INTERVAL_SEC=300). 기본
|
|
// 오프라인 판정은 약 4회 누락(20분)으로 둔다. config.local.php 로 조정 가능.
|
|
define('HEARTBEAT_TIMEOUT_SEC', (int)cfg('HEARTBEAT_TIMEOUT_SEC', 1200));
|
|
define('HEARTBEAT_CHECK_INTERVAL_SEC', (int)cfg('HEARTBEAT_CHECK_INTERVAL_SEC', 300)); // 오프라인 체크 실행 주기
|
|
define('CRON_SECRET', md5('cron_' . API_KEY)); // cron HTTP 접근용 시크릿
|
|
|
|
// ─── 대시보드 세션 인증 ──────────────────────────────────
|
|
define('ADMIN_USER', cfg('ADMIN_USER', 'admin'));
|
|
define('ADMIN_PASSWORD_HASH', cfg('ADMIN_PASSWORD_HASH', '')); // bcrypt 해시
|
|
define('ADMIN_TOTP_SECRET', cfg('ADMIN_TOTP_SECRET', '')); // TOTP base32 secret for authenticator apps
|
|
define('MFA_SETUP_TOKEN', cfg('MFA_SETUP_TOKEN', '')); // temporary setup token for setup_mfa.php bootstrap
|
|
define('SESSION_TIMEOUT', 3600); // 1시간
|
|
|
|
// ─── Retention policy ────────────────────────────────────────────────────────
|
|
define('SMS_LOG_RETENTION_DAYS', (int)cfg('SMS_LOG_RETENTION_DAYS', 365));
|
|
define('SENSOR_LOG_RETENTION_DAYS', (int)cfg('SENSOR_LOG_RETENTION_DAYS', 365));
|
|
define('SENSOR_METRIC_RETENTION_DAYS', (int)cfg('SENSOR_METRIC_RETENTION_DAYS', 365));
|
|
define('ADMIN_AUDIT_RETENTION_DAYS', (int)cfg('ADMIN_AUDIT_RETENTION_DAYS', 365));
|
|
|
|
// ─── 타임존 ───────────────────────────────────────────────
|
|
date_default_timezone_set('Asia/Seoul');
|
|
|
|
// ─── DB 연결 함수 ──────────────────────────────────────────
|
|
function get_db(): PDO {
|
|
static $pdo = null;
|
|
if ($pdo === null) {
|
|
$dsn = sprintf(
|
|
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
|
|
DB_HOST, DB_PORT, DB_NAME, DB_CHARSET
|
|
);
|
|
$pdo = new PDO($dsn, DB_USER, DB_PASS, [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
|
PDO::ATTR_EMULATE_PREPARES => false,
|
|
]);
|
|
}
|
|
return $pdo;
|
|
}
|
|
|
|
// ─── JSON 응답 헬퍼 ───────────────────────────────────────
|
|
function json_response(array $data, int $http_code = 200): void {
|
|
http_response_code($http_code);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
echo json_encode($data, JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
// ─── 서명 검증 (레거시: 본문 필드 기반, DEPRECATED) ───────
|
|
// 디코드된 데이터를 ksort 후 재직렬화하여 해시한다. 과거 RPi Python 클라이언트
|
|
// 전용의 하위 호환 경로이며, 현재 운영 클라이언트(STM32 펌웨어)는 사용하지 않는다.
|
|
// 키 접두 sha256 이라 길이확장에 약하므로 신규 클라이언트는 절대 사용하지 말 것.
|
|
// (남은 레거시 클라이언트가 없으면 차후 제거 가능.)
|
|
function verify_signature(array $data): bool {
|
|
if (empty($data['signature'])) return false;
|
|
$received_sig = $data['signature'];
|
|
unset($data['signature']);
|
|
ksort($data);
|
|
$payload = API_KEY . json_encode($data, JSON_UNESCAPED_UNICODE);
|
|
$expected = hash('sha256', $payload);
|
|
return hash_equals($expected, $received_sig);
|
|
}
|
|
|
|
// ─── 서명 검증 (신규: raw-body 기반, HMAC-SHA256) ──────────
|
|
// 요청 본문 바이트 전체에 대해 HMAC-SHA256(key=API_KEY, msg=raw) 를 계산하고
|
|
// X-Signature 헤더 값과 상수시간 비교한다.
|
|
//
|
|
// JSON 재직렬화(키 정렬·부동소수 포맷·슬래시 이스케이프)에 의존하지 않으므로
|
|
// MCU(STM32 등)에서 부동소수 표기 차이로 서명이 불일치하던 문제를 제거한다.
|
|
// 키 접두(sha256(key.body)) 대신 HMAC 을 써서 길이확장 공격에 견딘다.
|
|
// 펌웨어 sig.c(HMAC) / reference.py(hmac.new) 와 바이트 단위로 일치한다.
|
|
// 클라이언트는 전송하는 본문 바이트 그대로를 서명하기만 하면 된다.
|
|
function verify_signature_raw(string $raw_body): bool {
|
|
$received_sig = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
|
|
if ($received_sig === '') return false;
|
|
$expected = hash_hmac('sha256', $raw_body, API_KEY);
|
|
return hash_equals($expected, $received_sig);
|
|
}
|
|
|