feat(security): gate admin role promotion behind step-up 2FA and harden admin TOTP verification

- 提升用户为管理员 / 创建管理员账号纳入敏感操作:handler 级 EnforceStepUp 门控
  (admin API key 拒绝、未启用 TOTP 拒绝、无 grant 返回 STEP_UP_REQUIRED),
  目标已是管理员的日常编辑不触发
- 管理员启用/停用 2FA 一律使用密码验证(默认通知邮箱常收不到验证码),
  verification-method 按用户角色返回;普通用户行为不变
- 用户编辑/创建弹窗接入 useStepUp:命中 STEP_UP_REQUIRED 弹 TOTP 验证并自动重试
- 审计日志清理入口与其他敏感操作对齐:未启用 2FA 时直接提示先启用 TOTP,
  不再弹出无法完成的验证码输入框(后端强制现场 TOTP 语义不变)
- 审计日志页重构:DataTable 布局、详情弹窗分区展示、时间范围改为 ops 同款
  下拉(预设窗口 + 自定义起止支持时分)
This commit is contained in:
shaw
2026-07-16 16:49:08 +08:00
parent 393a8fe56a
commit 35748d8c51
15 changed files with 953 additions and 269 deletions
@@ -64,34 +64,57 @@
</div>
</template>
</BaseDialog>
<!-- 创建管理员账号时后端要求 step-up 2FA,弹出 TOTP 验证后自动重试 -->
<TotpStepUpDialog :controller="stepUp" />
</template>
<script setup lang="ts">
import { reactive, watch } from 'vue'
import { reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'; import { adminAPI } from '@/api/admin'
import { useForm } from '@/composables/useForm'
import { useAppStore } from '@/stores/app'
import BaseDialog from '@/components/common/BaseDialog.vue'
import Icon from '@/components/icons/Icon.vue'
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
const props = defineProps<{ show: boolean }>()
const emit = defineEmits(['close', 'success']); const { t } = useI18n()
const appStore = useAppStore()
const form = reactive({ email: '', password: '', username: '', notes: '', role: 'user' as 'user' | 'admin', balance: '', concurrency: 1, rpm_limit: 0 })
const { loading, submit } = useForm({
form,
submitFn: async (data) => {
const { balance: rawBalance, ...rest } = data
const stepUp = useStepUp()
const loading = ref(false)
const submit = async () => {
if (loading.value) return
loading.value = true
try {
const { balance: rawBalance, ...rest } = { ...form }
const balance = String(rawBalance).trim()
const payload: typeof rest & { balance?: number } = { ...rest }
if (balance !== '') {
payload.balance = Number(balance)
}
await adminAPI.users.create(payload)
// 创建管理员属敏感操作:后端返回 STEP_UP_REQUIRED 时弹 TOTP 验证并重试
await stepUp.run(() => adminAPI.users.create(payload))
appStore.showSuccess(t('admin.users.userCreated'))
emit('success'); emit('close')
},
successMsg: t('admin.users.userCreated')
})
} catch (e: any) {
if (isStepUpCancelled(e)) {
// 用户主动取消二次验证:静默返回,表单保持打开。
} else if (isStepUpBlocked(e)) {
appStore.showError(
stepUpBlockReason(e) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
? t('stepUp.adminApiKeyForbidden')
: t('stepUp.notEnabled')
)
} else {
appStore.showError(e?.message || t('admin.users.failedToCreate'))
}
} finally { loading.value = false }
}
watch(() => props.show, (v) => { if(v) Object.assign(form, { email: '', password: '', username: '', notes: '', role: 'user', balance: '', concurrency: 1, rpm_limit: 0 }) })
@@ -67,6 +67,9 @@
</div>
</template>
</BaseDialog>
<!-- 角色提升为管理员时后端要求 step-up 2FA,弹出 TOTP 验证后自动重试 -->
<TotpStepUpDialog :controller="stepUp" />
</template>
<script setup lang="ts">
@@ -79,6 +82,8 @@ import type { AdminUser, UserAttributeValuesMap } from '@/types'
import BaseDialog from '@/components/common/BaseDialog.vue'
import UserAttributeForm from '@/components/user/UserAttributeForm.vue'
import Icon from '@/components/icons/Icon.vue'
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
const props = defineProps<{ show: boolean, user: AdminUser | null }>()
const emit = defineEmits(['close', 'success'])
@@ -104,6 +109,8 @@ const copyPassword = async () => {
passwordCopied.value = true; setTimeout(() => passwordCopied.value = false, 2000)
}
}
const stepUp = useStepUp()
const handleUpdateUser = async () => {
if (!props.user) return
if (!form.email.trim()) {
@@ -114,16 +121,28 @@ const handleUpdateUser = async () => {
appStore.showError(t('admin.users.concurrencyMin'))
return
}
const userId = props.user.id
submitting.value = true
try {
const data: any = { email: form.email, username: form.username, notes: form.notes, role: form.role, concurrency: form.concurrency, rpm_limit: form.rpm_limit }
if (form.password.trim()) data.password = form.password.trim()
await adminAPI.users.update(props.user.id, data)
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(props.user.id, form.customAttributes)
// 提升为管理员属敏感操作:后端返回 STEP_UP_REQUIRED 时弹 TOTP 验证并重试
await stepUp.run(() => adminAPI.users.update(userId, data))
if (Object.keys(form.customAttributes).length > 0) await adminAPI.userAttributes.updateUserAttributeValues(userId, form.customAttributes)
appStore.showSuccess(t('admin.users.userUpdated'))
emit('success'); emit('close')
} catch (e: any) {
appStore.showError(e.response?.data?.detail || t('admin.users.failedToUpdate'))
if (isStepUpCancelled(e)) {
// 用户主动取消二次验证:静默返回,表单保持打开。
} else if (isStepUpBlocked(e)) {
appStore.showError(
stepUpBlockReason(e) === 'STEP_UP_ADMIN_API_KEY_FORBIDDEN'
? t('stepUp.adminApiKeyForbidden')
: t('stepUp.notEnabled')
)
} else {
appStore.showError(e?.message || t('admin.users.failedToUpdate'))
}
} finally { submitting.value = false }
}
</script>