feat: 初始化 kdbx-viewer 项目
实现服务端解密的 KeePass 网页查看器,包含登录门户口令与验证码、RSA+会话级 AES 加密通道、审计日志持久化、HTTPS 自动证书、Docker 部署配置及端到端测试。
这个提交包含在:
+390
@@ -0,0 +1,390 @@
|
||||
'use strict';
|
||||
// ===== 工具 =====
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const show = (el) => el.classList.remove('hidden');
|
||||
const hide = (el) => el.classList.add('hidden');
|
||||
|
||||
// ===== 加密体系 =====
|
||||
// 1) 非对称:用服务端 RSA 公钥加密口令 / 会话密钥
|
||||
let _PUBKEY = null;
|
||||
|
||||
async function importRsaPublicKey(pem) {
|
||||
const b64 = pem.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, '').replace(/\s+/g, '');
|
||||
const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
return crypto.subtle.importKey('spki', der, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
|
||||
}
|
||||
|
||||
async function getPubKey() {
|
||||
if (_PUBKEY) return _PUBKEY;
|
||||
const r = await api('/api/pubkey');
|
||||
_PUBKEY = await importRsaPublicKey(r.pubkey);
|
||||
return _PUBKEY;
|
||||
}
|
||||
|
||||
async function rsaEncrypt(text) {
|
||||
const key = await getPubKey();
|
||||
const data = new TextEncoder().encode(text);
|
||||
const buf = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data);
|
||||
return btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
}
|
||||
|
||||
// 2) 会话级对称密钥(AES-256-GCM):登录时生成,用于加密响应通道,RSA 上传给后端
|
||||
let _SESSION_AES = null; // CryptoKey,仅存内存
|
||||
let _SESSION_AES_B64 = null; // raw base64,缓存到 localStorage 供审计页(同会话)复用,避免重复协商冲突
|
||||
|
||||
async function sessionKeyForUpload() {
|
||||
const raw = crypto.getRandomValues(new Uint8Array(32));
|
||||
_SESSION_AES = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
|
||||
_SESSION_AES_B64 = btoa(String.fromCharCode(...raw));
|
||||
try { localStorage.setItem('sessAes', _SESSION_AES_B64); } catch (e) {}
|
||||
return rsaEncrypt(_SESSION_AES_B64); // RSA 加密后的 base64
|
||||
}
|
||||
|
||||
// 3) 动态数据密钥(dataRSA):前端生成密钥对,私钥仅留浏览器内存,公钥上传后端加密密码字段
|
||||
let _DATARSA_PRIV = null; // CryptoKey 私钥,仅存内存
|
||||
let _DATARSA_PUB_PEM = null;
|
||||
|
||||
async function ensureDataRsa() {
|
||||
if (_DATARSA_PRIV) return _DATARSA_PRIV;
|
||||
const pair = await crypto.subtle.generateKey(
|
||||
{ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' },
|
||||
false, ['encrypt', 'decrypt']
|
||||
);
|
||||
_DATARSA_PRIV = pair.privateKey;
|
||||
// 导出公钥为 PEM(SPKI)
|
||||
const spki = await crypto.subtle.exportKey('spki', pair.publicKey);
|
||||
const b64 = btoa(String.fromCharCode(...new Uint8Array(spki)));
|
||||
_DATARSA_PUB_PEM = '-----BEGIN PUBLIC KEY-----\n' + b64.match(/.{1,64}/g).join('\n') + '\n-----END PUBLIC KEY-----';
|
||||
return _DATARSA_PRIV;
|
||||
}
|
||||
|
||||
// 用会话 AES 密钥解密后端响应 { iv, ct, tag } -> object
|
||||
async function aesDecrypt(payload) {
|
||||
if (!payload || typeof payload !== 'object' || !('ct' in payload)) return payload; // 明文兜底
|
||||
const key = _SESSION_AES;
|
||||
if (!key) throw new Error('会话密钥缺失');
|
||||
const iv = Uint8Array.from(atob(payload.iv), (c) => c.charCodeAt(0));
|
||||
const ct = Uint8Array.from(atob(payload.ct), (c) => c.charCodeAt(0));
|
||||
const tag = Uint8Array.from(atob(payload.tag), (c) => c.charCodeAt(0));
|
||||
const buf = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, concatBytes(ct, tag));
|
||||
return JSON.parse(new TextDecoder().decode(buf));
|
||||
}
|
||||
function concatBytes(a, b) {
|
||||
const o = new Uint8Array(a.length + b.length);
|
||||
o.set(a, 0); o.set(b, a.length); return o;
|
||||
}
|
||||
|
||||
// 用 dataRSA 私钥解密后端返回的密码密文
|
||||
async function dataRsaDecrypt(b64) {
|
||||
if (!b64) return '';
|
||||
if (!_DATARSA_PRIV) throw new Error('数据密钥缺失');
|
||||
const ct = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const buf = await crypto.subtle.decrypt({ name: 'RSA-OAEP', hash: 'SHA-256' }, _DATARSA_PRIV, ct);
|
||||
return new TextDecoder().decode(buf);
|
||||
}
|
||||
|
||||
async function api(url, opts) {
|
||||
const res = await fetch(url, Object.assign({ credentials: 'same-origin' }, opts));
|
||||
const raw = await res.json().catch(() => ({}));
|
||||
let data = (raw && raw.ct) ? await aesDecrypt(raw) : raw; // 自动解密通道加密响应
|
||||
if (!res.ok) {
|
||||
const err = new Error(data.error || '请求失败');
|
||||
err.code = data.code;
|
||||
throw err;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// 复制(不把内容写进日志)
|
||||
async function copy(text) {
|
||||
try { await navigator.clipboard.writeText(text); } catch (e) {}
|
||||
}
|
||||
|
||||
// ===== 状态 =====
|
||||
let GROUPS = []; // 扁平分组 [{ id, name, path, parent }]
|
||||
let WRITABLE = false;
|
||||
let activeGroup = null; // 分组 id;null = 全部
|
||||
let activeId = null;
|
||||
let currentPage = 1;
|
||||
let dataKeyExpire = 0; // 动态数据密钥过期时间戳
|
||||
let _CAPTCHA_ID = ''; // 当前验证码 id(登录时回传服务端校验)
|
||||
|
||||
// ===== 登录 =====
|
||||
$('loginBtn').onclick = async () => {
|
||||
$('loginErr').textContent = '';
|
||||
try {
|
||||
await ensureDataRsa(); // 生成本次会话的 dataRSA 密钥对
|
||||
const enc = await rsaEncrypt($('appPw').value); // 门户口令用传输公钥加密
|
||||
const sessionKey = await sessionKeyForUpload(); // 会话 AES 密钥 RSA 加密上传
|
||||
const captcha = $('captchaInput') ? $('captchaInput').value : '';
|
||||
const r = await api('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enc, sessionKey, dataPubKey: _DATARSA_PUB_PEM, captcha, captchaId: _CAPTCHA_ID }),
|
||||
});
|
||||
WRITABLE = r.writable;
|
||||
hide($('loginView')); show($('unlockView')); $('masterPw').focus();
|
||||
} catch (e) { $('loginErr').textContent = e.message; refreshCaptcha(); }
|
||||
};
|
||||
|
||||
// 验证码刷新:服务端返回 cid + svg
|
||||
async function refreshCaptcha() {
|
||||
const el = $('captchaImg');
|
||||
if (!el) return;
|
||||
try {
|
||||
const res = await fetch('/api/captcha', { credentials: 'same-origin' });
|
||||
const j = await res.json();
|
||||
_CAPTCHA_ID = j.cid || '';
|
||||
el.innerHTML = j.svg || '';
|
||||
} catch (e) { el.innerHTML = ''; }
|
||||
}
|
||||
$('captchaImg') && ($('captchaImg').onclick = refreshCaptcha);
|
||||
|
||||
// ===== 解锁 =====
|
||||
$('unlockBtn').onclick = async () => {
|
||||
$('unlockErr').textContent = '';
|
||||
try {
|
||||
const enc = await rsaEncrypt($('masterPw').value); // 主密码用公钥加密后传输
|
||||
const r = await api('/api/unlock', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enc }),
|
||||
});
|
||||
WRITABLE = r.writable;
|
||||
dataKeyExpire = r.dataKeyExpire || 0;
|
||||
await loadTree();
|
||||
hide($('unlockView')); show($('mainView'));
|
||||
$('dbName').textContent = (await api('/api/tree')).name || '密码库';
|
||||
if (WRITABLE) show($('writableBadge'));
|
||||
$('search').focus();
|
||||
} catch (e) { $('unlockErr').textContent = e.message; }
|
||||
};
|
||||
|
||||
// ===== 退出 =====
|
||||
$('logoutBtn').onclick = async () => {
|
||||
await api('/api/logout', { method: 'POST' }).catch(() => {});
|
||||
location.reload();
|
||||
};
|
||||
|
||||
// ===== 加载分组树 =====
|
||||
async function loadTree() {
|
||||
const r = await api('/api/tree');
|
||||
GROUPS = r.groups || [];
|
||||
renderGroups();
|
||||
currentPage = 1;
|
||||
await loadEntries();
|
||||
}
|
||||
|
||||
// ===== 加载条目列表(分页,列表不含密码明文)=====
|
||||
async function loadEntries() {
|
||||
const q = $('search').value.trim();
|
||||
const r = await api('/api/entries?group=' + encodeURIComponent(activeGroup || '') +
|
||||
'&page=' + currentPage + '&q=' + encodeURIComponent(q));
|
||||
const list = $('entryList');
|
||||
list.innerHTML = '';
|
||||
if (!r.items || r.items.length === 0) {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'empty';
|
||||
li.textContent = q ? '无匹配条目' : '该分组暂无条目';
|
||||
list.appendChild(li);
|
||||
} else {
|
||||
r.items.forEach((i) => {
|
||||
const li = document.createElement('li');
|
||||
li.className = 'entry' + (i.id === activeId ? ' active' : '');
|
||||
const t = document.createElement('span'); t.className = 't'; t.textContent = i.title || '(无标题)';
|
||||
const u = document.createElement('span'); u.className = 'u'; u.textContent = i.username || '';
|
||||
li.appendChild(t); li.appendChild(u);
|
||||
li.onclick = () => openDetail(i.id); // 点条目 -> 拉详情(加密)弹窗
|
||||
list.appendChild(li);
|
||||
});
|
||||
}
|
||||
renderPager(r.page, r.total, r.pageSize);
|
||||
}
|
||||
|
||||
// 分页条
|
||||
function renderPager(page, total, pageSize) {
|
||||
const pager = $('pager');
|
||||
pager.innerHTML = '';
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const prev = document.createElement('button'); prev.textContent = '上一页';
|
||||
prev.disabled = page <= 1; prev.onclick = () => { currentPage = page - 1; loadEntries(); };
|
||||
const next = document.createElement('button'); next.textContent = '下一页';
|
||||
next.disabled = page >= totalPages; next.onclick = () => { currentPage = page + 1; loadEntries(); };
|
||||
const info = document.createElement('span'); info.className = 'pinfo';
|
||||
info.textContent = `第 ${page}/${totalPages} 页 · 共 ${total} 条`;
|
||||
pager.appendChild(prev); pager.appendChild(info); pager.appendChild(next);
|
||||
}
|
||||
|
||||
// ===== 搜索 =====
|
||||
$('search').oninput = () => { currentPage = 1; loadEntries(); };
|
||||
|
||||
// ===== 渲染分组树 =====
|
||||
function renderGroups() {
|
||||
const tree = $('groupTree');
|
||||
tree.innerHTML = '';
|
||||
const all = document.createElement('div');
|
||||
all.className = 'node' + (activeGroup === null ? ' active' : '');
|
||||
all.textContent = '全部';
|
||||
all.onclick = () => { activeGroup = null; renderGroups(); currentPage = 1; loadEntries(); };
|
||||
tree.appendChild(all);
|
||||
|
||||
GROUPS.forEach((g) => {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'node' + (activeGroup === g.id ? ' active' : '');
|
||||
node.textContent = g.path;
|
||||
node.onclick = () => { activeGroup = g.id; renderGroups(); currentPage = 1; loadEntries(); };
|
||||
tree.appendChild(node);
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 详情 / 编辑(弹窗,详情从服务端加密拉取)=====
|
||||
async function openDetail(id) {
|
||||
// 动态数据密钥过期检测:过期后需重新解锁
|
||||
if (dataKeyExpire && Date.now() > dataKeyExpire) {
|
||||
alert('动态数据密钥已过期,请重新解锁');
|
||||
hide($('mainView')); show($('unlockView')); $('masterPw').focus();
|
||||
return;
|
||||
}
|
||||
activeId = id;
|
||||
let item;
|
||||
try {
|
||||
item = await api('/api/entry/' + encodeURIComponent(id)); // 含密码,加密返回
|
||||
} catch (e) { $('detailErr') && ($('detailErr').textContent = e.message); return; }
|
||||
const body = $('modalBody');
|
||||
body.innerHTML = '';
|
||||
|
||||
const head = document.createElement('h2'); head.textContent = item.title || '(无标题)';
|
||||
body.appendChild(head);
|
||||
|
||||
const rows = [
|
||||
['分组', item.group],
|
||||
['账号', item.username],
|
||||
['密码', item.passwordCrypt, true], // 密文,前端 dataRSA 私钥解密后显示
|
||||
['网址', item.url],
|
||||
['备注', item.notes],
|
||||
];
|
||||
rows.forEach(([label, val, secret]) => {
|
||||
const row = document.createElement('div'); row.className = 'row';
|
||||
const l = document.createElement('span'); l.className = 'label'; l.textContent = label;
|
||||
const v = document.createElement('span'); v.className = 'value';
|
||||
if (secret && val) {
|
||||
v.textContent = '•'.repeat(Math.min(val.length, 12)) || '';
|
||||
let plain = null;
|
||||
const toggle = document.createElement('button'); toggle.className = 'copy'; toggle.textContent = '显示';
|
||||
toggle.onclick = async () => {
|
||||
try {
|
||||
if (v.dataset.shown === '1') { v.textContent = '•'.repeat(Math.min(val.length, 12)); toggle.textContent = '显示'; v.dataset.shown = '0'; }
|
||||
else { plain = plain || await dataRsaDecrypt(val); v.textContent = plain; toggle.textContent = '隐藏'; v.dataset.shown = '1'; }
|
||||
} catch (e) { v.textContent = '(解密失败)'; }
|
||||
};
|
||||
const c = document.createElement('button'); c.className = 'copy'; c.textContent = '复制';
|
||||
c.onclick = async () => { try { const p = plain || await dataRsaDecrypt(val); copy(p || ''); } catch (e) {} };
|
||||
row.appendChild(l); row.appendChild(v); row.appendChild(toggle); row.appendChild(c);
|
||||
} else {
|
||||
v.textContent = val || '';
|
||||
const c = document.createElement('button'); c.className = 'copy'; c.textContent = '复制';
|
||||
c.onclick = () => copy(val || '');
|
||||
row.appendChild(l); row.appendChild(v); if (val) row.appendChild(c);
|
||||
}
|
||||
body.appendChild(row);
|
||||
if (label === '网址' && val) {
|
||||
const a = document.createElement('a'); a.href = val; a.target = '_blank'; a.rel = 'noopener';
|
||||
a.textContent = '打开'; a.className = 'open';
|
||||
v.appendChild(document.createTextNode(' ')); v.appendChild(a);
|
||||
}
|
||||
});
|
||||
|
||||
if (WRITABLE) {
|
||||
const edit = document.createElement('button');
|
||||
edit.textContent = '编辑此条目';
|
||||
edit.className = 'ghost';
|
||||
edit.onclick = () => showEditor(item);
|
||||
body.appendChild(edit);
|
||||
}
|
||||
|
||||
show($('modal'));
|
||||
}
|
||||
|
||||
// ===== 编辑表单(仅可写模式,渲染于弹窗内)=====
|
||||
function showEditor(item) {
|
||||
const d = $('modalBody');
|
||||
d.innerHTML = '';
|
||||
const fields = ['title', 'username', 'password', 'url', 'notes'];
|
||||
const labels = { title: '标题', username: '账号', password: '密码', url: '网址', notes: '备注' };
|
||||
const inputs = {};
|
||||
fields.forEach((f) => {
|
||||
const wrap = document.createElement('div'); wrap.className = 'row';
|
||||
const l = document.createElement('span'); l.className = 'label'; l.textContent = labels[f];
|
||||
const inp = document.createElement(f === 'notes' ? 'textarea' : 'input');
|
||||
if (f !== 'notes') inp.type = 'text';
|
||||
inp.value = item[f] || '';
|
||||
inputs[f] = inp;
|
||||
wrap.appendChild(l); wrap.appendChild(inp); d.appendChild(wrap);
|
||||
});
|
||||
const save = document.createElement('button');
|
||||
save.textContent = '保存';
|
||||
save.onclick = async () => {
|
||||
try {
|
||||
const upd = {}; fields.forEach((f) => (upd[f] = inputs[f].value));
|
||||
const r = await api('/api/entry/update', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id: item.id, fields: upd }),
|
||||
});
|
||||
openDetail(item.id);
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
const cancel = document.createElement('button');
|
||||
cancel.textContent = '取消'; cancel.className = 'ghost';
|
||||
cancel.onclick = () => openDetail(item.id);
|
||||
d.appendChild(save); d.appendChild(cancel);
|
||||
}
|
||||
|
||||
// 回车提交
|
||||
$('appPw').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('loginBtn').click(); });
|
||||
$('masterPw').addEventListener('keydown', (e) => { if (e.key === 'Enter') $('unlockBtn').click(); });
|
||||
|
||||
// ===== 审计日志 =====
|
||||
// 打开独立审计页(支持筛选/分页/封堵查询),新标签页打开以保留当前会话
|
||||
$('auditBtn').onclick = () => { window.open('/audit.html', '_blank'); };
|
||||
// 主界面内也保留一个快速概览面板
|
||||
$('auditBtn').addEventListener('contextmenu', async (ev) => {
|
||||
ev.preventDefault();
|
||||
try {
|
||||
const r = await api('/api/audit?pageSize=50');
|
||||
const list = $('auditList');
|
||||
list.innerHTML = '';
|
||||
(r.log || []).slice().reverse().forEach((e) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'row ' + (e.ok ? 'ok' : 'fail');
|
||||
row.textContent = `${e.t} ${e.ip} ${e.method} ${e.path} ${e.code}`;
|
||||
list.appendChild(row);
|
||||
});
|
||||
show($('auditPanel'));
|
||||
} catch (e) { alert(e.message); }
|
||||
});
|
||||
$('auditClose').onclick = () => hide($('auditPanel'));
|
||||
|
||||
// ===== 详情弹窗关闭 =====
|
||||
function closeModal() { hide($('modal')); $('modalBody').innerHTML = ''; }
|
||||
$('modalClose').onclick = closeModal;
|
||||
$('modal').addEventListener('click', (e) => { if (e.target === $('modal')) closeModal(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('modal').classList.contains('hidden')) closeModal(); });
|
||||
|
||||
// 初始化:拉取首张验证码
|
||||
refreshCaptcha();
|
||||
|
||||
// 启动时探测状态,已解锁则直接进主界面
|
||||
(async () => {
|
||||
try {
|
||||
const s = await api('/api/status');
|
||||
if (s.unlocked) {
|
||||
WRITABLE = s.writable;
|
||||
hide($('loginView')); hide($('unlockView')); show($('mainView'));
|
||||
await loadTree();
|
||||
$('dbName').textContent = s.name || '密码库';
|
||||
if (WRITABLE) show($('writableBadge'));
|
||||
} else if (s.authed) {
|
||||
hide($('loginView')); show($('unlockView'));
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>审计日志 - KeePass 查看器</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="audit-page">
|
||||
<header class="audit-header">
|
||||
<h1>审计日志</h1>
|
||||
<div class="audit-actions">
|
||||
<a href="/" class="btn-ghost">← 返回主界面</a>
|
||||
<button id="logoutBtn" class="btn-ghost">退出</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="audit-filters">
|
||||
<input id="fIp" type="text" placeholder="来源 IP(模糊)">
|
||||
<select id="fIpType">
|
||||
<option value="">全部 IP 类型</option>
|
||||
<option value="external">外网</option>
|
||||
<option value="internal">内网</option>
|
||||
</select>
|
||||
<select id="fCode">
|
||||
<option value="">全部错误码</option>
|
||||
<option value="OK">OK(成功)</option>
|
||||
<option value="AUTH_APP_FAIL">AUTH_APP_FAIL</option>
|
||||
<option value="CAPTCHA_FAIL">CAPTCHA_FAIL</option>
|
||||
<option value="IP_BLOCKED">IP_BLOCKED</option>
|
||||
<option value="AUTH_MASTER_FAIL">AUTH_MASTER_FAIL</option>
|
||||
<option value="ENC_FAIL">ENC_FAIL</option>
|
||||
<option value="MISSING">MISSING</option>
|
||||
<option value="KEY_EXPIRED">KEY_EXPIRED</option>
|
||||
</select>
|
||||
<select id="fMethod">
|
||||
<option value="">全部方法</option>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
</select>
|
||||
<input id="fPath" type="text" placeholder="路径关键字">
|
||||
<label class="chk"><input id="fFail" type="checkbox"> 仅失败</label>
|
||||
<input id="fFrom" type="datetime-local" title="起始时间">
|
||||
<input id="fTo" type="datetime-local" title="结束时间">
|
||||
<button id="searchBtn" class="btn">查询</button>
|
||||
<button id="resetBtn" class="btn-ghost">重置</button>
|
||||
</section>
|
||||
|
||||
<section class="audit-blocks" id="blocksBox" style="display:none">
|
||||
<h3>当前被封锁 IP</h3>
|
||||
<ul id="blocksList"></ul>
|
||||
</section>
|
||||
|
||||
<section class="audit-table-wrap">
|
||||
<table class="audit-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th><th>来源 IP</th><th>类型</th><th>直连地址</th><th>XFF</th>
|
||||
<th>方法</th><th>路径</th><th>状态码</th><th>错误码</th><th>UA</th><th>详情</th><th>会话</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="auditBody"></tbody>
|
||||
</table>
|
||||
<div class="audit-pager">
|
||||
<button id="prevPage" class="btn-ghost">上一页</button>
|
||||
<span id="pageInfo">第 1 页</span>
|
||||
<button id="nextPage" class="btn-ghost">下一页</button>
|
||||
<span id="totalInfo"></span>
|
||||
<span id="indexInfo" class="index-info"></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<script src="/audit.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
'use strict';
|
||||
// 审计日志独立页:登录后可访问,支持筛选/分页,数据来自服务端加密响应(会话通道解密)
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ===== 通道密钥(与主页共享会话)=====
|
||||
let _SESSION_AES = null;
|
||||
let _SESSION_AES_B64 = null;
|
||||
|
||||
function bufFromB64(b64) {
|
||||
const bin = atob(b64);
|
||||
const u = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u[i] = bin.charCodeAt(i);
|
||||
return u;
|
||||
}
|
||||
|
||||
async function ensureChannel() {
|
||||
// 优先复用主页已协商并缓存的密钥(同会话,避免覆盖服务端密钥槽)
|
||||
try { _SESSION_AES_B64 = localStorage.getItem('sessAes'); } catch (e) {}
|
||||
if (_SESSION_AES_B64) {
|
||||
try {
|
||||
_SESSION_AES = await crypto.subtle.importKey('raw', bufFromB64(_SESSION_AES_B64), { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
return;
|
||||
} catch (e) {}
|
||||
}
|
||||
// 否则自行协商:取传输公钥,生成 AES 密钥并 RSA 上传
|
||||
const r = await fetch('/api/pubkey').then((x) => x.json());
|
||||
const pubPem = r.pubkey;
|
||||
const b64 = pubPem.replace(/-----(BEGIN|END) PUBLIC KEY-----/g, '').replace(/\s+/g, '');
|
||||
const der = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||
const pubKey = await crypto.subtle.importKey('spki', der, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt']);
|
||||
const raw = crypto.getRandomValues(new Uint8Array(32));
|
||||
_SESSION_AES_B64 = btoa(String.fromCharCode(...raw));
|
||||
const enc = await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, pubKey, new TextEncoder().encode(_SESSION_AES_B64));
|
||||
const encB64 = btoa(String.fromCharCode(...new Uint8Array(enc)));
|
||||
await fetch('/api/session/key', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ key: encB64 }) });
|
||||
_SESSION_AES = await crypto.subtle.importKey('raw', raw, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
try { localStorage.setItem('sessAes', _SESSION_AES_B64); } catch (e) {}
|
||||
}
|
||||
|
||||
// AES-256-GCM 解密 {iv, ct, tag}
|
||||
async function decryptPayload(p) {
|
||||
if (!p || typeof p !== 'object' || !p.ct) return p; // 明文兜底
|
||||
const iv = bufFromB64(p.iv);
|
||||
const tag = bufFromB64(p.tag);
|
||||
const ct = bufFromB64(p.ct);
|
||||
const key = await crypto.subtle.importKey('raw', bufFromB64(_SESSION_AES_B64), { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
// Web Crypto 约定:密文在前、认证标签在后(iv 已通过算法参数单独传入)
|
||||
const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, concat(ct, tag));
|
||||
return JSON.parse(new TextDecoder().decode(plain));
|
||||
}
|
||||
function concat(...arrs) {
|
||||
let len = 0; for (const a of arrs) len += a.length;
|
||||
const out = new Uint8Array(len); let o = 0;
|
||||
for (const a of arrs) { out.set(a, o); o += a.length; }
|
||||
return out;
|
||||
}
|
||||
|
||||
async function apiEnc(path) {
|
||||
const res = await fetch(path, { credentials: 'same-origin' });
|
||||
const p = await res.json();
|
||||
return decryptPayload(p);
|
||||
}
|
||||
|
||||
// ===== 渲染 =====
|
||||
let currentPage = 1;
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
function fmtTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return iso;
|
||||
return d.toLocaleString('zh-CN', { hour12: false });
|
||||
}
|
||||
|
||||
function renderRow(e) {
|
||||
const tr = document.createElement('tr');
|
||||
if (!e.ok) tr.classList.add('row-fail');
|
||||
const ipTypeLabel = e.ipType === 'internal' ? '<span class="tag tag-internal">内网</span>'
|
||||
: e.ipType === 'external' ? '<span class="tag tag-external">外网</span>' : '-';
|
||||
tr.innerHTML = `
|
||||
<td>${fmtTime(e.t)}</td>
|
||||
<td class="mono">${esc(e.ip)}</td>
|
||||
<td>${ipTypeLabel}</td>
|
||||
<td class="mono" title="${esc(e.remote)}">${esc(e.remote)}</td>
|
||||
<td class="mono" title="${esc(e.xff)}">${esc(e.xff && e.xff !== '-' ? e.xff : '-')}</td>
|
||||
<td>${esc(e.method)}</td>
|
||||
<td class="mono">${esc(e.path)}</td>
|
||||
<td>${e.status || '-'}</td>
|
||||
<td class="code">${esc(e.code)}</td>
|
||||
<td class="ua" title="${esc(e.ua)}">${esc(e.ua)}</td>
|
||||
<td>${esc(e.detail || '')}</td>
|
||||
<td class="mono">${esc(e.sid)}</td>`;
|
||||
return tr;
|
||||
}
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', String(currentPage));
|
||||
params.set('pageSize', String(PAGE_SIZE));
|
||||
const ip = $('fIp').value.trim(); if (ip) params.set('ip', ip);
|
||||
const ipType = $('fIpType').value; if (ipType) params.set('iptype', ipType);
|
||||
const code = $('fCode').value; if (code) params.set('code', code);
|
||||
const method = $('fMethod').value; if (method) params.set('method', method);
|
||||
const pathF = $('fPath').value.trim(); if (pathF) params.set('path', pathF);
|
||||
if ($('fFail').checked) params.set('fail', '1');
|
||||
const from = $('fFrom').value; if (from) params.set('from', new Date(from).toISOString());
|
||||
const to = $('fTo').value; if (to) params.set('to', new Date(to).toISOString());
|
||||
try {
|
||||
const data = await apiEnc('/api/audit?' + params.toString());
|
||||
const body = $('auditBody');
|
||||
body.innerHTML = '';
|
||||
(data.log || []).forEach((e) => body.appendChild(renderRow(e)));
|
||||
$('pageInfo').textContent = '第 ' + (data.page || 1) + ' 页(每页 ' + PAGE_SIZE + ')';
|
||||
$('totalInfo').textContent = ' 命中 ' + (data.total || 0) + ' 条';
|
||||
// 基于索引显示全量日志规模,便于核对分页正确性
|
||||
const idx = await apiEnc('/api/audit/index').catch(() => null);
|
||||
if (idx) {
|
||||
const files = (idx.files || []).map((f) => `${f.file}[${f.startSeq}-${f.endSeq},${f.count}条]`).join(' ');
|
||||
$('indexInfo').textContent = ` 索引总条数 ${idx.total} | 文件分布: ${files}`;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('加载审计日志失败:' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBlocks() {
|
||||
try {
|
||||
const data = await apiEnc('/api/audit/blocks');
|
||||
const box = $('blocksBox');
|
||||
const list = $('blocksList');
|
||||
list.innerHTML = '';
|
||||
if (data.blocks && data.blocks.length) {
|
||||
box.style.display = '';
|
||||
data.blocks.forEach((b) => {
|
||||
const li = document.createElement('li');
|
||||
li.innerHTML = `<span class="mono">${esc(b.ip)}</span> 失败 ${b.fails} 次,封锁至 ${fmtTime(b.expiresAt)}
|
||||
<button class="btn-ghost unblock" data-ip="${esc(b.ip)}">解封</button>`;
|
||||
list.appendChild(li);
|
||||
});
|
||||
list.querySelectorAll('.unblock').forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
await fetch('/api/audit/unblock', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ip: btn.dataset.ip }) });
|
||||
loadBlocks();
|
||||
};
|
||||
});
|
||||
} else { box.style.display = 'none'; }
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ===== 事件 =====
|
||||
$('searchBtn').onclick = () => { currentPage = 1; load(); };
|
||||
$('resetBtn').onclick = () => {
|
||||
$('fIp').value = ''; $('fCode').value = ''; $('fMethod').value = ''; $('fPath').value = '';
|
||||
$('fFail').checked = false; $('fFrom').value = ''; $('fTo').value = '';
|
||||
currentPage = 1; load();
|
||||
};
|
||||
$('prevPage').onclick = () => { if (currentPage > 1) { currentPage--; load(); } };
|
||||
$('nextPage').onclick = () => { currentPage++; load(); };
|
||||
$('logoutBtn').onclick = async () => {
|
||||
await fetch('/api/logout', { method: 'POST', credentials: 'same-origin' });
|
||||
location.href = '/';
|
||||
};
|
||||
|
||||
// ===== 启动 =====
|
||||
(async () => {
|
||||
// 鉴权检查
|
||||
try {
|
||||
const st = await fetch('/api/status', { credentials: 'same-origin' }).then((r) => r.json());
|
||||
if (!st.authed) { location.href = '/'; return; }
|
||||
} catch (e) { location.href = '/'; return; }
|
||||
await ensureChannel();
|
||||
await load();
|
||||
await loadBlocks();
|
||||
})();
|
||||
@@ -0,0 +1,68 @@
|
||||
<!doctype html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KeePass 查看器</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
||||
<!-- 1. 登录:APP 门户口令 + 验证码 -->
|
||||
<section id="loginView" class="card">
|
||||
<h1>KeePass 查看器</h1>
|
||||
<p class="muted">请输入访问口令</p>
|
||||
<input id="appPw" type="password" placeholder="访问口令(含大小写,≥8位)" autocomplete="off">
|
||||
<div class="captcha-row">
|
||||
<input id="captchaInput" type="text" placeholder="验证码" autocomplete="off" maxlength="5">
|
||||
<span id="captchaImg" class="captcha-img" title="点击刷新"></span>
|
||||
</div>
|
||||
<button id="loginBtn">进入</button>
|
||||
<p id="loginErr" class="err"></p>
|
||||
</section>
|
||||
|
||||
<!-- 2. 解锁:KeePass 主密码 -->
|
||||
<section id="unlockView" class="card hidden">
|
||||
<h1>解锁密码库</h1>
|
||||
<p class="muted">输入 KeePass 主密码(密钥文件由服务端自动使用)</p>
|
||||
<input id="masterPw" type="password" placeholder="主密码" autocomplete="off">
|
||||
<button id="unlockBtn">解锁</button>
|
||||
<p id="unlockErr" class="err"></p>
|
||||
</section>
|
||||
|
||||
<!-- 3. 查看/编辑 -->
|
||||
<section id="mainView" class="hidden">
|
||||
<header class="topbar">
|
||||
<strong id="dbName"></strong>
|
||||
<span id="writableBadge" class="badge hidden">可编辑</span>
|
||||
<input id="search" placeholder="搜索 标题 / 账号 / 网址">
|
||||
<button id="auditBtn" class="ghost">审计日志</button>
|
||||
<button id="logoutBtn" class="ghost">退出</button>
|
||||
</header>
|
||||
<div id="auditPanel" class="hidden">
|
||||
<h3>审计日志</h3>
|
||||
<div id="auditList" class="audit-list"></div>
|
||||
<button id="auditClose" class="ghost">关闭</button>
|
||||
</div>
|
||||
<div class="layout">
|
||||
<aside id="groupTree"></aside>
|
||||
<main>
|
||||
<ul id="entryList"></ul>
|
||||
<div id="pager"></div>
|
||||
</main>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. 详情弹窗 -->
|
||||
<div id="modal" class="modal hidden">
|
||||
<div class="modal-card">
|
||||
<button id="modalClose" class="modal-close" aria-label="关闭">×</button>
|
||||
<div id="modalBody"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
:root {
|
||||
--bg: #f5f6f8; --card: #fff; --line: #e3e6ea; --text: #1f2329;
|
||||
--muted: #8a9099; --primary: #2f6df6; --danger: #d8392b; --active: #e8f0ff;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; font-family: -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg); color: var(--text);
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
.card {
|
||||
max-width: 360px; margin: 12vh auto; background: var(--card);
|
||||
border: 1px solid var(--line); border-radius: 12px; padding: 28px;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,.05);
|
||||
}
|
||||
.card h1 { font-size: 20px; margin: 0 0 6px; }
|
||||
.muted { color: var(--muted); font-size: 13px; margin: 0 0 16px; }
|
||||
input, textarea {
|
||||
width: 100%; padding: 10px 12px; border: 1px solid var(--line);
|
||||
border-radius: 8px; font-size: 14px; margin-bottom: 12px;
|
||||
}
|
||||
button {
|
||||
background: var(--primary); color: #fff; border: 0; border-radius: 8px;
|
||||
padding: 10px 16px; font-size: 14px; cursor: pointer;
|
||||
}
|
||||
button.ghost { background: transparent; color: var(--primary); border: 1px solid var(--line); }
|
||||
.err { color: var(--danger); font-size: 13px; min-height: 18px; margin: 6px 0 0; }
|
||||
.badge {
|
||||
background: #fff3e0; color: #b26a00; font-size: 12px;
|
||||
padding: 2px 8px; border-radius: 10px; margin-left: 8px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex; align-items: center; gap: 12px; padding: 12px 16px;
|
||||
background: var(--card); border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.topbar #dbName { font-size: 15px; }
|
||||
.topbar #search { width: 240px; margin: 0 0 0 auto; }
|
||||
.layout { display: flex; height: calc(100vh - 53px); }
|
||||
#groupTree {
|
||||
width: 240px; border-right: 1px solid var(--line); overflow: auto;
|
||||
padding: 10px; background: var(--card);
|
||||
}
|
||||
.node { padding: 7px 10px; border-radius: 6px; font-size: 13px; cursor: pointer; color: var(--text); }
|
||||
.node:hover { background: #f0f2f5; }
|
||||
.node.active { background: var(--active); color: var(--primary); font-weight: 600; }
|
||||
main { flex: 1; overflow: auto; padding: 16px; }
|
||||
#entryList { list-style: none; margin: 0; padding: 0; }
|
||||
.entry {
|
||||
padding: 10px 12px; border: 1px solid var(--line); border-radius: 8px;
|
||||
margin-bottom: 8px; cursor: pointer; display: flex; flex-direction: column; gap: 2px;
|
||||
}
|
||||
.entry:hover { border-color: var(--primary); }
|
||||
.entry.active { border-color: var(--primary); background: var(--active); }
|
||||
.entry .t { font-weight: 600; }
|
||||
.entry .u { font-size: 12px; color: var(--muted); }
|
||||
.empty { color: var(--muted); padding: 12px; }
|
||||
#detail { margin-top: 12px; border-top: 1px dashed var(--line); padding-top: 12px; }
|
||||
#detail h2 { margin: 0 0 12px; font-size: 18px; }
|
||||
.row { display: flex; align-items: flex-start; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--line); }
|
||||
.row .label { width: 56px; color: var(--muted); font-size: 13px; flex: none; }
|
||||
.row .value { flex: 1; word-break: break-all; font-size: 14px; white-space: pre-wrap; }
|
||||
.row .copy, .row .open { flex: none; font-size: 12px; padding: 4px 10px; }
|
||||
|
||||
/* ===== 详情弹窗 ===== */
|
||||
.modal {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,.45);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 50;
|
||||
}
|
||||
.modal.hidden { display: none !important; }
|
||||
.modal-card {
|
||||
background: var(--card); width: min(520px, 92vw); max-height: 86vh; overflow: auto;
|
||||
border-radius: 14px; padding: 22px 24px; position: relative;
|
||||
box-shadow: 0 12px 40px rgba(0,0,0,.2);
|
||||
}
|
||||
.modal-card h2 { margin: 0 0 14px; font-size: 18px; padding-right: 28px; }
|
||||
.modal-close {
|
||||
position: absolute; top: 12px; right: 12px; background: transparent; color: var(--muted);
|
||||
border: 0; font-size: 22px; line-height: 1; cursor: pointer; padding: 4px 8px;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
|
||||
/* ===== 分页 ===== */
|
||||
#pager { display: flex; align-items: center; gap: 12px; padding: 12px 4px; justify-content: center; }
|
||||
#pager .pinfo { font-size: 13px; color: var(--muted); }
|
||||
#pager button:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* ===== 验证码 ===== */
|
||||
.captcha-row { display: flex; gap: 8px; align-items: center; }
|
||||
.captcha-row input { flex: 1; }
|
||||
.captcha-img { display: inline-flex; height: 44px; border: 1px solid var(--border); border-radius: 8px; cursor: pointer; overflow: hidden; background: #fff; }
|
||||
.captcha-img svg { height: 44px; display: block; }
|
||||
|
||||
/* ===== 审计日志 ===== */
|
||||
#auditPanel { padding: 16px 20px; border-bottom: 1px solid var(--border); background: var(--card); }
|
||||
#auditPanel h3 { margin: 0 0 10px; }
|
||||
.audit-list { max-height: 260px; overflow: auto; font-family: monospace; font-size: 12px; }
|
||||
.audit-list .row { display: flex; gap: 10px; padding: 2px 0; border-bottom: 1px dashed var(--border); }
|
||||
.audit-list .ok { color: var(--ok, #2f855a); }
|
||||
.audit-list .fail { color: var(--err, #c53030); }
|
||||
|
||||
/* ===== 独立审计页 ===== */
|
||||
.audit-page { max-width: 1200px; margin: 0 auto; padding: 20px; }
|
||||
.audit-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
|
||||
.audit-header h1 { font-size: 20px; margin: 0; }
|
||||
.audit-actions { display: flex; gap: 8px; }
|
||||
.audit-filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-bottom: 16px; }
|
||||
.audit-filters input, .audit-filters select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; font-size: 13px; }
|
||||
.audit-filters input[type="text"] { width: 160px; }
|
||||
.audit-filters .chk { font-size: 13px; display: flex; align-items: center; gap: 4px; }
|
||||
.audit-blocks { background: #fff4f3; border: 1px solid #f3c7c2; border-radius: 8px; padding: 10px 14px; margin-bottom: 16px; }
|
||||
.audit-blocks h3 { margin: 0 0 6px; color: var(--danger); font-size: 14px; }
|
||||
.audit-blocks ul { margin: 0; padding-left: 18px; font-size: 13px; }
|
||||
.audit-blocks li { padding: 2px 0; }
|
||||
.audit-blocks .mono, .audit-table .mono { font-family: monospace; }
|
||||
.audit-table-wrap { background: var(--card); border: 1px solid var(--line); border-radius: 10px; overflow: auto; }
|
||||
.audit-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
.audit-table th, .audit-table td { padding: 8px 10px; border-bottom: 1px solid var(--line); text-align: left; white-space: nowrap; }
|
||||
.audit-table th { background: #fafbfc; position: sticky; top: 0; font-weight: 600; }
|
||||
.audit-table tbody tr.row-fail { background: #fff5f5; }
|
||||
.audit-table .ua { max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
|
||||
.audit-table .code { font-family: monospace; }
|
||||
.audit-table .code:not(:empty) { color: var(--danger); }
|
||||
.tag { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 11px; line-height: 1.5; }
|
||||
.tag-internal { background: #fff3cd; color: #8a6d00; border: 1px solid #ffe69c; }
|
||||
.tag-external { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
|
||||
.index-info { display: block; margin-top: 6px; font-size: 12px; color: var(--muted); font-family: monospace; word-break: break-all; }
|
||||
.audit-filters select { padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; font-size: 13px; }
|
||||
.audit-pager { display: flex; align-items: center; gap: 12px; padding: 10px 14px; font-size: 13px; flex-wrap: wrap; }
|
||||
.btn { padding: 6px 14px; background: var(--primary); color: #fff; border: none; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.btn:hover { filter: brightness(1.05); }
|
||||
.btn-ghost { padding: 6px 12px; background: #fff; border: 1px solid var(--line); border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.btn-ghost:hover { background: #f0f2f5; }
|
||||
在新工单中引用
屏蔽一个用户