POSA_LEAKSMS/php/login.php
cw2007 e0f0f0963a feat: cafe24 관리자 UI "Slate NOC Console" 디자인시스템 통합 + 적대리뷰 18건 반영
관리자 사이트 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. 라이트/다크 헤드리스 렌더 확인.
2026-07-04 22:32:16 +09:00

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>