feat(vault): 凭据库(密码+2FA 动态码)与 MASTER_KEY 密钥托管
credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
This commit is contained in:
+108
-12
@@ -293,6 +293,8 @@ const AccountsViewModal = {
|
||||
<div class="text-xs text-slate-400 truncate"><span v-if="a.remark">{{ a.name }} · </span>关联 {{ assetsOf(a).length }} 个资产</div>
|
||||
</div>
|
||||
<button v-if="a.has_login_password" @click="viewPwd(a)" class="text-xs text-amber-600 dark:text-amber-400" title="查看登录密码">🔑</button>
|
||||
<button v-if="a.has_login_password" @click="copyAcctPwd(a)" class="text-xs text-slate-500 dark:text-slate-400" title="复制登录密码">复制</button>
|
||||
<button v-if="a.has_otp" @click="viewPwd(a)" class="text-xs text-blue-600 dark:text-blue-400" title="该账号已绑定 2FA,点 🔑 一并查看动态码">2FA</button>
|
||||
<button v-if="canSync(a)" @click="testAcct(a)" class="text-xs text-emerald-600 dark:text-emerald-400">测试</button>
|
||||
<button v-if="canSync(a)" @click="syncAcct(a)" class="text-xs text-violet-600 dark:text-violet-400">同步</button>
|
||||
<button @click="edit(a)" class="text-xs text-blue-600 dark:text-blue-400">编辑</button>
|
||||
@@ -326,17 +328,26 @@ const AccountsViewModal = {
|
||||
<span class="font-mono break-all">{{ pwd.password }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 账号绑定了 2FA:一并展示动态码(服务端生成,本地倒计时) -->
|
||||
<div v-if="pwd.otpId && otp.code" class="mt-3 flex items-center gap-2 bg-blue-50 dark:bg-blue-900/20 rounded-lg px-3 py-2">
|
||||
<span class="text-[10px] text-slate-500 shrink-0">2FA</span>
|
||||
<span class="font-mono text-xl tracking-widest text-blue-700 dark:text-blue-300">{{ otp.code }}</span>
|
||||
<span class="text-[10px] text-slate-400 shrink-0">{{ otp.expiresIn }}s</span>
|
||||
<button @click="copyOtpCode" class="ml-auto text-[11px] px-2 py-0.5 rounded border border-blue-300 dark:border-blue-700 text-blue-600 dark:text-blue-400 shrink-0">复制</button>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4">
|
||||
<button @click="copyPwd" class="flex-1 py-1.5 rounded-lg bg-blue-600 text-white text-xs">{{ pwd.copied ? '✓ 已复制' : '复制密码' }}</button>
|
||||
<button @click="pwd.show=false" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
|
||||
<button @click="closePwd" class="px-3 py-1.5 rounded-lg border border-slate-200 dark:border-slate-700 text-xs">关闭</button>
|
||||
</div>
|
||||
<p class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
|
||||
<p v-if="pwd.countdown" class="text-[10px] text-slate-400 mt-2 text-center">{{ pwd.countdown }}s 后自动关闭</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const expanded = Vue.reactive({});
|
||||
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, _timer: null });
|
||||
const pwd = Vue.reactive({ show: false, name: '', login_user: '', password: '', copied: false, countdown: 0, otpId: null, _timer: null });
|
||||
// 当前动态码(来自全局 otpState,服务端生成、secret 不下发)
|
||||
const otp = Vue.computed(() => (pwd.otpId ? (otpState[pwd.otpId] || { code: '', expiresIn: 0 }) : { code: '', expiresIn: 0 }));
|
||||
const singleProvider = Vue.computed(() => !!store.accountsModal.providerSlug);
|
||||
const title = Vue.computed(() => {
|
||||
const slug = store.accountsModal.providerSlug;
|
||||
@@ -396,7 +407,7 @@ const AccountsViewModal = {
|
||||
Vue.watch(() => store.accountsModal.show, (show) => {
|
||||
if (show) for (const k of Object.keys(expanded)) delete expanded[k];
|
||||
});
|
||||
// 查看密码:拉取明文并展示,8 秒后自动关闭;关闭时清除定时器
|
||||
// 查看密码:拉取明文并展示,8 秒后自动关闭;账号绑定 2FA 时一并取动态码且不自动关
|
||||
async function viewPwd(a) {
|
||||
try {
|
||||
const r = await Api.get('/accounts/' + a.id + '/password');
|
||||
@@ -406,13 +417,28 @@ const AccountsViewModal = {
|
||||
pwd.copied = false;
|
||||
pwd.show = true;
|
||||
if (pwd._timer) clearInterval(pwd._timer);
|
||||
pwd.countdown = 8;
|
||||
pwd._timer = setInterval(() => {
|
||||
pwd.countdown--;
|
||||
if (pwd.countdown <= 0) { pwd.show = false; clearInterval(pwd._timer); pwd._timer = null; }
|
||||
}, 1000);
|
||||
if (a.has_otp && a.credential_id) {
|
||||
// 验证码需看满 30s 周期,此时不自动关闭(countdown=0 不显示倒计时)
|
||||
pwd.otpId = a.credential_id;
|
||||
pwd.countdown = 0;
|
||||
fetchOtp(a.credential_id);
|
||||
} else {
|
||||
pwd.otpId = null;
|
||||
pwd.countdown = 8;
|
||||
pwd._timer = setInterval(() => {
|
||||
pwd.countdown--;
|
||||
if (pwd.countdown <= 0) closePwd();
|
||||
}, 1000);
|
||||
}
|
||||
} catch (e) { alert('获取密码失败:' + e.message); }
|
||||
}
|
||||
function closePwd() {
|
||||
pwd.show = false;
|
||||
pwd.password = '';
|
||||
if (pwd.otpId) { stopOtp(pwd.otpId); pwd.otpId = null; }
|
||||
if (pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; }
|
||||
}
|
||||
function copyOtpCode() { if (otp.value.code) copyText(otp.value.code, '验证码'); }
|
||||
function copyPwd() {
|
||||
const done = () => { pwd.copied = true; setTimeout(() => { pwd.copied = false; }, 1500); };
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
@@ -425,13 +451,83 @@ const AccountsViewModal = {
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
}
|
||||
// 关闭弹窗时清定时器,避免泄漏
|
||||
Vue.watch(() => pwd.show, (v) => { if (!v && pwd._timer) { clearInterval(pwd._timer); pwd._timer = null; } });
|
||||
// 关闭弹窗时清定时器并停掉动态码刷新,避免泄漏
|
||||
Vue.watch(() => pwd.show, (v) => { if (!v) closePwd(); });
|
||||
return {
|
||||
store, Fmt, expanded, pwd, singleProvider, title, accountList, groups,
|
||||
store, Fmt, expanded, pwd, otp, singleProvider, title, accountList, groups,
|
||||
platformName, assetsOf, toggle, canSync, testAcct, syncAcct, viewPwd, copyPwd,
|
||||
closePwd, copyOtpCode, copyAcctPwd: copyAccountPassword,
|
||||
add: () => openAccountCreate(store.accountsModal.providerSlug || ''),
|
||||
edit: openAccountEdit, del: deleteAccount,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/* 凭据编辑模态框(密码库)
|
||||
*
|
||||
* 粒度 = 站点 × 登录方式:同一邮箱注册多个站点就录多条,密码相同也各存一份。
|
||||
* 2FA 录入必须同时填当前动态码(服务端校验),防 secret 手误成废条目。
|
||||
*/
|
||||
const CredentialModal = {
|
||||
template: `
|
||||
<div v-if="store.credentialModal.show" class="fixed inset-0 bg-black/40 z-[60] flex items-end md:items-center justify-center p-0 md:p-4">
|
||||
<div class="bg-white dark:bg-slate-900 w-full md:max-w-md md:rounded-xl rounded-t-xl max-h-[92vh] overflow-y-auto">
|
||||
<div class="px-5 py-3 border-b border-slate-100 dark:border-slate-800 flex justify-between items-center">
|
||||
<h3 class="font-semibold text-sm">{{ store.credentialModal.editing ? '编辑凭据' : '新增凭据' }}</h3>
|
||||
<button @click="store.credentialModal.show=false" class="text-slate-400 hover:text-slate-600">✕</button>
|
||||
</div>
|
||||
<form @submit.prevent="save" class="px-5 py-4 space-y-3" v-if="f">
|
||||
<label class="block"><span class="text-xs text-slate-500">站点/服务名 *</span>
|
||||
<input v-model="f.site" required placeholder="如 GitHub、某论坛、邮箱" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label class="block"><span class="text-xs text-slate-500">登录方式</span>
|
||||
<select v-model="f.login_type" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
|
||||
<option v-for="(l,k) in Fmt.LOGIN_TYPE_LABELS" :key="k" :value="k">{{ l }}</option>
|
||||
</select></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">用户名/邮箱</span>
|
||||
<input v-model="f.username" placeholder="如 you@gmail.com" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
|
||||
</div>
|
||||
<!-- 授权登录:只需记录来源,本站无独立密码 -->
|
||||
<label v-if="f.login_type==='oauth'" class="block"><span class="text-xs text-slate-500">授权来源</span>
|
||||
<input v-model="f.oauth_provider" list="oauth-provider-options" placeholder="google / apple / github / wechat" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
|
||||
<datalist id="oauth-provider-options">
|
||||
<option v-for="p in Fmt.OAUTH_PROVIDERS" :key="p" :value="p"></option>
|
||||
</datalist></label>
|
||||
<label v-else-if="f.login_type==='password'" class="block"><span class="text-xs text-slate-500">登录密码(加密存储)</span>
|
||||
<input v-model="f.password" type="password" autocomplete="new-password" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
|
||||
<label class="block"><span class="text-xs text-slate-500">登录页地址</span>
|
||||
<input v-model="f.url" placeholder="https://…(可选,便于直接跳转)" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
|
||||
|
||||
<fieldset class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
|
||||
<legend class="text-xs font-medium text-slate-600 dark:text-slate-300 px-1">🛡 2FA(TOTP 动态码)</legend>
|
||||
<p class="text-[11px] text-slate-400 leading-relaxed">粘贴 otpauth:// 链接或直接填 secret;需同时填写当前 6 位动态码做校验,防止 secret 录错后永远算不出正确码。</p>
|
||||
<label class="block"><span class="text-xs text-slate-400">secret 或 otpauth 链接</span>
|
||||
<input v-model="f.otp_secret" :placeholder="store.credentialModal.editing ? '留空不修改' : '可选'" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono"></label>
|
||||
<label v-if="f.otp_secret" class="block"><span class="text-xs text-slate-400">当前 6 位动态码 *</span>
|
||||
<input v-model="f.otp_code" inputmode="numeric" maxlength="6" placeholder="如 123456" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm font-mono tracking-widest"></label>
|
||||
</fieldset>
|
||||
|
||||
<label class="block"><span class="text-xs text-slate-500">备注</span>
|
||||
<textarea v-model="f.note" rows="2" placeholder="如:备用邮箱注册、公司账号等" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></textarea></label>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" @click="store.credentialModal.show=false" class="px-4 py-2 rounded-lg border border-slate-300 dark:border-slate-700 text-sm text-slate-600 dark:text-slate-300">取消</button>
|
||||
<button type="submit" :disabled="store.credentialSaving" class="px-4 py-2 rounded-lg bg-blue-600 text-white text-sm hover:bg-blue-700 disabled:opacity-60">{{ store.credentialSaving ? '保存中…' : '保存' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>`,
|
||||
setup() {
|
||||
const f = Vue.computed(() => store.credentialModal.form);
|
||||
// 2FA 前端预校验:填了 secret 就必须填 6 位码(服务端会再校验一次)
|
||||
function save() {
|
||||
const form = f.value;
|
||||
if (form.otp_secret && form.otp_secret.trim() && !/^\d{6}$/.test((form.otp_code || '').trim())) {
|
||||
store.error = '绑定 2FA 需同时填写当前 6 位动态码';
|
||||
return;
|
||||
}
|
||||
saveCredential();
|
||||
}
|
||||
return { store, Fmt, f, save };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user