POSA_LEAKSMS/php/setup_mfa.php
cw2007 73e49fd528 feat: RPi4 누수+온습도 2-모듈 단일 서버 통합 + 보안 하드닝
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>
2026-06-23 16:21:11 +09:00

138 lines
6.7 KiB
PHP

<?php
// =============================================================================
// setup_mfa.php - Google Authenticator compatible TOTP enrollment helper
// Presents an otpauth:// enrollment URI without sending the secret to Google.
// =============================================================================
require_once __DIR__ . '/config.php';
require_once __DIR__ . '/admin_security.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();
function setup_mfa_allowed(): bool {
if (!empty($_SESSION['logged_in']) && $_SESSION['logged_in'] === true) {
return true;
}
// 토큰 경로는 초기 등록 전에만 허용한다. MFA가 이미 설정된 상태에서
// MFA_SETUP_TOKEN이 남아 있어도 해당 토큰으로 재등록 화면에 접근할 수 없게 한다.
// (운영자가 등록 후 토큰을 삭제하지 않았을 때의 잔류 접근 차단.)
if (admin_mfa_configured()) {
return false;
}
$token = $_GET['token'] ?? $_POST['token'] ?? '';
return MFA_SETUP_TOKEN !== '' && hash_equals(MFA_SETUP_TOKEN, (string)$token);
}
if (!setup_mfa_allowed()) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo "Forbidden\n";
exit;
}
if (empty($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
$issuer = $_POST['issuer'] ?? $_GET['issuer'] ?? 'Leak SMS';
$account = $_POST['account'] ?? $_GET['account'] ?? ADMIN_USER;
$secret = $_POST['secret'] ?? $_GET['secret'] ?? admin_totp_secret();
$secret = strtoupper(preg_replace('/[^A-Z2-7]/i', '', $secret));
$uri = admin_totp_uri($issuer, $account, $secret);
$verified = false;
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
$error = '잘못된 요청입니다. 페이지를 새로고침하세요.';
} else {
$verified = admin_totp_verify($secret, $_POST['otp_code'] ?? '', time(), 1);
if (!$verified) {
$error = '인증 앱 코드가 일치하지 않습니다.';
} else {
admin_audit_log('mfa_setup_verified', $_SESSION['username'] ?? ADMIN_USER, true, [
'issuer' => $issuer,
'account' => $account,
]);
}
}
}
$token = $_GET['token'] ?? $_POST['token'] ?? '';
?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>관리자 MFA 등록</title>
<style>
* { box-sizing: border-box; }
body { margin: 0; font-family: 'Noto Sans KR', sans-serif; background: #f0f4f8; color: #1f2937; }
main { max-width: 760px; margin: 32px auto; padding: 0 16px; }
.panel { background: #fff; border-radius: 8px; padding: 22px; box-shadow: 0 1px 5px rgba(15,23,42,.12); margin-bottom: 16px; }
h1 { font-size: 1.3rem; margin: 0 0 8px; color: #1a56db; }
h2 { font-size: 1rem; margin: 20px 0 8px; }
p, li { line-height: 1.55; font-size: .92rem; }
code, textarea { font-family: Consolas, Monaco, monospace; }
textarea, input[type=text] { width: 100%; border: 1px solid #cbd5e1; border-radius: 6px; padding: 10px; font-size: .9rem; }
textarea { min-height: 82px; resize: vertical; }
label { display: block; font-weight: 700; margin: 12px 0 6px; }
.secret { font-size: 1.05rem; letter-spacing: .08em; word-break: break-all; background: #f8fafc; padding: 12px; border-radius: 6px; border: 1px solid #e2e8f0; }
.btn { background: #1a56db; color: #fff; border: 0; border-radius: 6px; padding: 10px 14px; font-weight: 700; cursor: pointer; }
.error { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 6px; padding: 10px; }
.ok { background: #f0fdf4; border: 1px solid #bbf7d0; color: #166534; border-radius: 6px; padding: 10px; }
.muted { color: #64748b; }
</style>
</head>
<body>
<main>
<section class="panel">
<h1>Google Authenticator MFA 등록</h1>
<p class="muted">이 화면은 Google Authenticator, Microsoft Authenticator, Authy 등 TOTP 인증 앱에 관리자 계정을 등록하기 위한 도구입니다. 외부 Google API로 비밀키를 보내지 않습니다.</p>
<?php if ($error): ?>
<div class="error"><?= htmlspecialchars($error) ?></div>
<?php endif; ?>
<?php if ($verified): ?>
<div class="ok">인증 앱 코드가 확인되었습니다. 아래 설정 줄을 `config.local.php`에 반영하세요.</div>
<?php endif; ?>
<h2>1. 인증 앱에 수동 등록</h2>
<ol>
<li>Google Authenticator 앱에서 <strong>설정 키 입력</strong>을 선택합니다.</li>
<li>계정 이름에 <code><?= htmlspecialchars($issuer . ':' . $account) ?></code>를 입력합니다.</li>
<li>키에 아래 Base32 비밀키를 입력하고, 시간 기반을 선택합니다.</li>
</ol>
<div class="secret"><?= htmlspecialchars($secret) ?></div>
<h2>2. 앱 링크 등록</h2>
<p class="muted">모바일 브라우저에서 열면 인증 앱이 이 URI를 인식할 수 있습니다. 데스크톱에서는 수동 등록을 사용하세요.</p>
<textarea readonly><?= htmlspecialchars($uri) ?></textarea>
<p><a class="btn" href="<?= htmlspecialchars($uri) ?>">인증 앱으로 열기</a></p>
<h2>3. 6자리 코드 확인</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
<input type="hidden" name="secret" value="<?= htmlspecialchars($secret) ?>">
<input type="hidden" name="issuer" value="<?= htmlspecialchars($issuer) ?>">
<input type="hidden" name="account" value="<?= htmlspecialchars($account) ?>">
<input type="hidden" name="token" value="<?= htmlspecialchars((string)$token) ?>">
<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">
<p><button class="btn" type="submit">코드 확인</button></p>
</form>
<?php if ($verified): ?>
<h2>4. config.local.php 반영</h2>
<textarea readonly>'ADMIN_TOTP_SECRET' => '<?= htmlspecialchars($secret) ?>',</textarea>
<p class="muted">반영 후 `MFA_SETUP_TOKEN`은 빈 값으로 바꾸거나 삭제하세요.</p>
<?php endif; ?>
</section>
</main>
</body>
</html>