차량 모니터 설정 인증 관리자 세션으로 정리
This commit is contained in:
1 parent
5d75ad81bd
commit
11bd70b65f
2 files changed
+28
-186
No files matched your search
@@ -124,6 +124,7 @@ Worker 교체나 예외 종료로 `processing`, `ui_timeout_pending` 상태가
|
||||
|
||||
- 차량 API는 API token 또는 허용 IP 정책을 사용합니다.
|
||||
- 차량 모니터 화면과 `monitor.php?mode=ajax`, `monitor.php?mode=usage`는 런처 공통 인증 쿠키 또는 사이트 관리자 세션이 유효할 때만 응답합니다.
|
||||
- `monitor.php?mode=usage-calibration`은 사이트 관리자 세션이 확인될 때만 응답합니다. 일반 PIN 쿠키로 들어온 사용자는 화면에서 설정 아이콘이 보이지 않고, 직접 API를 호출해도 차단됩니다.
|
||||
- 공통 암호로 로그인한 일반 인증은 30분 유지하고, 사이트 관리자 세션이 확인되면 관리자 장기 쿠키를 발급해 PWA에서 반복 인증이 뜨는 일을 줄입니다.
|
||||
- PWA로 설치한 차량 모니터는 manifest 시작 URL `/car/monitor.php?pwa=1`로 진입합니다. 이 요청은 기존 공통 인증 쿠키가 있으면 유지하고, 사이트 관리자 세션이면 관리자 쿠키를 발급한 뒤 `/car/monitor.php`로 되돌아가므로 설치형 앱 진입 때 반복 PIN 화면이 뜨는 일을 줄입니다.
|
||||
- 런처 공통 인증은 우선 공통 인증 모듈로 직접 확인합니다. 공통 모듈을 사용할 수 없는 경우에만 `https://chaegeon.com/custom/launcher/api.php?session=1` 응답으로 확인합니다.
|
||||
@@ -139,7 +140,7 @@ Worker 교체나 예외 종료로 `processing`, `ui_timeout_pending` 상태가
|
||||
- 명령별 rate limit과 감사 로그를 유지합니다.
|
||||
- 통신사 기준 데이터 사용량 보정값을 주기적으로 확인합니다. 2026-06-30 15:15 T world 실측 114.5MB(117,250.5KB) 기준 2026-06 보정값은 통신사 누적 120,064,512 bytes, 통신사 과금 단위 추정 누적 129,148,416 bytes, 배율 0.929663입니다.
|
||||
- 2026-07-02 12:42 T world 실측 6.25MB(6,396KB) 기준 2026-07 보정값은 통신사 누적 6,549,504 bytes, 통신사 과금 단위 추정 누적 7,169,536 bytes, 배율 0.913519입니다.
|
||||
- 관리자 인증 상태에서는 상단 우측 설정 아이콘으로 T world 사용량 기준점을 저장할 수 있습니다. 입력값은 `car_usage_calibrations` 테이블에 저장되고, 같은 월에서는 가장 최근 기준 시각의 값을 우선 사용합니다.
|
||||
- 관리자 인증 상태에서는 상단 우측 설정 아이콘으로 T world 사용량 기준점을 저장할 수 있습니다. 설정 아이콘은 일반 PIN 인증 상태에서는 숨겨지고, 관리자 상태에서는 별도 비밀번호 재입력 없이 바로 열립니다. 입력값은 `car_usage_calibrations` 테이블에 저장되고, 같은 월에서는 가장 최근 기준 시각의 값을 우선 사용합니다.
|
||||
- 해당 월의 T world 기준점이 DB나 코드 기준점에 있으면 그 월 배율을 우선 사용하고, 아직 기준점이 없는 새 달은 가장 최근 월 배율을 임시로 사용합니다. 새 기준점이 기록되면 해당 월 배율로 다시 계산합니다.
|
||||
- 2026-06 기준 시각의 로컬 계측 범위는 2026-06-01 00:00:00부터 2026-06-30 15:15:00 직전까지이며, 마지막 로컬 사용량 기록은 2026-06-30 15:14:59입니다.
|
||||
- 데이터 사용량은 TCP payload 합계도 보존하지만 화면의 현재 사용량, 잔여 사용량, 예상 사용량, 예상 요금은 0.5KB 과금 단위로 올림 처리한 통신사형 누적값을 우선 사용합니다. 순수 연결 실패와 마지막 성공 이후 60초를 넘긴 수신 공백은 기존처럼 제외합니다.
|
||||
|
||||
+26
-185
@@ -21,9 +21,6 @@ define('DB_CHARSET', (string)($carDbConfig['charset'] ?? 'utf8mb4'));
|
||||
|
||||
const LOG_LIMIT = 300;
|
||||
const CHART_LIMIT = 500;
|
||||
const CAR_MONITOR_SETTINGS_COOKIE = 'car_monitor_settings_access';
|
||||
const CAR_MONITOR_SETTINGS_TTL = 900;
|
||||
const CAR_MONITOR_SETTINGS_PASSWORD = '1026!)Corjs';
|
||||
const DATA_MONTHLY_INCLUDED_BYTES = 104857600; // 100MB
|
||||
const DATA_BILLING_UNIT_BYTES = 512; // 0.5KB
|
||||
const DATA_BILLING_UNIT_KRW = 0.011;
|
||||
@@ -862,79 +859,6 @@ function json_response(array $payload, int $status = 200): void
|
||||
exit;
|
||||
}
|
||||
|
||||
function car_monitor_cookie_secret(): string
|
||||
{
|
||||
global $carSecretConfig;
|
||||
|
||||
$secret = (string)($carSecretConfig['monitor_auth']['secret'] ?? $carSecretConfig['auth_token'] ?? '');
|
||||
if ($secret === '') {
|
||||
throw new RuntimeException('Car monitor auth secret is missing.');
|
||||
}
|
||||
|
||||
return $secret;
|
||||
}
|
||||
|
||||
function car_monitor_cookie_options(int $expires): array
|
||||
{
|
||||
return [
|
||||
'expires' => $expires,
|
||||
'path' => '/custom/car/',
|
||||
'secure' => (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'),
|
||||
'httponly' => true,
|
||||
'samesite' => 'Strict',
|
||||
];
|
||||
}
|
||||
|
||||
function car_monitor_settings_cookie_value(int $expires): string
|
||||
{
|
||||
$body = base64_encode(json_encode([
|
||||
'aud' => 'car-monitor-settings',
|
||||
'exp' => $expires,
|
||||
'iat' => time(),
|
||||
], JSON_UNESCAPED_SLASHES));
|
||||
$sig = hash_hmac('sha256', $body, car_monitor_cookie_secret());
|
||||
|
||||
return $body . '.' . $sig;
|
||||
}
|
||||
|
||||
function car_monitor_issue_settings_cookie(): void
|
||||
{
|
||||
$expires = time() + CAR_MONITOR_SETTINGS_TTL;
|
||||
setcookie(CAR_MONITOR_SETTINGS_COOKIE, car_monitor_settings_cookie_value($expires), car_monitor_cookie_options($expires));
|
||||
}
|
||||
|
||||
function car_monitor_has_settings_access(): bool
|
||||
{
|
||||
$cookie = $_COOKIE[CAR_MONITOR_SETTINGS_COOKIE] ?? '';
|
||||
if (!is_string($cookie) || strpos($cookie, '.') === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
[$body, $sig] = explode('.', $cookie, 2);
|
||||
$expected = hash_hmac('sha256', $body, car_monitor_cookie_secret());
|
||||
if (!hash_equals($expected, $sig)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($body, true);
|
||||
$payload = $decoded !== false ? json_decode($decoded, true) : null;
|
||||
return is_array($payload)
|
||||
&& ($payload['aud'] ?? '') === 'car-monitor-settings'
|
||||
&& (int)($payload['exp'] ?? 0) >= time();
|
||||
}
|
||||
|
||||
function car_monitor_require_settings_access(): void
|
||||
{
|
||||
if (car_monitor_has_settings_access()) {
|
||||
return;
|
||||
}
|
||||
|
||||
json_response([
|
||||
'status' => 'forbidden',
|
||||
'message' => '관리자 비밀번호가 필요합니다.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
function car_monitor_launcher_session(): array
|
||||
{
|
||||
static $session = null;
|
||||
@@ -943,8 +867,7 @@ function car_monitor_launcher_session(): array
|
||||
return $session;
|
||||
}
|
||||
|
||||
if (custom_is_site_admin()) {
|
||||
custom_issue_auth_cookie(true);
|
||||
if (custom_has_admin_auth()) {
|
||||
$session = ['authenticated' => true, 'admin' => true];
|
||||
return $session;
|
||||
}
|
||||
@@ -961,6 +884,23 @@ function car_monitor_launcher_session(): array
|
||||
return $session;
|
||||
}
|
||||
|
||||
function car_monitor_require_admin_access(): void
|
||||
{
|
||||
if (function_exists('custom_block_login_denied')) {
|
||||
custom_block_login_denied(true);
|
||||
}
|
||||
|
||||
$session = car_monitor_launcher_session();
|
||||
if (!empty($session['admin'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
json_response([
|
||||
'status' => 'forbidden',
|
||||
'message' => '관리자 권한이 필요합니다.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
function car_monitor_has_access(): bool
|
||||
{
|
||||
$session = car_monitor_launcher_session();
|
||||
@@ -1063,39 +1003,10 @@ function car_monitor_auth_api(): void
|
||||
json_response(['result' => 'fail'], 401);
|
||||
}
|
||||
|
||||
function car_monitor_settings_auth_api(): void
|
||||
{
|
||||
car_monitor_require_access(true);
|
||||
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
|
||||
json_response(['error' => 'method_not_allowed'], 405);
|
||||
}
|
||||
|
||||
$data = request_payload();
|
||||
$password = (string)($data['password'] ?? '');
|
||||
if (!hash_equals(CAR_MONITOR_SETTINGS_PASSWORD, $password)) {
|
||||
json_response([
|
||||
'status' => 'forbidden',
|
||||
'message' => '관리자 비밀번호가 올바르지 않습니다.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
car_monitor_issue_settings_cookie();
|
||||
json_response([
|
||||
'status' => 'success',
|
||||
'settings_authenticated' => true,
|
||||
'expires_in' => CAR_MONITOR_SETTINGS_TTL,
|
||||
]);
|
||||
}
|
||||
|
||||
if (isset($_GET['mode']) && $_GET['mode'] === 'auth-login') {
|
||||
car_monitor_auth_api();
|
||||
}
|
||||
|
||||
if (isset($_GET['mode']) && $_GET['mode'] === 'settings-auth') {
|
||||
car_monitor_settings_auth_api();
|
||||
}
|
||||
|
||||
car_monitor_issue_pwa_cookie_if_requested();
|
||||
|
||||
if (isset($_GET['mode']) && $_GET['mode'] === 'ajax') {
|
||||
@@ -1202,7 +1113,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
|
||||
|
||||
if (isset($_GET['mode']) && $_GET['mode'] === 'usage-calibration') {
|
||||
car_monitor_require_access(true);
|
||||
car_monitor_require_settings_access();
|
||||
car_monitor_require_admin_access();
|
||||
|
||||
try {
|
||||
$pdo = car_db();
|
||||
@@ -2392,7 +2303,6 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
let latestUsageData = null;
|
||||
let calibrationModalOpen = false;
|
||||
let calibrationSaveInFlight = false;
|
||||
let calibrationSettingsAuthenticated = false;
|
||||
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
|
||||
const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1';
|
||||
const BACK_LINK_PATH_TITLE = {
|
||||
@@ -2904,86 +2814,17 @@ $carMonitorSession = car_monitor_launcher_session();
|
||||
}
|
||||
|
||||
function initCalibrationSettings() {
|
||||
if (!CAR_MONITOR_SESSION || CAR_MONITOR_SESSION.admin !== true) {
|
||||
return;
|
||||
}
|
||||
|
||||
showCalibrationSettings();
|
||||
}
|
||||
|
||||
async function ensureCalibrationSettingsAuth() {
|
||||
if (calibrationSettingsAuthenticated) return true;
|
||||
|
||||
const password = await promptCalibrationSettingsPassword();
|
||||
if (password === null) return false;
|
||||
|
||||
try {
|
||||
const res = await fetch('?mode=settings-auth', {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
});
|
||||
const payload = await res.json();
|
||||
if (!res.ok || payload.status !== 'success') {
|
||||
alert(payload.message || '관리자 비밀번호가 올바르지 않습니다.');
|
||||
return false;
|
||||
}
|
||||
calibrationSettingsAuthenticated = true;
|
||||
return true;
|
||||
} catch (_) {
|
||||
alert('관리자 인증에 실패했습니다.');
|
||||
return false;
|
||||
function openCalibrationModal() {
|
||||
if (!CAR_MONITOR_SESSION || CAR_MONITOR_SESSION.admin !== true) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function promptCalibrationSettingsPassword() {
|
||||
return new Promise(resolve => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'custom-modal-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="custom-modal-panel" role="dialog" aria-modal="true" aria-labelledby="settings-auth-title">
|
||||
<div class="custom-modal-header">
|
||||
<div>
|
||||
<div id="settings-auth-title" class="fw-bold">관리자 확인</div>
|
||||
<div class="small text-muted">설정 변경 권한을 확인합니다.</div>
|
||||
</div>
|
||||
<button type="button" class="btn-icon-toggle" data-settings-auth-cancel title="닫기">
|
||||
<i class="fas fa-xmark"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="custom-modal-body">
|
||||
<div class="calibration-field">
|
||||
<label for="settings-auth-password">관리자 비밀번호</label>
|
||||
<input id="settings-auth-password" type="password" autocomplete="current-password" autofocus>
|
||||
</div>
|
||||
<div class="calibration-message text-muted" data-settings-auth-message></div>
|
||||
</div>
|
||||
<div class="custom-modal-footer">
|
||||
<button type="button" class="btn btn-light rounded-pill px-4" data-settings-auth-cancel>닫기</button>
|
||||
<button type="button" class="btn btn-primary rounded-pill px-4" data-settings-auth-ok>확인</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const done = value => {
|
||||
document.body.style.overflow = '';
|
||||
overlay.remove();
|
||||
resolve(value);
|
||||
};
|
||||
const input = overlay.querySelector('#settings-auth-password');
|
||||
overlay.querySelectorAll('[data-settings-auth-cancel]').forEach(btn => {
|
||||
btn.addEventListener('click', () => done(null));
|
||||
});
|
||||
overlay.querySelector('[data-settings-auth-ok]')?.addEventListener('click', () => done(input?.value || ''));
|
||||
input?.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') done(input.value || '');
|
||||
if (event.key === 'Escape') done(null);
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
document.body.style.overflow = 'hidden';
|
||||
setTimeout(() => input?.focus(), 30);
|
||||
});
|
||||
}
|
||||
|
||||
async function openCalibrationModal() {
|
||||
if (!await ensureCalibrationSettingsAuth()) return;
|
||||
|
||||
const overlay = document.getElementById('calibration-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
Reference in new issue
Block a user