feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管

credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
This commit is contained in:
gouki
2026-09-05 15:54:41 +00:00
parent 2fbb658126
commit d711ad5827
26 changed files with 2529 additions and 35 deletions
+153 -1
View File
@@ -28,6 +28,12 @@ const store = reactive({
accountModal: { show: false, editing: null, form: null },
accountSaving: false, // 账号保存中锁,防止双击/重复提交触发后端 400 重名
accountsModal: { show: false, providerSlug: null }, // 账号查看弹窗:providerSlug 为 null 时看全部
// 凭据库(密码 + 2FA):全量列表,搜索/过滤交给服务端
credentials: [],
credentialModal: { show: false, editing: null, form: null },
credentialSaving: false, // 保存锁,防双击重复提交
vaultSearch: '',
vaultOtpOnly: false, // 只看已绑定 2FA 的条目
});
function applyDark() {
@@ -85,13 +91,19 @@ async function loadAssets() {
}
async function loadProviders() { store.providers = await Api.get('/providers'); }
async function loadAccounts() { store.accounts = await Api.get('/accounts'); }
async function loadCredentials() {
const params = new URLSearchParams();
if (store.vaultSearch) params.set('q', store.vaultSearch);
if (store.vaultOtpOnly) params.set('has_otp', 'true');
store.credentials = await Api.get('/credentials?' + params.toString());
}
async function loadSubdomains() { store.subdomains = await Api.get('/subdomains'); }
async function loadSiteCerts() { store.siteCerts = await Api.get('/site-certs'); }
async function loadOverview() { store.overview = await Api.get('/stats/overview'); }
async function loadExpiring() { store.expiring = await Api.get('/stats/expiring?days=30'); }
async function loadAll() {
store.loading = true; store.error = '';
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
try { await Promise.all([loadAssets(), loadProviders(), loadAccounts(), loadCredentials(), loadSubdomains(), loadSiteCerts(), loadOverview(), loadExpiring()]); }
catch (e) { store.error = e.message; } finally { store.loading = false; }
}
@@ -211,6 +223,146 @@ async function deleteAccount(a) {
catch (e) { store.error = e.message; }
}
/* ---------------- 凭据库(密码 + 2FA ---------------- */
function emptyCredentialForm() {
return {
site: '', username: '', login_type: 'password', oauth_provider: '',
password: '', url: '', note: '', otp_secret: '', otp_code: '',
};
}
function openCredentialCreate(preset) {
const form = emptyCredentialForm();
if (preset) Object.assign(form, preset);
store.credentialModal = { show: true, editing: null, form };
}
function openCredentialEdit(c) {
// password / otp_secret 留空 = 不修改(与账号凭证惯例一致)
store.credentialModal = {
show: true, editing: c.id,
form: {
site: c.site, username: c.username || '', login_type: c.login_type,
oauth_provider: c.oauth_provider || '', password: '', url: c.url || '',
note: c.note || '', otp_secret: '', otp_code: '',
},
};
}
async function saveCredential() {
// 提交锁:双击/网络慢时重复点击会发出两次请求,造成重复条目
if (store.credentialSaving) return;
store.error = '';
store.credentialSaving = true;
const f = store.credentialModal.form;
const editing = store.credentialModal.editing;
const payload = {
site: f.site, username: f.username || null, login_type: f.login_type,
oauth_provider: f.oauth_provider || null, url: f.url || null, note: f.note || null,
};
try {
if (editing) {
// 密码留空 = 不修改(后端 None 语义),填了才提交
if (f.password) payload.password = f.password;
await Api.put('/credentials/' + editing, payload);
// 2FA 走独立接口(secret + 当前动态码服务端校验)
if (f.otp_secret) {
await Api.put('/credentials/' + editing + '/otp', { secret: f.otp_secret, code: f.otp_code });
}
} else {
payload.password = f.password || null;
if (f.otp_secret) { payload.otp_secret = f.otp_secret; payload.otp_code = f.otp_code || null; }
await Api.post('/credentials', payload);
}
store.credentialModal.show = false;
// 账号侧 has_login_password / has_otp 来自关联凭据,需一并刷新
await Promise.all([loadCredentials(), loadAccounts()]);
} catch (e) { store.error = e.message; }
finally { store.credentialSaving = false; }
}
async function deleteCredential(c) {
const label = c.site + (c.username ? ' · ' + c.username : '');
if (!confirm('确认删除凭据「' + label + '」?此操作不可恢复。')) return;
store.error = '';
try {
await Api.del('/credentials/' + c.id);
await Promise.all([loadCredentials(), loadAccounts()]);
} catch (e) { store.error = e.message; }
}
async function unbindCredentialOtp(c) {
if (!confirm('确认解绑「' + c.site + '」的 2FA?解绑后需重新录入 secret。')) return;
store.error = '';
try { await Api.del('/credentials/' + c.id + '/otp'); await loadCredentials(); }
catch (e) { store.error = e.message; }
}
/* ---------------- 复制与轻提示(toast ---------------- */
const toast = Vue.reactive({ show: false, text: '', _timer: null });
function showToast(text) {
toast.text = text;
toast.show = true;
if (toast._timer) clearTimeout(toast._timer);
toast._timer = setTimeout(() => { toast.show = false; }, 1600);
}
function copyText(text, label) {
const done = () => showToast('✓ ' + (label || '内容') + '已复制');
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(done).catch(() => _fallbackCopy(text, done));
} else { _fallbackCopy(text, done); }
}
function _fallbackCopy(text, done) {
// 非安全上下文(http 局域网)无 clipboard API,降级用 execCommand
const ta = document.createElement('textarea');
ta.value = text;
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); done(); } catch (e) { showToast('复制失败,请手动选择'); }
document.body.removeChild(ta);
}
async function copyCredentialPassword(c) {
store.error = '';
try {
const r = await Api.get('/credentials/' + c.id + '/password');
copyText(r.password, '密码');
} catch (e) { store.error = e.message; }
}
async function copyAccountPassword(a) {
// 账号侧复制:后端已重定向到关联凭据,接口语义不变
store.error = '';
try {
const r = await Api.get('/accounts/' + a.id + '/password');
copyText(r.password, '密码');
} catch (e) { store.error = e.message; }
}
/* ---------------- 2FA 动态码(服务端生成,本地倒计时) ---------------- */
// otpState[credential_id] = { code, expiresIn, loading, timer }secret 永不下发到前端
const otpState = Vue.reactive({});
async function fetchOtp(id) {
// 先经 Proxy 创建占位对象,再取回响应式代理。
// 注意不能写 `const s = otpState[id] || (otpState[id] = {...})`:赋值表达式的
// 返回值是 raw 对象,后续 s.code = ... 绕过 Proxy 不触发视图更新(Vue 3 陷阱)。
if (!otpState[id]) otpState[id] = { code: '', expiresIn: 0, loading: false, timer: null };
const s = otpState[id];
s.loading = true;
try {
const r = await Api.get('/credentials/' + id + '/otp');
s.code = r.code;
s.expiresIn = r.expires_in;
if (s.timer) clearInterval(s.timer);
// 本地逐秒倒计时,归零自动拉下一个码(不做秒级轮询)
s.timer = setInterval(() => {
s.expiresIn--;
if (s.expiresIn <= 0) { clearInterval(s.timer); s.timer = null; fetchOtp(id); }
}, 1000);
} catch (e) { store.error = e.message; }
finally { s.loading = false; }
}
function stopOtp(id) {
const s = otpState[id];
if (s && s.timer) { clearInterval(s.timer); s.timer = null; }
}
function stopAllOtp() {
for (const id of Object.keys(otpState)) stopOtp(Number(id));
}
/* ---------------- 平台页跳转:按平台查看资产 ---------------- */
function openAssetsByProvider(p) {
store.filterProvider = p.slug;