安全码验证后支持下载到本地文件,并补充前端测试。
This commit is contained in:
parent
dbba255ce1
commit
3fa621255e
23
frontend/src/utils/recoveryCodeDownload.js
Normal file
23
frontend/src/utils/recoveryCodeDownload.js
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
72
test/frontend/security-code-save.test.mjs
Normal file
72
test/frontend/security-code-save.test.mjs
Normal 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')
|
||||
})
|
||||
Loading…
Reference in New Issue
Block a user