增加安全码本地保存功能
Some checks are pending
Security Checks / security (push) Waiting to run

安全码验证后支持下载到本地文件,并补充前端测试。
This commit is contained in:
smail 2026-10-05 18:37:52 +08:00
parent dbba255ce1
commit 3fa621255e
4 changed files with 131 additions and 3 deletions

View File

@ -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
}

View File

@ -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,
}

View File

@ -92,7 +92,33 @@
>
<div class="settings-header security-key-header">
<div><h3>安全码</h3><p class="settings-desc">安全码默认隐藏,完成安全验证后可以重新查看。</p></div>
<div class="section-actions"><el-button v-if="recoveryConfigured && !generatedCodes.length" @click="revealRecoveryCodes">验证后查看</el-button><el-button type="primary" @click="generateRecoveryCodes">生成新恢复码</el-button><el-button v-if="recoveryConfigured" type="danger" plain @click="revokeRecoveryCodes">撤销</el-button></div>
<div class="section-actions">
<el-button
v-if="recoveryConfigured && !generatedCodes.length"
@click="revealRecoveryCodes"
>
验证后查看
</el-button>
<el-button
v-if="generatedCodes.length"
type="primary"
plain
@click="saveRecoveryCodes"
>
保存安全码
</el-button>
<el-button type="primary" @click="generateRecoveryCodes">
生成新恢复码
</el-button>
<el-button
v-if="recoveryConfigured"
type="danger"
plain
@click="revokeRecoveryCodes"
>
撤销
</el-button>
</div>
</div>
<el-alert v-if="recoveryConfigured" title="当前已配置有效离线恢复码" type="success" :closable="false" show-icon />
<el-alert v-else title="尚未配置安全码;已验证外部邮箱也可满足安全配置要求。" type="warning" :closable="false" show-icon />
@ -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()

View File

@ -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')
})