[verified] fix: show HAL risk warning only on action

This commit is contained in:
zzabisagent-lab
2026-10-28 16:50:12 +00:00
parent 0a56d4612c
commit 6bfde82c5e
10 changed files with 262 additions and 12 deletions
+3 -2
View File
@@ -7,6 +7,7 @@
* - HAL 자체 데이터(서버·릴리스·작업·사건·감사·긴급 복구·재해복구·계정)는 서버에서 먼저 그린다(hal/ui/render.mjs). 포털 데이터(고객·주문·상품·사이트·메일·도메인·백업·이전)는
* 브라우저가 /ops/api/portal/* 중계로 받아 같은 렌더러로 그리고, 중계 실패는 '정보 지연'으로 보인다(정상으로 보이지 않음).
* - 모든 화면은 불러오는 중·비어 있음·정보 지연·부분 실패·전체 실패·권한 없음 상태와 360px 사용을 지원한다(hal/ui/public/ops.css).
* - 전역 배너(.alerts)는 접근권 만료·HAL 비정상·긴급 복구·부트스트랩 미완료·비밀번호 변경만 그린다. 고위험 재인증 안내는 0.2.1 부터 거부 시점의 재인증 창 안에만 둔다(hal/ui/public/reauth.mjs).
*/
import { esc } from '../lib/http.mjs';
import * as session from '../lib/session.mjs';
@@ -39,10 +40,10 @@ export function registerUi(app, d) {
/** 공통 틀. 운영자 세션이 없으면 로그인 화면(또는 최초 설정 화면)을 낸다. */
function shell(ctx, { title, nav, body, page, id = '', scripts = [] }) {
if (!ctx.operator) return ctx.html(loginPage(ctx, d));
const recent = session.recentlyAuthed(ctx.session); const grant = ctx.opsVia === 'grant' ? ops.grantValid(d, ctx.cookies[ops.GRANT_COOKIE], ctx.headers['user-agent']) : null;
const grant = ctx.opsVia === 'grant' ? ops.grantValid(d, ctx.cookies[ops.GRANT_COOKIE], ctx.headers['user-agent']) : null;
const health = core.selfHealth(d); const emOpen = d.store.get('SELECT COUNT(*) AS n FROM emergency_sessions WHERE status<>\'closed\'').n;
const banner = [];
if (!recent) banner.push(`<a class="alert warning" href="#" data-act="reauth">! 고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다 — 재인증</a>`);
// [0.2.1] 재인증 만료(10분 경과)는 더 이상 전역 배너로 그리지 않는다. 고위험 안내는 서버가 요청을 reauth_required 로 거부한 그 때 ops.js 가 여는 재인증 창(hal/ui/public/reauth.mjs) 안에만 보인다. 거부 자체(requireRecent)는 그대로다.
if (grant) banner.push(`<div class="alert info">ⓘ 기기 귀속 접근권으로 접속 중 · 만료 ${R.fmt(grant.expires_at)} (만료 후에는 로그인 화면도 열리지 않습니다)</div>`);
if (!health.ok) banner.push(`<div class="alert critical">⚠ HAL 자체 건강 비정상 — 일반 배포 작업이 거부됩니다. 플랫폼 복구 화면을 확인하세요</div>`);
if (emOpen) banner.push(`<a class="alert critical" href="/ops/recovery">⚠ 긴급 복구 세션 ${emOpen}건 진행 중</a>`);
+7 -4
View File
@@ -1,9 +1,11 @@
/* hal/ui/public/ops.js — 운영자 콘솔 브라우저 스크립트.
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 비밀번호 창 → 재시도, 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
* - /ops/api/* 호출(CSRF 자동), 재인증 필요(reauth_required) 시 고위험 안내가 붙은 비밀번호 창 → 성공 시 1회 재시도(reauth.mjs), 3단계 확인(고위험), 1회 표시 값(토큰·자격·복구 코드) 안내
* - [0.2.1] 고위험 재인증 안내는 전역 배너가 아니라 서버가 거부한 그 때의 재인증 창 안에만 보인다. 계정 화면의 수동 재인증 창에는 붙지 않는다.
* - 포털 중계 화면(/ops/api/portal/*)은 같은 렌더러(render.mjs)로 그리고, 중계 실패는 '정보 지연'으로 보인다
* - 진행 중 화면(운영 현황·작업·서버)은 15초마다 다시 그린다(탭이 숨겨지면 느리게). 화면을 떠나도 작업은 HAL 이 계속한다.
*/
import * as R from './render.mjs';
import { callWithReauth, reauthPrompt } from './reauth.mjs';
const $ = (s, el = document) => el.querySelector(s);
const csrf = () => ($('meta[name=csrf]') || {}).content || '';
async function api(method, path, body) {
@@ -12,8 +14,8 @@ async function api(method, path, body) {
if (!r.ok || (j && j.ok === false)) { const e = new Error((j && j.message) || `오류 ${r.status}`); e.code = (j && j.error) || 'http_' + r.status; e.detail = j && j.detail; e.status = r.status; throw e; }
return j;
}
/** 재인증이 필요하면 비밀번호를 받아 재인증한 뒤 한 번 더 시도 */
async function call(method, path, body) { try { return await api(method, path, body); } catch (e) { if (e.code === 'reauth_required') { if (await reauth()) return api(method, path, body); } throw e; } }
/** 서버가 reauth_required 로 거부하면 고위험 안내가 붙은 재인증 창을 열고, 성공했을 때만 딱 한 번 다시 시도(취소·실패 시 원 요청 미실행 — hal/ui/public/reauth.mjs 계약) */
const call = (method, path, body) => callWithReauth(() => api(method, path, body), reauth);
const portal = (method, path, body) => call(method, '/ops/api/portal' + path, body);
let toastT; function toast(t) { const el = $('#toast'); if (!el) return alert(t); el.textContent = t; el.classList.add('show'); clearTimeout(toastT); toastT = setTimeout(() => el.classList.remove('show'), 5000); }
function modal(html) { const m = $('#modal'); m.innerHTML = `<div class="box" role="dialog" aria-modal="true">${html}</div>`; m.hidden = false; const close = () => { m.hidden = true; m.innerHTML = ''; }; m.onclick = (e) => { if (e.target === m) close(); }; return { el: m, close }; }
@@ -37,7 +39,8 @@ function threeStep({ title, impacts = [], identifiers = [], revertible = '되돌
render();
});
}
async function reauth() { const v = await ask('재인증(최근 10분)', [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: '재인증' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } }
/** 재인증 창. required=true(서버 거부 직후)일 때만 고위험 안내를 보이고, 계정 화면의 수동 재인증은 안내 없이 비밀번호만 묻는다 */
async function reauth(opts = {}) { const p = reauthPrompt(opts); const v = await ask(p.title, [{ name: 'password', label: '운영자 비밀번호', type: 'password', required: true }], { okLabel: p.okLabel, note: p.note ? R.esc(p.note) : '' }); if (!v) return false; try { await api('POST', '/ops/api/reauth', { password: v.password }); toast('재인증했습니다'); return true; } catch (e) { toast(e.message); return false; } }
const reload = () => location.reload();
async function guarded(fn) { try { await fn(); } catch (e) { toast(e.message || String(e)); } }
// ---------------- 페이지별 데이터 로드(포털 중계 포함)
+39
View File
@@ -0,0 +1,39 @@
/**
* hal/ui/public/reauth.mjs — 운영자 콘솔 재인증 흐름의 순수 계약(0.2.1, 개발 지시 `Ariweb-Suitle_Dev-Order_v0.2.1.md`).
*
* 브라우저(ops.js)와 Node 테스트가 같은 파일을 쓴다. DOM·fetch 를 직접 만지지 않고, 호출자가 넘긴 함수만 부른다.
*
* 왜 따로 두나
* - 0.2.0 까지는 재인증이 10분을 넘기면 모든 일반 화면 위에 "고위험 작업(…)은 최근 10분 안의 재인증이 필요합니다" 전역 배너가 늘 떠 있었다.
* 단순 조회·화면 이동·로그인 10분 뒤에도 보여 운영자가 경고에 무뎌진다. 0.2.1 은 이 안내를 backend 가 실제로 고위험 요청을
* `reauth_required` 로 거부한 **바로 그 때 여는 재인증 창 안**으로만 옮긴다. 수동 재인증(계정 화면 단추)에는 붙이지 않는다.
* - 보안 경계는 그대로다: 거부·재인증 판단은 전부 서버(hal/app.mjs requireRecent·CSRF·3단계 확인)가 하고, 여기는 "거부됐을 때 어떻게 묻고 몇 번 다시 보내는가"만 정한다.
*/
/** 서버가 고위험 요청을 거부할 때 쓰는 오류 코드(hal/app.mjs requireRecent 와 같은 값) */
export const REAUTH_REQUIRED = 'reauth_required';
/** 거부 시점 재인증 창에만 보이는 고위험 안내(옛 전역 배너 문구 그대로 — 위치만 옮겼다) */
export const HIGH_RISK_NOTICE = '고위험 작업(서버 삭제·되돌리기·인증 키 변경·복구)은 최근 10분 안의 재인증이 필요합니다';
/**
* 재인증 창의 문구. required=true(서버 거부 직후)일 때만 고위험 안내(note)를 붙이고, 수동 재인증(required 없음)은 안내 없이 비밀번호만 묻는다.
* @returns {{ title: string, okLabel: string, note: string }}
*/
export function reauthPrompt({ required = false } = {}) {
return { title: '재인증(최근 10분)', okLabel: '재인증', note: required ? HIGH_RISK_NOTICE : '' };
}
/**
* 요청을 보내고, 서버가 `reauth_required` 로 거부하면 재인증을 묻고(required=true) 성공했을 때만 **딱 한 번** 다시 보낸다.
* - 재인증 창을 취소하거나 재인증이 실패하면 원 요청을 다시 보내지 않고 원래 오류를 그대로 올린다(고위험 작업이 몰래 실행되지 않음).
* - 재시도도 거부되면 다시 묻지 않고 오류를 올린다(무한 반복 금지).
* - `reauth_required` 가 아닌 오류(권한 없음·검증 실패·네트워크)는 재인증을 묻지 않는다.
* @param {() => Promise<any>} run 실제 요청(같은 method·path·body 로 다시 부를 수 있어야 한다)
* @param {(opts: { required: true }) => Promise<boolean>} reauth 재인증 창을 열어 성공 여부를 돌려주는 함수
*/
export async function callWithReauth(run, reauth) {
try { return await run(); } catch (e) {
if (e && e.code === REAUTH_REQUIRED && await reauth({ required: true })) return run();
throw e;
}
}
+1 -1
View File
@@ -122,7 +122,7 @@ export function drView(data) {
<h2>복구 훈련</h2>${table([{ l: '훈련', f: (x) => `<span class="mono">${esc(x.id)}</span>` }, { l: '시작·종료', f: (x) => `${fmt(x.started_at)} → ${fmt(x.finished_at)}` }, { l: '결과', f: (x) => x.result === 'running' ? badge('running', '진행 중') + ` <button class="btn small" data-act="dr-drill-finish" data-id="${esc(x.id)}">결과 입력</button>` : x.result === 'success' ? badge('ok', '성공') : badge('failed', x.result) }, { l: '측정 RTO/RPO', f: (x) => `${x.measured_rto_minutes ?? '—'} / ${x.measured_rpo_minutes ?? '—'}분` }, { l: '실패·조치 항목', f: (x) => x.findings_json ? `<pre class="pre">${esc(x.findings_json)}</pre>` : '—' }], data.drills || [], { empty: '훈련 기록이 없습니다' })}`;
}
export function accountsView(data) {
return `<div class="cards"><div class="card"><h2>내 계정</h2>${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` <span class="muted">${esc(data.me.securityHint || '')}</span>`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '재인증 필요(고위험 작업)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}<div class="actions"><button class="btn" data-act="reauth">재인증</button> <button class="btn" data-act="password">비밀번호 변경</button> ${data.me.mfaEnabled ? `<button class="btn" data-act="mfa-disable">MFA 해제</button>` : `<button class="btn primary" data-act="mfa-begin">MFA 등록</button>`}</div></div>
return `<div class="cards"><div class="card"><h2>내 계정</h2>${kv([['아이디', esc(data.me.login)], ['보안 수준', data.me.securityLevel === 'mfa' ? badge('ok', '다중 인증') : badge('warn', '비밀번호만') + ` <span class="muted">${esc(data.me.securityHint || '')}</span>`], ['최근 재인증', data.recentlyAuthed ? badge('ok', '유효(10분)') : badge('warn', '만료(10분 지남)')], ['접속 경로', data.via === 'grant' ? '기기 귀속 접근권' : '허용 네트워크']])}<div class="actions"><button class="btn" data-act="reauth">재인증</button> <button class="btn" data-act="password">비밀번호 변경</button> ${data.me.mfaEnabled ? `<button class="btn" data-act="mfa-disable">MFA 해제</button>` : `<button class="btn primary" data-act="mfa-begin">MFA 등록</button>`}</div></div>
<div class="card"><h2>로그인 세션</h2>${table([{ l: '기기', f: (s) => `${esc(s.label || '')}${s.current ? ' <b>(현재)</b>' : ''}` }, { l: 'IP', k: 'ip' }, { l: '마지막 사용', f: (s) => fmt(s.last_seen_at) }, { l: '', f: (s) => s.current ? '' : `<button class="btn small" data-act="session-revoke" data-id="${esc(s.id)}">종료</button>` }], data.sessions || [])}<div class="actions"><button class="btn" data-act="session-revoke-all">다른 모든 세션 종료</button></div></div></div>
<h2>접근권(허용 네트워크 밖 기기)</h2>${table([{ l: '계정', k: 'account_id' }, { l: '발급', f: (g) => `${esc(g.created_by || '')} ${fmt(g.created_at)}` }, { l: '만료', f: (g) => fmt(g.expires_at) }, { l: '기기 묶임', f: (g) => g.bound_at ? `${fmt(g.bound_at)} (${esc(g.ip || '')})` : '아직' }, { l: '상태', f: (g) => g.revoked_at ? badge('neutral', '회수') : Date.parse(g.expires_at) < Date.now() ? badge('neutral', '만료') : badge('ok', '유효') }, { l: '사유', k: 'reason' }, { l: '', f: (g) => g.revoked_at ? '' : `<button class="btn small" data-act="grant-revoke" data-id="${esc(g.id)}">회수</button>` }], data.grants || [], { empty: '발급된 접근권이 없습니다' })}<div class="actions"><button class="btn" data-act="grant-issue">접근권 발급(URL 1회 표시)</button></div>
<h2>운영자 계정</h2>${table([{ l: '아이디', f: (a) => `<b>${esc(a.login)}</b> ${esc(a.displayName)}` }, { l: '보안', f: (a) => a.mfaEnabled ? badge('ok', 'MFA') : badge('warn', '비밀번호만') }, { l: '상태', f: (a) => a.lockedUntil ? badge('warn', `잠김 ${fmt(a.lockedUntil)}`) : badge('ok', a.status) }, { l: '마지막 로그인', f: (a) => fmt(a.lastLoginAt) }], data.accounts || [])}<div class="actions"><button class="btn" data-act="account-new">운영자 추가</button></div>`;