feat(security): 敏感操作 step-up 2FA 开关化,安全开关默认关闭

新增系统设置 step_up_enabled(默认关闭),把敏感操作 2FA 门控做成可开关;
同时将会话 IP/UA 绑定默认值从开启改为关闭,避免用户因 IP 变动登录后掉线。

## 新增功能
- 敏感操作 step-up 2FA 总开关 step_up_enabled(默认关闭):关闭时账号/代理导出、
  备份创建/下载、S3 配置修改、提升管理员等操作恢复门控引入前的直接放行行为;
  开启后要求当前会话在 15 分钟内完成过 TOTP step-up 验证。

## 优化改进
- 会话 IP/UA 绑定默认改为关闭(功能保留,可在设置页按需开启)。
- 开启 step-up 开关需操作者本人已启用 TOTP(防自锁);关闭开关本身作为敏感操作,
  需通过 step-up 验证(防止攻击者拿到会话后先关闸再导出/备份)。
- 两个安全开关请求字段改为可空指针(省略=保持现值),避免旧客户端全量保存时
  静默重置安全开关。
- 备份恢复(整库覆盖可回滚安全设置)纳入 step-up 门控。
- 审计摘要 diffSettings 补记 step_up_enabled / session_binding_enabled 变更。

## Bug 修复
- 修复 BackupView 恢复操作 409(恢复进行中)判断未适配 apiClient 扁平化错误对象。
This commit is contained in:
shaw
2026-07-18 10:46:42 +08:00
parent 57914967cb
commit 539bfc8bad
28 changed files with 472 additions and 43 deletions
+2
View File
@@ -367,6 +367,7 @@ export interface SystemSettings {
totp_enabled: boolean; // TOTP 双因素认证
totp_encryption_key_configured: boolean; // TOTP 加密密钥是否已配置
session_binding_enabled: boolean; // 会话 IP/UA 绑定
step_up_enabled: boolean; // 敏感操作 step-up 2FA
audit_log_retention_days: number; // 审计日志保留天数
login_agreement_enabled: boolean;
login_agreement_mode: "modal" | "checkbox" | string;
@@ -672,6 +673,7 @@ export interface UpdateSettingsRequest {
invitation_code_enabled?: boolean;
totp_enabled?: boolean; // TOTP 双因素认证
session_binding_enabled?: boolean; // 会话 IP/UA 绑定
step_up_enabled?: boolean; // 敏感操作 step-up 2FA
audit_log_retention_days?: number; // 审计日志保留天数
login_agreement_enabled?: boolean;
login_agreement_mode?: "modal" | "checkbox" | string;
@@ -127,6 +127,9 @@ export default {
'Please configure TOTP_ENCRYPTION_KEY in environment variables first. Generate a key with: openssl rand -hex 32'
},
security: {
stepUp: 'Step-up 2FA for Sensitive Operations',
stepUpHint: 'When enabled, sensitive operations (account/proxy export, backup creation and download, S3 config changes, promoting admins) require a recent TOTP verification (valid for 15 minutes). Your own account must have 2FA enabled before turning this on; turning it off also requires step-up verification.',
stepUpEnableRequiresTotp: 'Enable 2FA (TOTP) for your own account in Profile before turning on step-up verification.',
sessionBinding: 'Session IP/UA Binding',
sessionBindingHint: 'Bind login sessions to the client IP and User-Agent. Any change immediately invalidates the session and forces re-login, raising the bar for stolen-credential reuse.',
auditRetention: 'Audit Log Retention (days)',
@@ -127,6 +127,9 @@ export default {
'请先在环境变量中配置 TOTP_ENCRYPTION_KEY。使用命令 openssl rand -hex 32 生成密钥。'
},
security: {
stepUp: '敏感操作二次验证 (step-up 2FA)',
stepUpHint: '开启后,账号/代理导出、备份创建与下载、S3 配置修改、提升管理员等敏感操作需要先完成 TOTP 二次验证(15 分钟内有效)。开启前需本人已启用 2FA;关闭该开关本身也需要二次验证。',
stepUpEnableRequiresTotp: '开启敏感操作二次验证前,请先在个人资料中为当前账号启用 2FA (TOTP)。',
sessionBinding: '会话 IP/UA 绑定',
sessionBindingHint: '将登录会话与客户端 IP 和 User-Agent 绑定,任一变化即强制该会话失效并需重新登录(提升被盗凭证的利用门槛)。',
auditRetention: '操作日志保留天数',
+6 -3
View File
@@ -586,16 +586,19 @@ async function restoreBackup(id: string) {
if (!password) return
restoringId.value = id
try {
const record = await adminAPI.backup.restoreBackup(id, password)
const record = await backupStepUp.run(() => adminAPI.backup.restoreBackup(id, password))
updateRecordInList(record)
startRestorePolling(id)
} catch (error: any) {
if (error?.response?.status === 409) {
restoringId.value = ''
if (isStepUpCancelled(error)) return
if (reportStepUpBlocked(error)) return
// apiClient 拦截器把 HTTP 错误归一化为顶层 { status } 平面对象(无 response 字段)
if (error?.status === 409 || error?.response?.status === 409) {
appStore.showWarning(t('admin.backup.operations.restoreRunning'))
} else {
appStore.showError(error?.message || t('errors.networkError'))
}
restoringId.value = ''
}
}
+51 -2
View File
@@ -1578,6 +1578,21 @@
/>
</div>
<!-- 敏感操作 step-up 2FA -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
>
<div>
<label class="font-medium text-gray-900 dark:text-white">{{
t("admin.settings.security.stepUp")
}}</label>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ t("admin.settings.security.stepUpHint") }}
</p>
</div>
<Toggle v-model="form.step_up_enabled" />
</div>
<!-- 会话 IP/UA 绑定 -->
<div
class="flex items-center justify-between border-t border-gray-100 pt-4 dark:border-dark-700"
@@ -7466,6 +7481,8 @@
@confirm="handleAffiliateConfirm"
@cancel="cancelAffiliateConfirm"
/>
<!-- 关闭 step-up 开关等敏感保存操作触发的 TOTP 二次验证 -->
<TotpStepUpDialog :controller="settingsStepUp" />
</div>
</AppLayout>
</template>
@@ -7519,6 +7536,13 @@ import BackupSettings from "@/views/admin/BackupView.vue";
import EmailTemplateEditor from "@/views/admin/settings/EmailTemplateEditor.vue";
import OpenAIFastPolicyUserSelector from "@/views/admin/settings/OpenAIFastPolicyUserSelector.vue";
import { useClipboard } from "@/composables/useClipboard";
import {
useStepUp,
isStepUpCancelled,
isStepUpBlocked,
stepUpBlockReason,
} from "@/composables/useStepUp";
import TotpStepUpDialog from "@/components/auth/TotpStepUpDialog.vue";
import { affiliatesAPI, type AffiliateAdminEntry, type SimpleUser as AffiliateSimpleUser } from "@/api/admin/affiliates";
import { extractApiErrorMessage, extractI18nErrorMessage } from "@/utils/apiError";
import { useAppStore } from "@/stores";
@@ -7539,6 +7563,8 @@ import {
const { t, locale } = useI18n();
const appStore = useAppStore();
// 关闭 step-up 开关是敏感操作:后端返回 STEP_UP_REQUIRED 时弹 TOTP 码重试
const settingsStepUp = useStepUp();
const adminSettingsStore = useAdminSettingsStore();
const isZhLocale = computed(() => locale.value.startsWith("zh"));
@@ -8192,7 +8218,8 @@ const form = reactive<SettingsForm>({
password_reset_enabled: false,
totp_enabled: false,
totp_encryption_key_configured: false,
session_binding_enabled: true,
session_binding_enabled: false,
step_up_enabled: false,
audit_log_retention_days: 180,
login_agreement_enabled: false,
login_agreement_mode: "modal",
@@ -9559,6 +9586,7 @@ async function saveSettings() {
password_reset_enabled: form.password_reset_enabled,
totp_enabled: form.totp_enabled,
session_binding_enabled: form.session_binding_enabled,
step_up_enabled: form.step_up_enabled,
// 清空数字框时 v-model.number 会得到空串,后端 int 字段解析空串会 400 拒绝整次保存;
// 空/非法值回退默认 180(与后端 parseAuditLogRetentionDays("") 语义一致,0 仍表示永久保留)。
audit_log_retention_days: Number.isFinite(form.audit_log_retention_days)
@@ -9856,7 +9884,9 @@ async function saveSettings() {
payload.default_platform_quotas = sanitizePlatformQuotasMap(form.default_platform_quotas);
appendAuthSourceDefaultsToUpdateRequest(payload, authSourceDefaults);
const updated = await adminAPI.settings.updateSettings(payload);
const updated = await settingsStepUp.run(() =>
adminAPI.settings.updateSettings(payload),
);
for (const [key, value] of Object.entries(updated)) {
if (key === "openai_fast_policy_settings") continue;
if (value !== null && value !== undefined) {
@@ -9930,6 +9960,25 @@ async function saveSettings() {
appStore.showSuccess(t("admin.settings.settingsSaved"));
}
} catch (error: unknown) {
// 用户取消 step-up 验证:静默返回,不弹错误
if (isStepUpCancelled(error)) {
return;
}
if (isStepUpBlocked(error)) {
appStore.showError(
stepUpBlockReason(error) === "STEP_UP_ADMIN_API_KEY_FORBIDDEN"
? t("stepUp.adminApiKeyForbidden")
: t("stepUp.notEnabled"),
);
return;
}
// 开启 step-up 开关但本人未启用 2FA:给出可操作的专用提示
if (
(error as { reason?: string })?.reason === "STEP_UP_ENABLE_REQUIRES_TOTP"
) {
appStore.showError(t("admin.settings.security.stepUpEnableRequiresTotp"));
return;
}
appStore.showError(
extractApiErrorMessage(error, t("admin.settings.failedToSave")),
);