feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管
credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
This commit is contained in:
+153
-1
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user