관리자 사이트 7페이지의 파편화된 인라인 스타일(색상 리터럴 160개, 페이지별 <style>)을 단일 디자인시스템으로 통합. 신규 php/assets/app.css — "Slate NOC Console": - CSS 변수 단일 토큰(라이트/다크 prefers-color-scheme/인쇄 강제라이트). - 한글 시스템폰트 우선(폐쇄망 웹폰트 미로드 → 오프라인 일관 렌더), 외부 리소스 0. - 상태 3중 인코딩(색+도형[점/좌측레일/글리프]+텍스트) — 색맹·흑백 인쇄 대비. - WCAG AA 대비(토큰 쌍 실측), 본문/캡션 하한 .8125rem. 7페이지 리팩터(dashboard/login/monthly_report/security_evidence/setup_wizard/ setup_mfa/blocked): 인라인 <style> 제거→app.css 링크, PHP 색 삼항→클래스, 생 hex 0, PHP 로직 무변경. make_deploy_bundles.py: TEXT_EXTS += .css. 적대적 다차원 리뷰(미학·CSS아키텍처·통합일관성·a11y·slop) 확정 18건 반영: - [HIGH] .ok 배너 셀렉터가 .badge.ok/.quick-tile.ok 와 충돌 → 정상 배지가 사각형으로 깨지던 것 .banner-ok 네임스페이스로 수정. - 자동새로고침 일시정지 토글(WCAG 2.2.1)+스크롤/details 복원, 차트 임계선+ 최신값 마커, 다크 헤더 슬레이트화, 그림자 절제, 정상 레일 중립화(예외만 컬러), 지표칩 중립화+저습 .dry 분리, 진단 severity 텍스트 라벨, forced-colors 포커스, <main> 랜드마크, role=alert/status, ink-3 대비 상향, 인쇄 칩색 제거 등. 검증: 7페이지 php -l clean, 생 hex 0, 외부참조 0. 라이트/다크 헤드리스 렌더 확인.
210 lines
9.1 KiB
PHP
210 lines
9.1 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>
|
|
<link rel="stylesheet" href="assets/app.css">
|
|
<style>
|
|
/* 로그인 페이지 전용: 카드 화면 중앙 정렬 (그 외 크롬/폼 스타일은 app.css) */
|
|
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: var(--sp-4); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<main class="login-box">
|
|
<h1>누수감지 모니터링 <?= htmlspecialchars(APP_VERSION) ?></h1>
|
|
<p class="sub">대시보드에 접근하려면 로그인하세요.</p>
|
|
|
|
<?php if ($expired): ?>
|
|
<div class="expired" role="status">세션이 만료되었습니다. 다시 로그인해주세요.</div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($error): ?>
|
|
<div class="error" role="alert"><?= htmlspecialchars($error) ?></div>
|
|
<?php endif; ?>
|
|
|
|
<?php if ($configuration_error): ?>
|
|
<div class="error" role="alert">관리자 비밀번호 해시 또는 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>
|
|
</main>
|
|
</body>
|
|
</html>
|