2번 모듈 신규(raspberry_pi4_sht30/): SHT30 온습도 → cafe24, raw-body X-Signature HMAC, 폐쇄망 오프라인 설치(vendor/install), systemd, CRC/포맷 자가점검. firmware sht30_fw wire 포맷과 byte-parity 검증. 1번 모듈 통합(raspberry_pi4/): 누수+카메라를 동일 서버로. 레거시 sha256(key+json) body-sig → X-Signature HMAC 전환, 사진 canonical=device_id|sensor_id|log_id|timestamp. 서버 통합(php/): api/sensor_data.php 누수 이벤트 수신, api/photo_upload.php 신규(HMAC·finfo·서버생성 파일명·nonce 멱등·sensor 결속), photo.php 인증 사진서빙, dashboard.php 누수 패널+사진, sms_send.php 누수 SMS, config.php 태그/요약. DB: sql/migration_add_leak.sql(가산·멱등), schema_sht30.sql 통합 스키마. 보안 하드닝(외부 펜테스트 대비, 확정 CRITICAL/HIGH 0): cron_heartbeat api_key fail-closed, setup_mfa 부트스트랩 토큰 post-enrollment 잠금, var/ PHP 실행차단, HSTS. 문서/테스트: SYSTEM_OVERVIEW.md, security_hardening_test.php 갱신. 참고: 사용자 요청으로 세션 외 기존 미커밋 변경도 함께 포함. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
245 lines
11 KiB
PHP
245 lines
11 KiB
PHP
<?php
|
|
// =============================================================================
|
|
// login.php - 대시보드 세션 인증 로그인 페이지
|
|
// =============================================================================
|
|
|
|
// ── 프로덕션: 에러 표시 비활성화 ─────────────────────────
|
|
ini_set('display_errors', 0);
|
|
error_reporting(E_ALL);
|
|
ini_set('log_errors', 1);
|
|
|
|
require_once __DIR__ . '/config.php';
|
|
require_once __DIR__ . '/admin_security.php';
|
|
require_once __DIR__ . '/sms_send.php';
|
|
require_once __DIR__ . '/cron_heartbeat.php';
|
|
|
|
// ── 세션 보안 설정 ───────────────────────────────────────────
|
|
ini_set('session.cookie_httponly', 1);
|
|
ini_set('session.cookie_secure', 1);
|
|
ini_set('session.cookie_samesite', 'Strict');
|
|
ini_set('session.use_strict_mode', 1);
|
|
session_start();
|
|
|
|
// 이미 로그인된 경우 대시보드로 리다이렉트
|
|
if (!empty($_SESSION['logged_in']) && $_SESSION['logged_in'] === true) {
|
|
header('Location: dashboard.php');
|
|
exit;
|
|
}
|
|
|
|
$error = '';
|
|
$expired = isset($_GET['expired']);
|
|
$configuration_error = (ADMIN_PASSWORD_HASH === '' || !admin_mfa_configured());
|
|
|
|
// ── 브루트포스 방어: 파일 기반 IP별 실패 횟수 추적 ──────────
|
|
$lockout_file = __DIR__ . '/var/login_attempts.json';
|
|
$max_attempts = 5; // 최대 연속 실패 허용
|
|
$lockout_sec = 900; // 잠금 시간 (15분)
|
|
$client_ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0';
|
|
|
|
function load_attempts(string $file): array {
|
|
if (!file_exists($file)) return [];
|
|
$data = @json_decode(file_get_contents($file), true);
|
|
return is_array($data) ? $data : [];
|
|
}
|
|
|
|
// 원자적 읽기-수정-쓰기. 배타 락(flock LOCK_EX) 아래에서 최신 상태를 다시 읽고
|
|
// $mutator(array &$data) 로 변경한 뒤 기록한다. 동시 실패 로그인이 카운트를
|
|
// 덮어써(lost update) 잠금이 무력화되는 경합을 막는다. 갱신된 배열을 반환한다.
|
|
function update_attempts(string $file, callable $mutator): array {
|
|
$fh = @fopen($file, 'c+');
|
|
if ($fh !== false && flock($fh, LOCK_EX)) {
|
|
$raw = stream_get_contents($fh);
|
|
$decoded = ($raw !== false && $raw !== '') ? json_decode($raw, true) : [];
|
|
$data = is_array($decoded) ? $decoded : [];
|
|
$mutator($data);
|
|
ftruncate($fh, 0);
|
|
rewind($fh);
|
|
fwrite($fh, json_encode($data));
|
|
fflush($fh);
|
|
flock($fh, LOCK_UN);
|
|
fclose($fh);
|
|
return $data;
|
|
}
|
|
|
|
// 파일을 못 열었거나(예: 권한) 락을 못 얻었으면(예: flock 미지원 FS / NFS)
|
|
// 가용성 우선으로 잠금 없이 best-effort 갱신한다. 락 실패 시에도 변경(증가)이
|
|
// 반드시 반영·반환되도록 하여, 카운터가 멈춰 무제한 시도가 허용되지 않게 한다.
|
|
if ($fh !== false) {
|
|
fclose($fh);
|
|
}
|
|
$data = load_attempts($file);
|
|
$mutator($data);
|
|
@file_put_contents($file, json_encode($data), LOCK_EX);
|
|
return $data;
|
|
}
|
|
|
|
$attempts = load_attempts($lockout_file);
|
|
$is_locked = false;
|
|
|
|
if (isset($attempts[$client_ip])) {
|
|
$rec = $attempts[$client_ip];
|
|
// 잠금 시간 경과 시 초기화
|
|
if ($rec['count'] >= $max_attempts && (time() - $rec['last']) < $lockout_sec) {
|
|
$is_locked = true;
|
|
$remaining = $lockout_sec - (time() - $rec['last']);
|
|
$error = "로그인 시도가 너무 많습니다. {$remaining}초 후 다시 시도하세요.";
|
|
} elseif ($rec['count'] >= $max_attempts && (time() - $rec['last']) >= $lockout_sec) {
|
|
$attempts = update_attempts($lockout_file, function (array &$a) use ($client_ip) {
|
|
unset($a[$client_ip]);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── CSRF 토큰 생성 ──────────────────────────────────────────
|
|
if (empty($_SESSION['csrf_token'])) {
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
}
|
|
|
|
// POST 로그인 처리
|
|
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !$is_locked && !$configuration_error) {
|
|
// CSRF 검증
|
|
$submitted_token = $_POST['csrf_token'] ?? '';
|
|
if (!hash_equals($_SESSION['csrf_token'], $submitted_token)) {
|
|
$error = '잘못된 요청입니다. 페이지를 새로고침하세요.';
|
|
} else {
|
|
$username = $_POST['username'] ?? '';
|
|
$password = $_POST['password'] ?? '';
|
|
$otp_code = $_POST['otp_code'] ?? '';
|
|
|
|
// 비밀번호 검증: bcrypt 해시
|
|
$pw_ok = password_verify($password, ADMIN_PASSWORD_HASH);
|
|
$mfa_ok = $pw_ok && hash_equals(ADMIN_USER, $username) && admin_totp_verify(ADMIN_TOTP_SECRET, $otp_code);
|
|
|
|
if (hash_equals(ADMIN_USER, $username) && $pw_ok && $mfa_ok) {
|
|
// 성공: 실패 기록 초기화
|
|
update_attempts($lockout_file, function (array &$a) use ($client_ip) {
|
|
unset($a[$client_ip]);
|
|
});
|
|
|
|
session_regenerate_id(true);
|
|
$_SESSION['logged_in'] = true;
|
|
$_SESSION['login_time'] = time(); // 절대 수명 기준(이후 갱신하지 않음)
|
|
$_SESSION['last_activity'] = time(); // 유휴 타이머 기준(활동 시 갱신)
|
|
$_SESSION['username'] = $username;
|
|
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
|
|
admin_audit_log('login_success', $username, true);
|
|
|
|
// 로그인 시 센서 오프라인 체크 즉시 실행 (쓰로틀 없이)
|
|
check_offline_sensors();
|
|
|
|
header('Location: dashboard.php');
|
|
exit;
|
|
} else {
|
|
// 실패: 카운트 증가(원자적 읽기-수정-쓰기로 lost update 방지)
|
|
$attempts = update_attempts($lockout_file, function (array &$a) use ($client_ip) {
|
|
if (!isset($a[$client_ip])) {
|
|
$a[$client_ip] = ['count' => 0, 'last' => 0];
|
|
}
|
|
$a[$client_ip]['count']++;
|
|
$a[$client_ip]['last'] = time();
|
|
});
|
|
|
|
$attempt_count = $attempts[$client_ip]['count'] ?? $max_attempts;
|
|
$left = $max_attempts - $attempt_count;
|
|
if ($left > 0) {
|
|
$error = "아이디 또는 비밀번호가 올바르지 않습니다. (남은 시도: {$left}회)";
|
|
} else {
|
|
$error = "로그인 시도가 너무 많습니다. {$lockout_sec}초 후 다시 시도하세요.";
|
|
}
|
|
|
|
error_log("[LOGIN] 로그인 실패 - IP: {$client_ip}, User: {$username}");
|
|
admin_audit_log('login_failure', $username, false, [
|
|
'password_ok' => $pw_ok,
|
|
'mfa_present' => preg_replace('/\D+/', '', $otp_code) !== '',
|
|
]);
|
|
}
|
|
}
|
|
}
|
|
?>
|
|
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>로그인 - 누수감지 대시보드 <?= htmlspecialchars(APP_VERSION) ?></title>
|
|
<style>
|
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
body { font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #333;
|
|
display: flex; align-items: center; justify-content: center; min-height: 100vh; }
|
|
.login-box { background: #fff; border-radius: 12px; padding: 40px 36px;
|
|
box-shadow: 0 4px 24px rgba(0,0,0,.1); width: 100%; max-width: 400px; }
|
|
.login-box h1 { font-size: 1.4rem; color: #1a56db; margin-bottom: 8px; text-align: center; }
|
|
.login-box p.sub { font-size: .85rem; color: #64748b; text-align: center; margin-bottom: 28px; }
|
|
.form-group { margin-bottom: 18px; }
|
|
.form-group label { display: block; font-size: .85rem; color: #475569; margin-bottom: 6px; font-weight: 600; }
|
|
.form-group input { width: 100%; padding: 10px 14px; border: 1px solid #d1d5db; border-radius: 8px;
|
|
font-size: .95rem; outline: none; transition: border-color .2s; }
|
|
.form-group input:focus { border-color: #1a56db; box-shadow: 0 0 0 3px rgba(26,86,219,.1); }
|
|
.btn { width: 100%; padding: 12px; background: #1a56db; color: #fff; border: none;
|
|
border-radius: 8px; font-size: 1rem; font-weight: 600; cursor: pointer;
|
|
transition: background .2s; }
|
|
.btn:hover { background: #1347b8; }
|
|
.btn:disabled { background: #94a3b8; cursor: not-allowed; }
|
|
.error { background: #fef2f2; color: #991b1b; padding: 10px 14px; border-radius: 8px;
|
|
font-size: .85rem; margin-bottom: 18px; border: 1px solid #fecaca; }
|
|
.expired { background: #fefce8; color: #854d0e; padding: 10px 14px; border-radius: 8px;
|
|
font-size: .85rem; margin-bottom: 18px; border: 1px solid #fde68a; }
|
|
|
|
/* --- 폰 (≤480px) --- */
|
|
@media (max-width: 480px) {
|
|
.login-box { padding: 28px 20px; margin: 0 12px; }
|
|
.login-box h1 { font-size: 1.2rem; }
|
|
.login-box p.sub { font-size: .8rem; margin-bottom: 20px; }
|
|
.form-group input { font-size: .9rem; padding: 9px 12px; }
|
|
.btn { font-size: .95rem; padding: 11px; }
|
|
}
|
|
|
|
/* --- 초소형 (≤360px) --- */
|
|
@media (max-width: 360px) {
|
|
.login-box { padding: 24px 16px; margin: 0 8px; }
|
|
.login-box h1 { font-size: 1.1rem; }
|
|
.form-group input { font-size: .85rem; padding: 8px 10px; }
|
|
.btn { font-size: .9rem; padding: 10px; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="login-box">
|
|
<h1>누수감지 모니터링 <?= htmlspecialchars(APP_VERSION) ?></h1>
|
|
<p class="sub">대시보드에 접근하려면 로그인하세요.</p>
|
|
|
|
<?php if ($expired): ?>
|
|
<div class="expired">세션이 만료되었습니다. 다시 로그인해주세요.</div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($error): ?>
|
|
<div class="error"><?= htmlspecialchars($error) ?></div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($configuration_error): ?>
|
|
<div class="error">관리자 비밀번호 해시 또는 MFA 인증앱 비밀키가 설정되지 않았습니다. config.local.php의 ADMIN_PASSWORD_HASH와 ADMIN_TOTP_SECRET을 확인하세요.</div>
|
|
<?php endif; ?>
|
|
|
|
<form method="POST" action="">
|
|
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
|
|
<div class="form-group">
|
|
<label for="username">아이디</label>
|
|
<input type="text" id="username" name="username" required autofocus
|
|
autocomplete="username"
|
|
value="<?= htmlspecialchars($_POST['username'] ?? '') ?>">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="password">비밀번호</label>
|
|
<input type="password" id="password" name="password" required autocomplete="current-password">
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="otp_code">인증 앱 6자리 코드</label>
|
|
<input type="text" id="otp_code" name="otp_code" required
|
|
inputmode="numeric" pattern="[0-9]{6}" maxlength="6"
|
|
autocomplete="one-time-code">
|
|
</div>
|
|
<button type="submit" class="btn" <?= ($is_locked || $configuration_error) ? 'disabled' : '' ?>>로그인</button>
|
|
</form>
|
|
</div>
|
|
</body>
|
|
</html>
|