实现服务端解密的 KeePass 网页查看器,包含登录门户口令与验证码、RSA+会话级 AES 加密通道、审计日志持久化、HTTPS 自动证书、Docker 部署配置及端到端测试。
760 行
35 KiB
JavaScript
760 行
35 KiB
JavaScript
'use strict';
|
||
require('dotenv').config(); // 必须在 require('./config') 之前加载 .env
|
||
const express = require('express');
|
||
const session = require('express-session');
|
||
const path = require('path');
|
||
const fs = require('fs');
|
||
const crypto = require('crypto');
|
||
const http = require('http');
|
||
const https = require('https');
|
||
|
||
const config = require('./config');
|
||
const { loadDatabase, extract, ProtectedValue } = require('./kdbxlib');
|
||
|
||
// 是否启用 HTTPS:证书存在则启用;否则尝试自动生成自签证书(容器/首次部署默认 HTTPS)
|
||
// 注意:session.cookie.secure 必须是布尔值;用函数形式在 trust proxy 下会导致 cookie 不下发
|
||
function ensureSsl() {
|
||
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
|
||
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
|
||
if (fs.existsSync(keyPath) && fs.existsSync(certPath)) return keyPath;
|
||
// 证书缺失:自动生成自签证书,保证默认走 HTTPS(生产应使用受信任证书替换)
|
||
try {
|
||
const { generateSelfSigned } = require('./gen-cert');
|
||
generateSelfSigned(keyPath, certPath, 'localhost');
|
||
console.log(`[ssl] 未检测到证书,已自动生成自签证书: ${keyPath}`);
|
||
return fs.existsSync(keyPath) && fs.existsSync(certPath) ? keyPath : null;
|
||
} catch (e) {
|
||
console.error('[ssl] 自动生成证书失败,将回退 HTTP:', e.message);
|
||
return null;
|
||
}
|
||
}
|
||
function detectSsl() { return !!ensureSsl(); }
|
||
const HAS_SSL = detectSsl();
|
||
|
||
// ============ 密钥体系 ============
|
||
// 1) 长期 RSA(仅用于加密传输 login/unlock 的口令,私钥仅存服务端内存)
|
||
const TRANSPORT_RSA = crypto.generateKeyPairSync('rsa', {
|
||
modulusLength: 2048,
|
||
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||
});
|
||
|
||
// 解密前端用传输公钥加密的密文
|
||
function rsaDecrypt(b64) {
|
||
const buf = Buffer.from(b64, 'base64');
|
||
return crypto.privateDecrypt(
|
||
{ key: TRANSPORT_RSA.privateKey, padding: crypto.constants.RSA_PKCS1_OAEP_PADDING, oaepHash: 'sha256' },
|
||
buf
|
||
).toString('utf8');
|
||
}
|
||
|
||
// 2) 会话级 AES(通道加密:login 协商,用于加密所有响应体)
|
||
const sessionKeys = new Map(); // sessionID -> Buffer(32)
|
||
|
||
function encPayload(req, obj) {
|
||
const key = sessionKeys.get(req.sessionID);
|
||
if (!key) return obj;
|
||
const iv = crypto.randomBytes(12);
|
||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||
const ct = Buffer.concat([cipher.update(JSON.stringify(obj), 'utf8'), cipher.final()]);
|
||
const tag = cipher.getAuthTag();
|
||
return { iv: iv.toString('base64'), ct: ct.toString('base64'), tag: tag.toString('base64') };
|
||
}
|
||
function encRes(req, res, obj, status = 200) { res.status(status).json(encPayload(req, obj)); }
|
||
|
||
// ============ 错误码(便于审计与前端区分)============
|
||
const ERR = {
|
||
AUTH_APP_FAIL: 'AUTH_APP_FAIL', // 门户口令错误
|
||
CAPTCHA_FAIL: 'CAPTCHA_FAIL', // 验证码错误
|
||
IP_BLOCKED: 'IP_BLOCKED', // IP 被封锁
|
||
AUTH_MASTER_FAIL: 'AUTH_MASTER_FAIL', // 主密码/keyfile 错误
|
||
KEY_EXPIRED: 'KEY_EXPIRED', // 动态数据密钥过期
|
||
ENC_FAIL: 'ENC_FAIL', // 密文解析失败
|
||
MISSING: 'MISSING', // 缺少参数
|
||
NOT_FOUND: 'NOT_FOUND',
|
||
RATE_LIMIT: 'RATE_LIMIT',
|
||
};
|
||
|
||
// ============ 审计日志(本地文件持久化 + 索引文件,按大小切分)============
|
||
// 数据模型:
|
||
// - 每个日志文件(audit.log / audit.N.log)为 JSON Lines,每行一条记录,按全局 seq 单调递增
|
||
// - 索引文件 audit.index.json 记录每个文件的 起始序号/结束序号/条数/字节/时间范围,以及全局总条数
|
||
// - 前端分页先读取索引计算分布,再按需从对应文件提取片段,保证分页与文件一致
|
||
const AUDIT_FILE_MAX = config.auditFileMax; // 单文件切分阈值,可由 AUDIT_FILE_MAX 环境变量配置(默认 5M)
|
||
const AUDIT_FILE_KEEP = config.auditFileKeep; // 切分文件最多保留份数,可由 AUDIT_FILE_KEEP 环境变量配置(默认 10)
|
||
let auditSeq = 0; // 全局单调递增序号(持久化,重启后从索引恢复)
|
||
|
||
const logDir = config.logDir;
|
||
try { fs.mkdirSync(logDir, { recursive: true }); } catch (e) {}
|
||
function auditCurrentFile() { return path.join(logDir, 'audit.log'); }
|
||
function auditRotatedFile(i) { return path.join(logDir, `audit.${i}.log`); }
|
||
function auditIndexFile() { return path.join(logDir, 'audit.index.json'); }
|
||
|
||
// 索引:{ total, nextSeq, files: [ { file, startSeq, endSeq, count, bytes, firstTime, lastTime } ] }
|
||
// nextSeq:全局下一个序号(跨重启持久化,避免 seq 重叠导致分页/区间错乱)
|
||
let auditIndex = { total: 0, nextSeq: 0, files: [] };
|
||
|
||
// 启动时从索引文件恢复(若无则基于现有审计文件重建)
|
||
function auditLoadIndex() {
|
||
try {
|
||
const raw = fs.readFileSync(auditIndexFile(), 'utf8');
|
||
const idx = JSON.parse(raw);
|
||
if (idx && Array.isArray(idx.files)) {
|
||
auditIndex = idx;
|
||
auditSeq = auditIndex.nextSeq || auditIndex.files.reduce((m, f) => Math.max(m, f.endSeq || 0), 0);
|
||
return;
|
||
}
|
||
} catch (e) { /* 索引缺失或损坏,下面重建 */ }
|
||
// 重建:扫描存在的审计文件,重排行号(按文件名顺序,认为 audit.log 最新)
|
||
auditRebuildIndex();
|
||
auditIndex.nextSeq = auditSeq;
|
||
auditSaveIndex();
|
||
}
|
||
// 扫描磁盘上的审计文件并重建索引(在索引丢失/损坏时调用)
|
||
function auditRebuildIndex() {
|
||
const names = [];
|
||
if (fs.existsSync(auditCurrentFile())) names.push('audit.log');
|
||
for (let i = 1; i <= AUDIT_FILE_KEEP; i++) {
|
||
const n = `audit.${i}.log`;
|
||
if (fs.existsSync(auditRotatedFile(i))) names.push(n);
|
||
}
|
||
// 顺序:audit.10 ... audit.1 在前(旧),audit.log 在最后(新)
|
||
names.sort((a, b) => {
|
||
const num = (n) => n === 'audit.log' ? 0 : -parseInt(n.match(/(\d+)/)[1], 10);
|
||
return num(a) - num(b);
|
||
});
|
||
let seq = 0;
|
||
auditIndex = { total: 0, nextSeq: 0, files: [] };
|
||
for (const n of names) {
|
||
const fp = path.join(logDir, n);
|
||
let bytes = 0; try { bytes = fs.statSync(fp).size; } catch (e) {}
|
||
const lines = readAuditLines(fp);
|
||
if (!lines.length) continue;
|
||
const startSeq = seq + 1;
|
||
let firstTime = '', lastTime = '';
|
||
lines.forEach((e, i) => { e.seq = startSeq + i; seq = e.seq; if (!firstTime) firstTime = e.t; lastTime = e.t; });
|
||
const endSeq = seq;
|
||
auditIndex.files.push({ file: n, startSeq, endSeq, count: lines.length, bytes, firstTime, lastTime });
|
||
}
|
||
auditIndex.total = auditIndex.files.reduce((s, f) => s + f.count, 0);
|
||
auditSeq = auditIndex.files.reduce((m, f) => Math.max(m, f.endSeq || 0), 0);
|
||
auditIndex.nextSeq = auditSeq;
|
||
auditSaveIndex();
|
||
}
|
||
function readAuditLines(fp) {
|
||
let txt = '';
|
||
try { txt = fs.readFileSync(fp, 'utf8'); } catch (e) { return []; }
|
||
const out = [];
|
||
txt.split('\n').forEach((ln) => {
|
||
ln = ln.trim();
|
||
if (!ln) return;
|
||
try { out.push(JSON.parse(ln)); } catch (e) {}
|
||
});
|
||
return out;
|
||
}
|
||
function auditSaveIndex() {
|
||
// 索引文件很小(仅元数据),每次追加后同步落盘,保证重启/异常退出后分页与文件一致
|
||
try { fs.writeFileSync(auditIndexFile(), JSON.stringify(auditIndex)); } catch (e) {}
|
||
}
|
||
|
||
// 写入一行到当前审计文件(JSON Lines);超阈值则切分(滚动重命名),并同步更新索引
|
||
function auditAppendToFile(line) {
|
||
try {
|
||
const cur = auditCurrentFile();
|
||
let size = 0;
|
||
try { size = fs.statSync(cur).size; } catch (e) {}
|
||
if (size + line.length + 1 > AUDIT_FILE_MAX) {
|
||
// 将当前文件归档进索引(切分前先记录其区间)
|
||
const curLines = readAuditLines(cur);
|
||
let curStart = auditIndex.files.length && auditIndex.files[auditIndex.files.length - 1].file === 'audit.log'
|
||
? auditIndex.files[auditIndex.files.length - 1].startSeq : (auditSeq - curLines.length + 1);
|
||
const curEnd = curStart + curLines.length - 1;
|
||
// 滚动:audit.9 -> 删除, audit.8 -> audit.9, ... audit.log -> audit.1
|
||
for (let i = AUDIT_FILE_KEEP - 1; i >= 1; i--) {
|
||
const src = i === 1 ? cur : auditRotatedFile(i - 1);
|
||
const dst = auditRotatedFile(i);
|
||
try { if (fs.existsSync(src)) fs.renameSync(src, dst); } catch (e) {}
|
||
}
|
||
// 把刚归档的 audit.log 信息更新进索引(它现在变成 audit.1)
|
||
const fobj = { file: 'audit.log', startSeq: curStart, endSeq: curEnd, count: curLines.length, bytes: size, firstTime: curLines[0] ? curLines[0].t : '', lastTime: curLines[curLines.length - 1] ? curLines[curLines.length - 1].t : '' };
|
||
const existing = auditIndex.files.find((f) => f.file === 'audit.log');
|
||
if (existing) Object.assign(existing, fobj); else auditIndex.files.push(fobj);
|
||
// 重排:保证 audit.log 始终在数组末尾;其他按序号倒序
|
||
auditIndex.files.sort((a, b) => {
|
||
const num = (n) => n === 'audit.log' ? -1 : -parseInt(n.match(/(\d+)/)[1], 10);
|
||
return num(a) - num(b);
|
||
});
|
||
auditSaveIndex();
|
||
}
|
||
fs.appendFileSync(cur, line + '\n');
|
||
} catch (e) { /* 文件写入失败不阻断主流程 */ }
|
||
}
|
||
|
||
// 记录一条审计:详情包含来源 IP(含内网/外网、XFF 全链、直连地址)、UA、时间、方法、路径等
|
||
function audit({ ip, method, path: p, sessionId, code, ok, ua, referer, status, detail }) {
|
||
// ip 可为字符串(兼容旧调用)或结构化对象 { ip, remote, xff, isInternal }
|
||
const ipInfo = (typeof ip === 'object' && ip) ? ip : { ip: ip || 'unknown', remote: '', xff: '', isInternal: false };
|
||
const entry = {
|
||
seq: ++auditSeq,
|
||
t: new Date().toISOString(),
|
||
ip: ipInfo.ip || 'unknown',
|
||
ipType: ipInfo.isInternal ? 'internal' : 'external',
|
||
remote: ipInfo.remote || '-', // 直连(代理/服务端看到的)地址
|
||
xff: ipInfo.xff || '-', // 完整 X-Forwarded-For 链
|
||
ua: ua || '-',
|
||
referer: referer || '-',
|
||
method: method || '-',
|
||
path: p || '-',
|
||
status: status || 0,
|
||
code: code || '-',
|
||
ok: ok ? 1 : 0,
|
||
sid: sessionId ? sessionId.slice(0, 8) : '-',
|
||
detail: detail || '',
|
||
};
|
||
auditAppendToFile(JSON.stringify(entry));
|
||
// 维护索引中当前文件(audit.log)的区间
|
||
const curFile = auditIndex.files.find((f) => f.file === 'audit.log');
|
||
if (curFile) {
|
||
if (curFile.count === 0) { curFile.startSeq = entry.seq; curFile.firstTime = entry.t; }
|
||
curFile.endSeq = entry.seq;
|
||
curFile.count += 1;
|
||
curFile.lastTime = entry.t;
|
||
try { curFile.bytes = fs.statSync(auditCurrentFile()).size; } catch (e) {}
|
||
} else {
|
||
auditIndex.files.push({ file: 'audit.log', startSeq: entry.seq, endSeq: entry.seq, count: 1, bytes: 0, firstTime: entry.t, lastTime: entry.t });
|
||
}
|
||
auditIndex.total += 1;
|
||
auditIndex.nextSeq = auditSeq + 1;
|
||
auditSaveIndex();
|
||
}
|
||
|
||
// ============ IP 封锁(登录错误 5 次/30min)============
|
||
const ipFails = new Map(); // ip -> { count, first }
|
||
const IP_WINDOW = 30 * 60 * 1000;
|
||
const IP_MAX_FAIL = 5;
|
||
function recordIpFail(ip) {
|
||
const now = Date.now();
|
||
let r = ipFails.get(ip);
|
||
if (!r || now - r.first > IP_WINDOW) r = { count: 0, first: now };
|
||
r.count++; ipFails.set(ip, r);
|
||
return r.count >= IP_MAX_FAIL;
|
||
}
|
||
function isIpBlocked(ip) {
|
||
const r = ipFails.get(ip);
|
||
if (!r) return false;
|
||
if (Date.now() - r.first > IP_WINDOW) { ipFails.delete(ip); return false; }
|
||
return r.count >= IP_MAX_FAIL;
|
||
}
|
||
|
||
// 从请求构造审计的基础字段(IP/UA/referer),与各接口的具体 code/status 合并
|
||
function auditFromReq(req, res, extra) {
|
||
return Object.assign({
|
||
ip: getClientIp(req),
|
||
ua: req.headers['user-agent'] || '',
|
||
referer: req.headers['referer'] || '',
|
||
sessionId: req.sessionID,
|
||
status: res.statusCode,
|
||
}, extra);
|
||
}
|
||
|
||
// ============ 验证码 ============
|
||
const captchaStore = new Map(); // sessionID -> { text, expires }
|
||
const CAPTCHA_TTL = 5 * 60 * 1000;
|
||
function genCaptcha() {
|
||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 去掉易混淆字符
|
||
let text = '';
|
||
for (let i = 0; i < 5; i++) text += chars[Math.floor(Math.random() * chars.length)];
|
||
// 生成 SVG 图片
|
||
const colors = ['#2b6cb0', '#2f855a', '#c05621', '#6b46c1'];
|
||
let svg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="44" viewBox="0 0 120 44">`;
|
||
svg += `<rect width="120" height="44" fill="#f7fafc"/>`;
|
||
for (let i = 0; i < text.length; i++) {
|
||
const x = 14 + i * 22, y = 30 + (Math.random() * 6 - 3);
|
||
const rot = Math.random() * 30 - 15;
|
||
const c = colors[i % colors.length];
|
||
svg += `<text x="${x}" y="${y}" font-size="26" font-family="monospace" font-weight="bold" fill="${c}" transform="rotate(${rot} ${x} ${y})">${text[i]}</text>`;
|
||
}
|
||
// 干扰线
|
||
for (let i = 0; i < 3; i++) {
|
||
svg += `<line x1="${Math.random() * 120}" y1="${Math.random() * 44}" x2="${Math.random() * 120}" y2="${Math.random() * 44}" stroke="#cbd5e0" stroke-width="1"/>`;
|
||
}
|
||
svg += `</svg>`;
|
||
return { text, svg };
|
||
}
|
||
|
||
// 解析客户端 IP 信息:完整记录直连地址、X-Forwarded-For 全链、真实客户端 IP 及是否内网
|
||
// 拦截器/反向代理场景:XFF 最右侧(最后一个)为真实客户端,前面为各级代理
|
||
const PRIVATE_RE = /^(10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/;
|
||
function isPrivateIp(ip) {
|
||
if (!ip) return false;
|
||
ip = ip.trim().replace(/^::ffff:/, '');
|
||
if (ip === '::1' || ip === 'localhost' || ip === 'unknown') return true;
|
||
return PRIVATE_RE.test(ip);
|
||
}
|
||
function getClientIp(req) {
|
||
const remote = (req.socket && req.socket.remoteAddress) || 'unknown';
|
||
const xffRaw = (req.headers['x-forwarded-for'] || '').toString().trim();
|
||
const xffList = xffRaw ? xffRaw.split(',').map((s) => s.trim()).filter(Boolean) : [];
|
||
// 真实客户端:XFF 链最后一跳(离服务端最远),若没有 XFF 则取直连
|
||
const realIp = xffList.length ? xffList[xffList.length - 1] : remote;
|
||
const isInternal = isPrivateIp(realIp) && xffList.length === 0 ? isPrivateIp(remote) : isPrivateIp(realIp);
|
||
return { ip: realIp, remote, xff: xffRaw, isInternal };
|
||
}
|
||
|
||
// ============ Express ============
|
||
const app = express();
|
||
// 注意:未启用 trust proxy。若部署在反向代理后,请按实际拓扑设置;
|
||
// 当前按直连处理,IP 通过 req.socket.remoteAddress 获取,避免误判。
|
||
app.disable('x-powered-by');
|
||
app.use((req, res, next) => {
|
||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||
res.setHeader('X-Frame-Options', 'DENY');
|
||
res.setHeader('Referrer-Policy', 'no-referrer');
|
||
// 基础 CSP:仅允许同源脚本/样式,防 XSS 与注入
|
||
res.setHeader('Content-Security-Policy',
|
||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'");
|
||
next();
|
||
});
|
||
|
||
app.use(express.json({ limit: '8kb' }));
|
||
app.use(express.static(path.join(__dirname, 'public'), { extensions: ['html'] }));
|
||
|
||
// 注:登录不复用 express-rate-limit(用户要求 login 不限流);
|
||
// 防爆破由下方「登录失败 IP 封锁」机制(30 分钟内 5 次失败封 IP)承担。
|
||
// unlock 同样处于 login 之后,且主密码错误仅返回 401,无额外限流。
|
||
|
||
app.use(session({
|
||
secret: config.sessionSecret || crypto.randomBytes(32).toString('hex'),
|
||
resave: true,
|
||
saveUninitialized: false,
|
||
cookie: {
|
||
httpOnly: true, sameSite: 'lax',
|
||
// HTTPS 模式下标记为 Secure;HTTP 开发模式为 false(cookie 必须始终下发)
|
||
secure: HAS_SSL,
|
||
maxAge: config.sessionMaxAge,
|
||
},
|
||
}));
|
||
|
||
// 全局审计中间件:注册在 session 之后,确保 req.sessionID 有效;
|
||
// 已在具体接口内精细记录(带错误码)的,通过 res._audited 标记避免重复
|
||
app.use('/api', (req, res, next) => {
|
||
res.on('finish', () => {
|
||
if (res._audited) return;
|
||
const ip = getClientIp(req);
|
||
audit({
|
||
ip, method: req.method, path: req.path, sessionId: req.sessionID,
|
||
code: res.statusCode < 400 ? 'OK' : 'ERR' + res.statusCode,
|
||
ok: res.statusCode < 400,
|
||
ua: req.headers['user-agent'] || '',
|
||
referer: req.headers['referer'] || '',
|
||
status: res.statusCode,
|
||
});
|
||
});
|
||
next();
|
||
});
|
||
|
||
// ---- 中间件 ----
|
||
function appAuth(req, res, next) {
|
||
if (req.session && req.session.appAuthed) return next();
|
||
return res.status(401).json({ error: '未登录' });
|
||
}
|
||
function unlocked(req, res, next) {
|
||
if (dbs.has(req.sessionID)) return next();
|
||
return res.status(401).json({ error: '未解锁' });
|
||
}
|
||
function writable(req, res, next) {
|
||
if (config.writable) return next();
|
||
return res.status(403).json({ error: '只读模式' });
|
||
}
|
||
|
||
// 解密前端用传输公钥加密的密文(提取 helper)
|
||
function decryptBody(req, field) {
|
||
const raw = (req.body && req.body[field]) || '';
|
||
if (!raw) throw new Error(ERR.ENC_FAIL);
|
||
return rsaDecrypt(raw);
|
||
}
|
||
|
||
// 动态数据密钥是否过期(1 小时)
|
||
function checkDataKey(req, res) {
|
||
const rec = dbs.get(req.sessionID);
|
||
if (!rec) return res.status(401).json({ error: '未解锁' });
|
||
if (Date.now() > rec.dataKeyExpire) {
|
||
return res.status(403).json({ error: '动态密钥已过期,请重新解锁', code: ERR.KEY_EXPIRED });
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// 解密后端用会话 dataRSA 公钥加密的字段(密码)-> 这里只是转发密文,前端用私钥解
|
||
// 后端只存密文,明文不落内存
|
||
|
||
// ============ 路由 ============
|
||
const dbs = new Map(); // sessionID -> { db, items, groups, tree, name, dataKeyExpire }
|
||
|
||
// 状态探测
|
||
app.get('/api/status', (req, res) => {
|
||
const authed = !!(req.session && req.session.appAuthed);
|
||
const unlocked = dbs.has(req.sessionID);
|
||
res.json({ authed, unlocked, writable: config.writable, name: unlocked ? dbs.get(req.sessionID).name : null });
|
||
});
|
||
|
||
// 下发传输公钥
|
||
app.get('/api/pubkey', (req, res) => res.json({ pubkey: TRANSPORT_RSA.publicKey }));
|
||
|
||
// 验证码(SVG 图片 + 答案存内存,返回 captchaId 由前端在登录时回传)
|
||
app.get('/api/captcha', (req, res) => {
|
||
const { text, svg } = genCaptcha();
|
||
const cid = crypto.randomBytes(8).toString('hex');
|
||
captchaStore.set(cid, { text, expires: Date.now() + CAPTCHA_TTL });
|
||
// 仅测试环境回显明文,便于自动化;生产环境绝不返回 text
|
||
if (process.env.NODE_ENV === 'test') res.json({ cid, svg, text });
|
||
else res.json({ cid, svg });
|
||
});
|
||
|
||
// 登录:APP 门户口令 + 验证码
|
||
app.post('/api/login', (req, res) => {
|
||
const ip = getClientIp(req);
|
||
if (isIpBlocked(ip)) {
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.IP_BLOCKED, ok: false, detail: 'IP 已封锁' }));
|
||
return res.status(429).json({ error: '该 IP 已被封锁,请 30 分钟后再试', code: ERR.IP_BLOCKED });
|
||
}
|
||
let password, dataPubPem, captcha, captchaId;
|
||
try {
|
||
password = decryptBody(req, 'enc');
|
||
dataPubPem = (req.body && req.body.dataPubKey) || '';
|
||
captcha = (req.body && req.body.captcha) || '';
|
||
captchaId = (req.body && req.body.captchaId) || '';
|
||
} catch (e) {
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.ENC_FAIL, ok: false, detail: '请求密文解析失败' }));
|
||
return res.status(400).json({ error: '密文解析失败', code: ERR.ENC_FAIL });
|
||
}
|
||
// 验证码校验
|
||
const cap = captchaStore.get(captchaId);
|
||
captchaStore.delete(captchaId);
|
||
if (!cap || Date.now() > cap.expires || !captcha || captcha.toUpperCase() !== cap.text) {
|
||
recordIpFail(ip);
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.CAPTCHA_FAIL, ok: false, detail: '验证码错误' }));
|
||
return res.status(400).json({ error: '验证码错误', code: ERR.CAPTCHA_FAIL });
|
||
}
|
||
// 门户口令校验(含大小写+8位规则在 config 侧已由期望值约束;此处仅比对)
|
||
if (!password || password !== config.getExpectedAppPassword()) {
|
||
const blocked = recordIpFail(ip);
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.AUTH_APP_FAIL, ok: false, detail: blocked ? '尝试过多已封锁' : '门户口令错误' }));
|
||
if (blocked) return res.status(429).json({ error: '尝试次数过多,IP 已封锁', code: ERR.IP_BLOCKED });
|
||
return res.status(401).json({ error: '门户口令错误', code: ERR.AUTH_APP_FAIL });
|
||
}
|
||
// 门户口令合规检查:必须含大小写且长度>=8(期望值本身应满足,这里做额外策略校验)
|
||
if (!/^(?=.*[a-z])(?=.*[A-Z]).{8,}$/.test(password)) {
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: ERR.AUTH_APP_FAIL, ok: false, detail: '口令复杂度不满足' }));
|
||
return res.status(401).json({ error: '门户口令不符合复杂度要求', code: ERR.AUTH_APP_FAIL });
|
||
}
|
||
req.session.regenerate((err) => {
|
||
if (err) return res.status(500).json({ error: '会话错误' });
|
||
req.session.appAuthed = true;
|
||
// 暂存前端 dataRSA 公钥,unlock 时使用
|
||
if (dataPubPem) req.session.dataPubPem = dataPubPem;
|
||
// 协商会话 AES 通道密钥:前端用传输公钥加密上传,服务端用私钥解出 32 字节写入内存
|
||
sessionKeys.delete(req.sessionID); // 旧会话的通道密钥清理
|
||
try {
|
||
const skB64 = (req.body && req.body.sessionKey) ? rsaDecrypt(req.body.sessionKey) : '';
|
||
const skBuf = Buffer.from(skB64, 'base64');
|
||
if (skBuf.length === 32) sessionKeys.set(req.sessionID, skBuf);
|
||
} catch (e) { /* 通道密钥缺失不阻断登录,仅后续响应走明文兜底 */ }
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/login', code: 'OK', ok: true, detail: '登录成功' }));
|
||
res.json({ ok: true, writable: config.writable });
|
||
});
|
||
});
|
||
|
||
// 解锁:KeePass 主密码 -> 解密库 -> 用前端 dataRSA 公钥加密密码字段存密文(内存无明文)
|
||
app.post('/api/unlock', appAuth, async (req, res) => {
|
||
const ip = getClientIp(req);
|
||
let masterPassword;
|
||
try { masterPassword = decryptBody(req, 'enc'); }
|
||
catch (e) {
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.ENC_FAIL, ok: false, detail: '请求密文解析失败' }));
|
||
return res.status(400).json({ error: '密文解析失败', code: ERR.ENC_FAIL });
|
||
}
|
||
if (!masterPassword) {
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.MISSING, ok: false, detail: '缺少主密码' }));
|
||
return res.status(400).json({ error: '缺少主密码', code: ERR.MISSING });
|
||
}
|
||
const dataPubPem = req.session.dataPubPem;
|
||
if (!dataPubPem) {
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.MISSING, ok: false, detail: '缺少数据公钥' }));
|
||
return res.status(400).json({ error: '缺少数据公钥,请重新登录', code: ERR.MISSING });
|
||
}
|
||
try {
|
||
const rec = await loadDatabase(
|
||
config.kdbxPath,
|
||
config.keyfilePath || undefined,
|
||
masterPassword,
|
||
dataPubPem // 用于加密密码字段存密文
|
||
);
|
||
const prevKeys = sessionKeys.get(req.sessionID);
|
||
req.session.regenerate((err) => {
|
||
if (err) return res.status(500).json({ error: '会话错误' });
|
||
req.session.appAuthed = true;
|
||
req.session.dataPubPem = dataPubPem;
|
||
dbs.set(req.sessionID, rec);
|
||
if (prevKeys) sessionKeys.set(req.sessionID, prevKeys);
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: 'OK', ok: true, detail: '解锁成功' }));
|
||
res.json({ ok: true, name: rec.name, count: rec.items.length, writable: config.writable,
|
||
dataKeyExpire: rec.dataKeyExpire });
|
||
});
|
||
} catch (e) {
|
||
res._audited = true;
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/unlock', code: ERR.AUTH_MASTER_FAIL, ok: false, detail: '主密码或密钥文件错误' }));
|
||
return res.status(401).json({ error: '主密码或密钥文件错误', code: ERR.AUTH_MASTER_FAIL });
|
||
}
|
||
});
|
||
|
||
// 数据:分组树(无敏感字段,仍按会话加密返回)
|
||
app.get('/api/tree', appAuth, unlocked, (req, res) => {
|
||
const rec = dbs.get(req.sessionID);
|
||
const blocked = checkDataKey(req, res); if (blocked) return;
|
||
encRes(req, res, { name: rec.name, tree: rec.tree, groups: rec.groups });
|
||
});
|
||
|
||
// 数据:某分组下的密码列表(分页;列表不含密码明文,整体加密返回)
|
||
const PAGE_SIZE = 50;
|
||
app.get('/api/entries', appAuth, unlocked, (req, res) => {
|
||
const rec = dbs.get(req.sessionID);
|
||
const blocked = checkDataKey(req, res); if (blocked) return;
|
||
const group = req.query.group || null;
|
||
const q = (req.query.q || '').toString().trim().toLowerCase();
|
||
const page = Math.max(1, parseInt(req.query.page || '1', 10));
|
||
let list = rec.items;
|
||
if (group) list = list.filter((i) => i.groupId === group);
|
||
if (q) list = list.filter((i) =>
|
||
(i.title || '').toLowerCase().includes(q) ||
|
||
(i.username || '').toLowerCase().includes(q) ||
|
||
(i.url || '').toLowerCase().includes(q));
|
||
const total = list.length;
|
||
const start = (page - 1) * PAGE_SIZE;
|
||
const pageItems = list.slice(start, start + PAGE_SIZE).map((i) => ({
|
||
id: i.id, title: i.title, username: i.username, url: i.url, group: i.group,
|
||
}));
|
||
encRes(req, res, { items: pageItems, page, pageSize: PAGE_SIZE, total, group, q });
|
||
});
|
||
|
||
// 数据:单条详情(密码为密文,前端用 dataRSA 私钥解密;整体再经通道加密)
|
||
app.get('/api/entry/:id', appAuth, unlocked, (req, res) => {
|
||
const rec = dbs.get(req.sessionID);
|
||
const blocked = checkDataKey(req, res); if (blocked) return;
|
||
const item = rec.items.find((i) => i.id === req.params.id);
|
||
if (!item) return encRes(req, res, { error: '条目不存在', code: ERR.NOT_FOUND }, 404);
|
||
encRes(req, res, {
|
||
id: item.id, title: item.title, username: item.username,
|
||
passwordCrypt: item.passwordCrypt, // 密文!前端解密
|
||
url: item.url, notes: item.notes, group: item.group,
|
||
});
|
||
});
|
||
|
||
// 编辑(仅可写模式)
|
||
app.post('/api/entry/update', appAuth, unlocked, writable, async (req, res) => {
|
||
const rec = dbs.get(req.sessionID);
|
||
const blocked = checkDataKey(req, res); if (blocked) return;
|
||
const { id, fields } = req.body || {};
|
||
if (!id || !fields) return res.status(400).json({ error: '参数缺失', code: ERR.MISSING });
|
||
try {
|
||
const entry = rec.db.entries.find((e) => Buffer.from(e.uuid.toBytes()).toString('base64') === id);
|
||
if (!entry) return res.status(404).json({ error: '条目不存在', code: ERR.NOT_FOUND });
|
||
entry.fields.set('Title', fields.title || '');
|
||
entry.fields.set('UserName', fields.username || '');
|
||
entry.fields.set('Password', ProtectedValue.fromString(fields.password || ''));
|
||
entry.fields.set('URL', fields.url || '');
|
||
entry.fields.set('Notes', fields.notes || '');
|
||
rec.db.cleanup({ historyRules: true });
|
||
rec.db.save();
|
||
const buf = rec.db.save();
|
||
if (config.keyfilePath) {
|
||
fs.copyFileSync(config.kdbxPath, config.kdbxPath + '.bak');
|
||
}
|
||
fs.writeFileSync(config.kdbxPath, Buffer.from(buf));
|
||
// 重新抽取并加密密码字段
|
||
const ex = extract(rec.db, req.session.dataPubPem);
|
||
rec.groups = ex.groups; rec.items = ex.items; rec.tree = ex.tree; rec.name = ex.name;
|
||
encRes(req, res, { ok: true, item: ex.items.find((i) => i.id === id) });
|
||
} catch (e) {
|
||
res.status(500).json({ error: '保存失败' });
|
||
}
|
||
});
|
||
|
||
// 独立审计页(或新标签页)协商响应通道密钥:复用同一会话的服务端密钥槽
|
||
app.post('/api/session/key', appAuth, (req, res) => {
|
||
try {
|
||
const raw = (req.body && req.body.key) || '';
|
||
if (!raw) return res.status(400).json({ error: '缺少 key' });
|
||
const b64 = rsaDecrypt(raw);
|
||
const buf = Buffer.from(b64, 'base64');
|
||
if (buf.length !== 32) return res.status(400).json({ error: '密钥长度错误' });
|
||
sessionKeys.set(req.sessionID, buf);
|
||
res.json({ ok: true });
|
||
} catch (e) { res.status(400).json({ error: '密钥解析失败' }); }
|
||
});
|
||
|
||
// 审计索引:返回各日志文件分布(文件/起始序号/结束序号/条数/时间范围)与总条数
|
||
// 前端分页前先读此接口计算分布,再按需从对应文件提取,保证分页与文件一致
|
||
app.get('/api/audit/index', appAuth, (req, res) => {
|
||
encRes(req, res, { total: auditIndex.total, files: auditIndex.files });
|
||
});
|
||
|
||
// 按文件 + 序号区间读取审计片段(解密后返回),供前端精确分页
|
||
app.get('/api/audit/file', appAuth, (req, res) => {
|
||
const q = req.query || {};
|
||
const file = (q.file || 'audit.log').toString();
|
||
// 仅允许白名单文件名,防目录穿越
|
||
if (!/^audit(\.\d+)?\.log$/.test(file)) return encRes(req, res, { error: '非法文件名' }, 400);
|
||
const fp = path.join(logDir, file);
|
||
const fromSeq = parseInt(q.fromSeq || '0', 10) || 0;
|
||
const toSeq = parseInt(q.toSeq || '0', 10) || 0;
|
||
let lines = readAuditLines(fp);
|
||
if (fromSeq || toSeq) {
|
||
lines = lines.filter((e) => (!fromSeq || e.seq >= fromSeq) && (!toSeq || e.seq <= toSeq));
|
||
}
|
||
encRes(req, res, { file, count: lines.length, log: lines });
|
||
});
|
||
|
||
// 审计日志(登录后可访问):基于「索引 + 本地文件」读取,支持筛选 + 分页,条数与文件完全一致
|
||
app.get('/api/audit', appAuth, (req, res) => {
|
||
const q = req.query || {};
|
||
const ip = (q.ip || '').trim().toLowerCase();
|
||
const ipType = (q.iptype || '').trim().toLowerCase();
|
||
const code = (q.code || '').trim();
|
||
const method = (q.method || '').trim().toUpperCase();
|
||
const pathFilter = (q.path || '').trim().toLowerCase();
|
||
const onlyFail = q.fail === '1' || q.fail === 'true';
|
||
const from = q.from ? Date.parse(q.from) : 0;
|
||
const to = q.to ? Date.parse(q.to) : Date.now() + 1;
|
||
const pageSize = Math.min(parseInt(q.pageSize || '50', 10) || 50, 500);
|
||
const page = Math.max(parseInt(q.page || '1', 10) || 1, 1);
|
||
|
||
// 汇总所有日志文件(按索引顺序,旧文件在前、audit.log 在后),保证完整覆盖
|
||
let all = [];
|
||
for (const f of auditIndex.files) {
|
||
const fp = path.join(logDir, f.file);
|
||
all = all.concat(readAuditLines(fp));
|
||
}
|
||
// 按 seq 升序排列(seq 全局单调递增;文件顺序已保证时间序,这里保险)
|
||
all.sort((a, b) => (a.seq || 0) - (b.seq || 0));
|
||
|
||
let rows = all;
|
||
if (ip || code || method || pathFilter || onlyFail || from || to < Date.now() + 1) {
|
||
rows = rows.filter((e) => {
|
||
if (ip && !String(e.ip || '').toLowerCase().includes(ip)) return false;
|
||
if (ipType && (e.ipType || '').toLowerCase() !== ipType) return false;
|
||
if (code && e.code !== code) return false;
|
||
if (method && e.method !== method) return false;
|
||
if (pathFilter && !String(e.path || '').toLowerCase().includes(pathFilter)) return false;
|
||
if (onlyFail && e.ok) return false;
|
||
const ts = Date.parse(e.t);
|
||
if (from && ts < from) return false;
|
||
if (to && ts > to) return false;
|
||
return true;
|
||
});
|
||
}
|
||
const total = rows.length;
|
||
// 默认显示最近的数据:按 seq 倒序取第 page 页(前端"最新在前")
|
||
const sortedDesc = rows.slice().sort((a, b) => (b.seq || 0) - (a.seq || 0));
|
||
const start = (page - 1) * pageSize;
|
||
const list = sortedDesc.slice(start, start + pageSize);
|
||
encRes(req, res, {
|
||
log: list,
|
||
total,
|
||
page,
|
||
pageSize,
|
||
indexTotal: auditIndex.total,
|
||
filters: { ip, code, method, path: pathFilter, onlyFail, from: from || '', to: to < Date.now() + 1 ? q.to : '' },
|
||
});
|
||
});
|
||
|
||
// 当前被封锁的 IP 列表(便于审计页展示与排查)
|
||
app.get('/api/audit/blocks', appAuth, (req, res) => {
|
||
const now = Date.now();
|
||
const list = [];
|
||
for (const [ip, r] of ipFails) {
|
||
if (now - r.first > IP_WINDOW) continue;
|
||
if (r.count >= IP_MAX_FAIL) {
|
||
list.push({ ip, fails: r.count, expiresAt: new Date(r.first + IP_WINDOW).toISOString() });
|
||
}
|
||
}
|
||
encRes(req, res, { blocks: list });
|
||
});
|
||
|
||
// 手动解除某 IP 封锁(管理员操作,记入审计)
|
||
app.post('/api/audit/unblock', appAuth, (req, res) => {
|
||
const target = (req.body && req.body.ip) || '';
|
||
if (!target) return encRes(req, res, { error: '缺少 ip' }, 400);
|
||
ipFails.delete(target);
|
||
audit(auditFromReq(req, res, { method: 'POST', path: '/api/audit/unblock', code: 'OK', ok: true, detail: '手动解封 IP: ' + target }));
|
||
encRes(req, res, { ok: true });
|
||
});
|
||
|
||
// 退出:清空内存
|
||
app.post('/api/logout', (req, res) => {
|
||
if (req.sessionID) { dbs.delete(req.sessionID); sessionKeys.delete(req.sessionID); captchaStore.delete(req.sessionID); }
|
||
if (req.session) req.session.destroy(() => res.json({ ok: true }));
|
||
else res.json({ ok: true });
|
||
});
|
||
|
||
// ============ 启动 ============
|
||
// 会话销毁时联动清理内存中的明文/密文数据,避免孤儿数据常驻
|
||
const sessionMiddleware = app._router && app._router.stack;
|
||
// 通过监听 destroy:express-session 在 req.session.destroy 时触发,这里在 logout 已清理;
|
||
// 另加定时器兜底:扫描过期数据密钥并清理
|
||
setInterval(() => {
|
||
const now = Date.now();
|
||
for (const [sid, rec] of dbs) {
|
||
if (rec.dataKeyExpire && now > rec.dataKeyExpire + 60 * 1000) {
|
||
dbs.delete(sid); sessionKeys.delete(sid);
|
||
}
|
||
}
|
||
// 清理过期验证码
|
||
for (const [sid, cap] of captchaStore) {
|
||
if (now > cap.expires) captchaStore.delete(sid);
|
||
}
|
||
}, 60 * 1000).unref();
|
||
|
||
function startServer() {
|
||
// 启动前恢复/重建审计索引,使分页与本地文件一致
|
||
try { auditLoadIndex(); } catch (e) {}
|
||
const port = config.port;
|
||
const keyPath = process.env.SSL_KEY || path.join(__dirname, 'ssl', 'key.pem');
|
||
const certPath = process.env.SSL_CERT || path.join(__dirname, 'ssl', 'cert.pem');
|
||
const hasSsl = fs.existsSync(keyPath) && fs.existsSync(certPath);
|
||
if (hasSsl) {
|
||
// 生产模式:默认 HTTPS;同时起一个 HTTP 端口把所有请求 301 重定向到 HTTPS
|
||
const opt = { key: fs.readFileSync(keyPath), cert: fs.readFileSync(certPath) };
|
||
https.createServer(opt, app).listen(port, () => {
|
||
console.log(`[kdbx-viewer] HTTPS 已启动: https://localhost:${port}`);
|
||
if (!config.sessionSecret) console.warn('[config] 警告: SESSION_SECRET 未设置,已使用随机值(重启后会话失效)');
|
||
});
|
||
const redirectPort = port + 1;
|
||
http.createServer((req, res) => {
|
||
const host = req.headers.host ? req.headers.host.replace(/:\d+$/, '') : 'localhost';
|
||
res.writeHead(301, { Location: `https://${host}:${port}${req.url}` });
|
||
res.end();
|
||
}).listen(redirectPort, () => {
|
||
console.log(`[kdbx-viewer] HTTP(${redirectPort}) -> HTTPS(${port}) 重定向已启用`);
|
||
});
|
||
} else {
|
||
// 开发模式(无证书):仍允许 HTTP,但明确提示生产必须用 HTTPS
|
||
http.createServer(app).listen(port, () => {
|
||
console.log(`[kdbx-viewer] HTTP 已启动: http://localhost:${port}`);
|
||
console.warn('[安全] 未检测到 SSL 证书,已使用明文 HTTP。生产环境请配置 SSL_KEY/SSL_CERT 启用 HTTPS。');
|
||
});
|
||
}
|
||
}
|
||
startServer();
|
||
|
||
module.exports = app;
|