From 3fa621255e68ca241e75fa92ab4ee8aeff07bbe4 Mon Sep 17 00:00:00 2001 From: smail Date: Mon, 5 Oct 2026 18:37:52 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AE=89=E5=85=A8=E7=A0=81?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E4=BF=9D=E5=AD=98=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 安全码验证后支持下载到本地文件,并补充前端测试。 --- frontend/src/utils/recoveryCodeDownload.js | 23 ++++++ .../SecuritySettings/SecuritySettings.js | 9 ++- .../SecuritySettings/SecuritySettings.vue | 30 +++++++- test/frontend/security-code-save.test.mjs | 72 +++++++++++++++++++ 4 files changed, 131 insertions(+), 3 deletions(-) create mode 100644 frontend/src/utils/recoveryCodeDownload.js create mode 100644 test/frontend/security-code-save.test.mjs diff --git a/frontend/src/utils/recoveryCodeDownload.js b/frontend/src/utils/recoveryCodeDownload.js new file mode 100644 index 0000000..c67805d --- /dev/null +++ b/frontend/src/utils/recoveryCodeDownload.js @@ -0,0 +1,23 @@ +export function downloadRecoveryCodes(codes, documentRef = document, urlApi = URL) { + if (!Array.isArray(codes) || codes.length === 0) return false + + const blob = new Blob([`${codes.join('\n')}\n`], { + type: 'text/plain;charset=utf-8', + }) + const objectUrl = urlApi.createObjectURL(blob) + const anchor = documentRef.createElement('a') + + anchor.href = objectUrl + anchor.download = 'smail-recovery-codes.txt' + anchor.style.display = 'none' + documentRef.body.appendChild(anchor) + + try { + anchor.click() + } finally { + anchor.remove() + urlApi.revokeObjectURL(objectUrl) + } + + return true +} diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.js b/frontend/src/views/components/SecuritySettings/SecuritySettings.js index 71b388f..655602e 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.js +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.js @@ -11,6 +11,7 @@ import { } from '@/utils/recentAuthentication' import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore' import { normalizeCreationOptions, serializeCredential, webAuthnSupported } from '@/utils/webauthn' +import { downloadRecoveryCodes } from '@/utils/recoveryCodeDownload' export function usePage() { const globalStatus = useGlobalStatusStore() @@ -296,6 +297,12 @@ export function usePage() { } } + const saveRecoveryCodes = () => { + if (!generatedCodes.value.length) return + downloadRecoveryCodes(generatedCodes.value) + notify('安全码已保存到本地文件,文件不会上传服务器', 'success') + } + const revokeRecoveryCodes = async () => { if (recoveryLoading.value) return clearRecoveryCodes() @@ -441,7 +448,7 @@ export function usePage() { registering, credentialsLoading, credentials, securityLoading, recoveryLoading, securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalListLoading, externalDialogVisible, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential, - saveSecurityQuestion, generateRecoveryCodes, revealRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog, + saveSecurityQuestion, generateRecoveryCodes, revealRecoveryCodes, saveRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog, handleExternalEmailBound, disableExternalEmail, formatTime, } diff --git a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue index 12672a4..e764d20 100644 --- a/frontend/src/views/components/SecuritySettings/SecuritySettings.vue +++ b/frontend/src/views/components/SecuritySettings/SecuritySettings.vue @@ -92,7 +92,33 @@ >

安全码

安全码默认隐藏,完成安全验证后可以重新查看。

-
验证后查看生成新恢复码撤销
+
+ + 验证后查看 + + + 保存安全码 + + + 生成新恢复码 + + + 撤销 + +
@@ -158,7 +184,7 @@ const { registering, credentialsLoading, credentials, securityLoading, recoveryLoading, securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalListLoading, externalDialogVisible, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential, - saveSecurityQuestion, generateRecoveryCodes, revealRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog, + saveSecurityQuestion, generateRecoveryCodes, revealRecoveryCodes, saveRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog, handleExternalEmailBound, disableExternalEmail, formatTime, } = usePage() diff --git a/test/frontend/security-code-save.test.mjs b/test/frontend/security-code-save.test.mjs new file mode 100644 index 0000000..220c491 --- /dev/null +++ b/test/frontend/security-code-save.test.mjs @@ -0,0 +1,72 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { downloadRecoveryCodes } from '../../frontend/src/utils/recoveryCodeDownload.js' + +function createDocumentFixture() { + const anchors = [] + const body = { + appendChild(anchor) { + anchors.push(anchor) + }, + } + return { + anchors, + documentRef: { + body, + createElement() { + return { + style: {}, + click() { + this.clicked = true + }, + remove() { + this.removed = true + }, + } + }, + }, + } +} + +test('没有安全码时不生成下载文件', () => { + const fixture = createDocumentFixture() + let created = 0 + const urlApi = { + createObjectURL() { + created += 1 + return 'blob:test' + }, + revokeObjectURL() {}, + } + + assert.equal(downloadRecoveryCodes([], fixture.documentRef, urlApi), false) + assert.equal(created, 0) + assert.equal(fixture.anchors.length, 0) +}) + +test('安全码只在浏览器本地生成下载,不产生后端请求', async () => { + const fixture = createDocumentFixture() + const blobs = [] + const revoked = [] + const urlApi = { + createObjectURL(blob) { + blobs.push(blob) + return 'blob:recovery-codes' + }, + revokeObjectURL(url) { + revoked.push(url) + }, + } + + assert.equal( + downloadRecoveryCodes(['ABCD-1234', 'EFGH-5678'], fixture.documentRef, urlApi), + true + ) + assert.equal(fixture.anchors.length, 1) + assert.equal(fixture.anchors[0].href, 'blob:recovery-codes') + assert.equal(fixture.anchors[0].download, 'smail-recovery-codes.txt') + assert.equal(fixture.anchors[0].clicked, true) + assert.equal(fixture.anchors[0].removed, true) + assert.deepEqual(revoked, ['blob:recovery-codes']) + assert.equal(await blobs[0].text(), 'ABCD-1234\nEFGH-5678\n') +})