449 lines
19 KiB
JavaScript
449 lines
19 KiB
JavaScript
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||
|
||
import { ElMessageBox, ElNotification } from 'element-plus'
|
||
|
||
import lang from '../../../i18n/i18n'
|
||
import { http } from '@/utils/axios'
|
||
import {
|
||
confirmRecentAuthentication,
|
||
isRecentAuthenticationCancelled,
|
||
requestWithRecentAuthentication,
|
||
} from '@/utils/recentAuthentication'
|
||
import { useGlobalStatusStore } from '@/stores/useGlobalStatusStore'
|
||
import { normalizeCreationOptions, serializeCredential, webAuthnSupported } from '@/utils/webauthn'
|
||
|
||
export function usePage() {
|
||
const globalStatus = useGlobalStatusStore()
|
||
const isSuperAdmin = computed(() => globalStatus.role === 'super_admin')
|
||
const supported = webAuthnSupported()
|
||
const webAuthnEnabled = computed(() => globalStatus.userInfos.webAuthnEnabled === true)
|
||
const canReadGlobalPolicy = computed(() => globalStatus.hasCapability('config.read'))
|
||
const sectionOptions = computed(() => {
|
||
const options = []
|
||
options.push({ value: 'profile', label: '个人资料' })
|
||
if (canReadGlobalPolicy.value) {
|
||
options.push({ value: 'security-policy', label: '安全策略' })
|
||
}
|
||
options.push({ value: 'password', label: lang.modify_pwd })
|
||
options.push({ value: 'question', label: '密保问题' })
|
||
options.push({ value: 'recovery', label: '安全码' })
|
||
options.push({ value: 'external-email', label: '外部安全邮箱' })
|
||
options.push({ value: 'totp', label: '手机动态码' })
|
||
options.push({ value: 'webauthn', label: '安全密钥' })
|
||
return globalStatus.userInfos.securitySetupRequired
|
||
? options.filter(item => ['recovery', 'external-email', 'totp'].includes(item.value))
|
||
: options
|
||
})
|
||
const selectedSection = computed({
|
||
get: () => globalStatus.securityActiveSection,
|
||
set: value => { globalStatus.securityActiveSection = value }
|
||
})
|
||
|
||
const globalSecurityPolicyPage = ref(null)
|
||
const selectSection = async value => {
|
||
if (!sectionOptions.value.some(item => item.value === value) || value === selectedSection.value) return
|
||
if (selectedSection.value === 'security-policy' && !await (globalSecurityPolicyPage.value?.beforeLeave?.() ?? true)) return
|
||
selectedSection.value = value
|
||
}
|
||
|
||
watch(sectionOptions, options => {
|
||
if (!options.some(item => item.value === selectedSection.value)) {
|
||
selectedSection.value = options[0]?.value || ''
|
||
}
|
||
}, { immediate: true })
|
||
|
||
const registering = ref(false)
|
||
const credentialsLoading = ref(false)
|
||
const credentials = ref([])
|
||
const securityLoading = ref(false)
|
||
const securityQuestion = reactive({ configured: false, question: '' })
|
||
const questionForm = reactive({ question: '', answer: '' })
|
||
const recoveryConfigured = ref(false)
|
||
const generatedCodes = ref([])
|
||
const recoveryLoading = ref(false)
|
||
let recoveryCodeHideTimer = null
|
||
let recoveryViewVersion = 0
|
||
let pageActive = true
|
||
|
||
const clearRecoveryCodes = () => {
|
||
recoveryViewVersion += 1
|
||
generatedCodes.value = []
|
||
clearTimeout(recoveryCodeHideTimer)
|
||
recoveryCodeHideTimer = null
|
||
}
|
||
|
||
const isRecoveryViewCurrent = version => (
|
||
pageActive && selectedSection.value === 'recovery' && version === recoveryViewVersion
|
||
)
|
||
|
||
const handleVisibilityChange = () => {
|
||
if (document.hidden) clearRecoveryCodes()
|
||
}
|
||
|
||
const requestRecoveryAction = (action, data, version) => (
|
||
requestWithRecentAuthentication(() => {
|
||
if (!isRecoveryViewCurrent(version)) return null
|
||
return http.post(`/api/v1/security/credentials/recovery-codes/${action}`, data)
|
||
})
|
||
)
|
||
|
||
const externalEmails = ref([])
|
||
const externalListLoading = ref(false)
|
||
const externalDialogVisible = ref(false)
|
||
const passwordEmail = computed(() => {
|
||
const verified = externalEmails.value.filter(item => item.status === 'verified')
|
||
return verified.find(item => item.type === 'primary') || verified.find(item => item.type === 'backup')
|
||
})
|
||
const passwordSending = ref(false)
|
||
const passwordSubmitting = ref(false)
|
||
const passwordChallenge = ref('')
|
||
const passwordCountdown = ref(0)
|
||
let passwordTimer = null
|
||
let lastNotificationKey = ''
|
||
let lastNotificationAt = 0
|
||
|
||
const ruleForm = reactive({ current_pwd: '', new_pwd: '', new_pwd2: '', code: '' })
|
||
const rules = reactive({
|
||
current_pwd: [{ required: true, message: lang.err_required_pwd, trigger: 'blur' }],
|
||
new_pwd: [{ required: true, message: lang.err_required_pwd, trigger: 'blur' }],
|
||
new_pwd2: [{ required: true, message: lang.err_required_pwd, trigger: 'blur' }],
|
||
})
|
||
|
||
const notify = (message, type = 'error') => {
|
||
const notificationKey = `${type}:${String(message || '').trim()}`
|
||
const now = Date.now()
|
||
if (notificationKey === lastNotificationKey && now - lastNotificationAt < 1500) return
|
||
lastNotificationKey = notificationKey
|
||
lastNotificationAt = now
|
||
ElNotification({ title: type === 'success' ? '操作成功' : '操作失败', message, type })
|
||
}
|
||
|
||
const loadCredentials = async () => {
|
||
if (globalStatus.userInfos.securitySetupRequired) return
|
||
if (!supported || !webAuthnEnabled.value) return
|
||
credentialsLoading.value = true
|
||
try {
|
||
const res = await http.get('/api/v1/security/webauthn/credentials')
|
||
if (res.errorNo !== 0) throw new Error(res.errorMsg || '加载安全密钥失败')
|
||
credentials.value = Array.isArray(res.data) ? res.data : []
|
||
} catch (error) {
|
||
notify(error?.message || '加载安全密钥失败')
|
||
} finally {
|
||
credentialsLoading.value = false
|
||
}
|
||
}
|
||
|
||
const loadSecurityData = async () => {
|
||
securityLoading.value = true
|
||
try {
|
||
const requests = [
|
||
http.get('/api/v1/security/credentials/question'),
|
||
http.get('/api/v1/security/external-email/list'),
|
||
]
|
||
requests.push(http.get('/api/v1/security/credentials/recovery-codes'))
|
||
const [question, external, recovery] = await Promise.all(requests)
|
||
if (question.errorNo === 0 && question.data) {
|
||
securityQuestion.configured = Boolean(question.data.configured)
|
||
securityQuestion.question = question.data.question || ''
|
||
questionForm.question = question.data.question || ''
|
||
}
|
||
if (recovery && recovery.errorNo === 0) recoveryConfigured.value = Boolean(recovery.data?.configured)
|
||
if (external.errorNo === 0) externalEmails.value = Array.isArray(external.data) ? external.data : []
|
||
} catch (error) {
|
||
notify(error?.message || '读取账户安全设置失败')
|
||
} finally {
|
||
securityLoading.value = false
|
||
}
|
||
}
|
||
|
||
const loadExternalEmails = async () => {
|
||
externalListLoading.value = true
|
||
try {
|
||
const response = await http.get('/api/v1/security/external-email/list')
|
||
if (response.errorNo !== 0) throw new Error(response.errorMsg || '读取外部邮箱失败')
|
||
externalEmails.value = Array.isArray(response.data) ? response.data : []
|
||
await globalStatus.refresh()
|
||
return true
|
||
} catch (error) {
|
||
notify(error?.message || '读取外部邮箱失败')
|
||
return false
|
||
} finally {
|
||
externalListLoading.value = false
|
||
}
|
||
}
|
||
|
||
const registerCredential = async () => {
|
||
if (!supported || !webAuthnEnabled.value || registering.value) return
|
||
registering.value = true
|
||
try {
|
||
const prompt = await ElMessageBox.prompt('请为新安全密钥设置一个名称', '注册安全密钥', {
|
||
confirmButtonText: '确定', cancelButtonText: '取消', inputPlaceholder: '例如:工作电脑',
|
||
inputValidator: value => String(value || '').trim().length > 0 || '请输入密钥名称',
|
||
})
|
||
const begin = await http.post('/api/v1/security/webauthn/registration/begin', {})
|
||
if (begin.errorNo !== 0) throw new Error(begin.errorMsg || '无法开始安全密钥注册')
|
||
const credential = await navigator.credentials.create({ publicKey: normalizeCreationOptions(begin.data.options) })
|
||
const finish = await http.post('/api/v1/security/webauthn/registration/finish', {
|
||
session_id: begin.data.session_id,
|
||
display_name: String(prompt.value).trim(),
|
||
credential: serializeCredential(credential),
|
||
})
|
||
if (finish.errorNo !== 0) throw new Error(finish.errorMsg || '安全密钥注册失败')
|
||
notify('安全密钥注册成功', 'success')
|
||
await loadCredentials()
|
||
} catch (error) {
|
||
if (error !== 'cancel' && error !== 'close' && error?.name !== 'NotAllowedError') notify(error?.message || '安全密钥注册失败')
|
||
} finally {
|
||
registering.value = false
|
||
}
|
||
}
|
||
|
||
const renameCredential = async item => {
|
||
try {
|
||
const prompt = await ElMessageBox.prompt('请输入新的密钥名称', '重命名', {
|
||
confirmButtonText: '确定', cancelButtonText: '取消', inputValue: item.display_name,
|
||
inputValidator: value => String(value || '').trim().length > 0 || '请输入密钥名称',
|
||
})
|
||
const res = await http.post('/api/v1/security/webauthn/credentials/rename', { id: item.id, display_name: String(prompt.value).trim() })
|
||
if (res.errorNo !== 0) throw new Error(res.errorMsg || '密钥重命名失败')
|
||
notify('密钥重命名成功', 'success')
|
||
await loadCredentials()
|
||
} catch (error) {
|
||
if (error !== 'cancel' && error !== 'close') notify(error?.message || '密钥重命名失败')
|
||
}
|
||
}
|
||
|
||
const revokeCredential = async item => {
|
||
try {
|
||
await ElMessageBox.confirm(`确定要撤销密钥“${item.display_name}”吗?撤销后将不能用于登录`, '撤销安全密钥', {
|
||
confirmButtonText: '确定撤销', cancelButtonText: '取消', type: 'warning',
|
||
})
|
||
const res = await http.post('/api/v1/security/webauthn/credentials/revoke', { id: item.id })
|
||
if (res.errorNo !== 0) throw new Error(res.errorMsg || '撤销安全密钥失败')
|
||
notify('安全密钥已撤销', 'success')
|
||
await loadCredentials()
|
||
} catch (error) {
|
||
if (error !== 'cancel' && error !== 'close') notify(error?.message || '撤销安全密钥失败')
|
||
}
|
||
}
|
||
|
||
const saveSecurityQuestion = async () => {
|
||
if (!questionForm.question.trim() || !questionForm.answer.trim()) {
|
||
notify('请填写密保问题和答案')
|
||
return
|
||
}
|
||
const res = await http.post('/api/v1/security/credentials/question', questionForm)
|
||
if (res.errorNo !== 0) {
|
||
notify(res.errorMsg || '密保问题设置失败')
|
||
return
|
||
}
|
||
securityQuestion.configured = true
|
||
securityQuestion.question = questionForm.question.trim()
|
||
questionForm.answer = ''
|
||
notify('密保问题已保存', 'success')
|
||
}
|
||
|
||
const generateRecoveryCodes = async () => {
|
||
if (recoveryLoading.value) return
|
||
clearRecoveryCodes()
|
||
const version = recoveryViewVersion
|
||
recoveryLoading.value = true
|
||
try {
|
||
await ElMessageBox.confirm('生成新恢复码会撤销当前仍有效的恢复码,请确认已准备好安全保存。', '生成离线恢复码', { type: 'warning' })
|
||
if (!isRecoveryViewCurrent(version)) return
|
||
const res = await requestRecoveryAction('generate', { count: 12 }, version)
|
||
if (!isRecoveryViewCurrent(version)) return
|
||
if (res.errorNo !== 0 || !Array.isArray(res.data?.codes)) throw new Error(res.errorMsg || '离线恢复码生成失败')
|
||
generatedCodes.value = res.data.codes
|
||
scheduleRecoveryCodeHide()
|
||
recoveryConfigured.value = true
|
||
await globalStatus.refresh()
|
||
notify('恢复码已生成,请妥善保存;验证本人密码后可再次查看有效恢复码', 'success')
|
||
} catch (error) {
|
||
if (isRecentAuthenticationCancelled(error) || !isRecoveryViewCurrent(version)) return
|
||
if (error !== 'cancel' && error !== 'close') notify(error?.message || '离线恢复码生成失败')
|
||
} finally {
|
||
recoveryLoading.value = false
|
||
}
|
||
}
|
||
|
||
const scheduleRecoveryCodeHide = () => {
|
||
clearTimeout(recoveryCodeHideTimer)
|
||
recoveryCodeHideTimer = setTimeout(clearRecoveryCodes, 60000)
|
||
}
|
||
|
||
const revealRecoveryCodes = async () => {
|
||
if (recoveryLoading.value) return
|
||
clearRecoveryCodes()
|
||
const version = recoveryViewVersion
|
||
recoveryLoading.value = true
|
||
try {
|
||
await confirmRecentAuthentication()
|
||
if (!isRecoveryViewCurrent(version)) return
|
||
const res = await requestRecoveryAction('reveal', {}, version)
|
||
if (!isRecoveryViewCurrent(version)) return
|
||
if (res.errorNo !== 0 || !Array.isArray(res.data?.codes) || !res.data.codes.length) {
|
||
throw new Error(res.errorMsg || '读取安全码失败')
|
||
}
|
||
generatedCodes.value = res.data.codes
|
||
scheduleRecoveryCodeHide()
|
||
notify('安全码已显示,60 秒后自动隐藏', 'success')
|
||
} catch (error) {
|
||
if (isRecentAuthenticationCancelled(error) || !isRecoveryViewCurrent(version)) return
|
||
if (error !== 'cancel' && error !== 'close') notify(error?.message || '读取安全码失败')
|
||
} finally {
|
||
recoveryLoading.value = false
|
||
}
|
||
}
|
||
|
||
const revokeRecoveryCodes = async () => {
|
||
if (recoveryLoading.value) return
|
||
clearRecoveryCodes()
|
||
const version = recoveryViewVersion
|
||
recoveryLoading.value = true
|
||
try {
|
||
await ElMessageBox.confirm('撤销后现有离线恢复码将立即失效,是否继续?', '撤销离线恢复码', { type: 'warning' })
|
||
if (!isRecoveryViewCurrent(version)) return
|
||
const res = await requestRecoveryAction('revoke', {}, version)
|
||
if (!isRecoveryViewCurrent(version)) return
|
||
if (res.errorNo !== 0) throw new Error(res.errorMsg || '撤销恢复码失败')
|
||
recoveryConfigured.value = false
|
||
generatedCodes.value = []
|
||
clearTimeout(recoveryCodeHideTimer)
|
||
await globalStatus.refresh()
|
||
notify('离线恢复码已撤销', 'success')
|
||
} catch (error) {
|
||
if (isRecentAuthenticationCancelled(error) || !isRecoveryViewCurrent(version)) return
|
||
if (error !== 'cancel' && error !== 'close') notify(error?.message || '撤销恢复码失败')
|
||
} finally {
|
||
recoveryLoading.value = false
|
||
}
|
||
}
|
||
|
||
const openExternalEmailDialog = () => {
|
||
externalDialogVisible.value = true
|
||
}
|
||
|
||
const handleExternalEmailBound = async () => {
|
||
await loadExternalEmails()
|
||
notify('外部安全邮箱绑定成功', 'success')
|
||
}
|
||
|
||
const disableExternalEmail = async item => {
|
||
try {
|
||
await ElMessageBox.confirm(`确定停用 ${item.email} 吗?停用后不能用于登录验证和恢复。`, '停用外部邮箱', { type: 'warning' })
|
||
const res = await http.post('/api/v1/security/external-email/disable', { id: item.id })
|
||
if (res.errorNo !== 0) throw new Error(res.errorMsg || '外部邮箱停用失败')
|
||
await loadSecurityData()
|
||
await globalStatus.refresh()
|
||
notify('外部邮箱已停用', 'success')
|
||
} catch (error) {
|
||
if (error !== 'cancel' && error !== 'close') notify(error?.message || '外部邮箱停用失败')
|
||
}
|
||
}
|
||
|
||
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-'
|
||
|
||
const submit = async () => {
|
||
if (passwordSubmitting.value || passwordSending.value) return
|
||
if (!ruleForm.new_pwd || (!isSuperAdmin.value && !ruleForm.current_pwd)) {
|
||
notify('请填写完整的密码信息')
|
||
return
|
||
}
|
||
const passwordBytes = new TextEncoder().encode(ruleForm.new_pwd).length
|
||
if (passwordBytes < 8 || passwordBytes > 12) {
|
||
notify('密码长度必须为 8-12 字节')
|
||
return
|
||
}
|
||
if (ruleForm.new_pwd !== ruleForm.new_pwd2) {
|
||
notify(lang.err_pwd_diff)
|
||
return
|
||
}
|
||
if (isSuperAdmin.value && (!passwordEmail.value || !passwordChallenge.value || !/^\d{8}$/.test(ruleForm.code.trim()))) {
|
||
notify('请获取并输入安全邮箱中的 8 位验证码')
|
||
return
|
||
}
|
||
passwordSubmitting.value = true
|
||
try {
|
||
const payload = isSuperAdmin.value
|
||
? { password: ruleForm.new_pwd, challenge_id: passwordChallenge.value, code: ruleForm.code.trim() }
|
||
: { current_password: ruleForm.current_pwd, password: ruleForm.new_pwd }
|
||
const res = await http.post('/api/v1/settings/modify_password', payload)
|
||
if (res.errorNo !== 0) throw new Error(res.errorMsg || '密码修改失败')
|
||
Object.assign(ruleForm, { current_pwd: '', new_pwd: '', new_pwd2: '', code: '' })
|
||
passwordChallenge.value = ''
|
||
notify('密码修改成功,请重新登录;原安全凭据已失效', 'success')
|
||
globalStatus.clear()
|
||
location.assign('/#/login')
|
||
} catch (error) {
|
||
notify(error?.message || '密码修改失败,请重新获取验证码后重试')
|
||
} finally {
|
||
passwordSubmitting.value = false
|
||
}
|
||
}
|
||
|
||
const sendPasswordCode = async () => {
|
||
if (passwordSending.value || passwordSubmitting.value || passwordCountdown.value > 0 || !passwordEmail.value) return
|
||
passwordSending.value = true
|
||
passwordChallenge.value = ''
|
||
ruleForm.code = ''
|
||
try {
|
||
const res = await http.post('/api/v1/security/verification/request', {
|
||
purpose: 'password_change', account: globalStatus.userInfos.account,
|
||
})
|
||
if (res.errorNo !== 0 || !res.data?.id) throw new Error(res.errorMsg || '验证码发送失败')
|
||
passwordChallenge.value = res.data.id
|
||
passwordCountdown.value = 60
|
||
const deadline = Date.now() + 60000
|
||
clearInterval(passwordTimer)
|
||
passwordTimer = setInterval(() => {
|
||
passwordCountdown.value = Math.max(0, Math.ceil((deadline - Date.now()) / 1000))
|
||
if (!passwordCountdown.value) clearInterval(passwordTimer)
|
||
}, 1000)
|
||
notify(`验证码已发送至 ${res.data.email || passwordEmail.value.email}`, 'success')
|
||
} catch (error) {
|
||
notify(error?.message || '验证码发送失败')
|
||
} finally {
|
||
passwordSending.value = false
|
||
}
|
||
}
|
||
|
||
watch(() => passwordEmail.value?.id, () => {
|
||
passwordChallenge.value = ''
|
||
ruleForm.code = ''
|
||
})
|
||
onUnmounted(() => {
|
||
pageActive = false
|
||
clearRecoveryCodes()
|
||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||
clearInterval(passwordTimer)
|
||
})
|
||
|
||
watch(selectedSection, section => {
|
||
if (section !== 'recovery') {
|
||
clearRecoveryCodes()
|
||
}
|
||
})
|
||
|
||
onMounted(async () => {
|
||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||
await loadSecurityData()
|
||
})
|
||
|
||
watch([selectedSection, webAuthnEnabled], ([section]) => {
|
||
if (section === 'webauthn') loadCredentials()
|
||
}, { immediate: true })
|
||
|
||
return {
|
||
lang, isSuperAdmin, supported, webAuthnEnabled, sectionOptions, selectedSection, selectSection,
|
||
globalSecurityPolicyPage,
|
||
passwordEmail, passwordSending, passwordSubmitting, passwordCountdown, sendPasswordCode,
|
||
registering, credentialsLoading, credentials, securityLoading, recoveryLoading,
|
||
securityQuestion, questionForm, recoveryConfigured, generatedCodes, externalEmails, externalListLoading,
|
||
externalDialogVisible, ruleForm, rules, submit, registerCredential, renameCredential, revokeCredential,
|
||
saveSecurityQuestion, generateRecoveryCodes, revealRecoveryCodes, revokeRecoveryCodes, openExternalEmailDialog,
|
||
handleExternalEmailBound,
|
||
disableExternalEmail, formatTime,
|
||
}
|
||
}
|