feat(mfa-ui): 前端 MFA UI - 绑定+验证+高危弹窗+管理 (Phase 2.4 task #20)

This commit is contained in:
Claude
2026-06-21 01:16:36 +08:00
parent c1ac9b936c
commit f564d0e42a
7 changed files with 1484 additions and 0 deletions
+111
View File
@@ -0,0 +1,111 @@
// =============================================================================
// 企微IT智能服务台 — 管理后台 MFA 管理 API 适配层 (Phase 2.4)
// =============================================================================
// 说明:封装 /api/admin/mfa/* 管理员视角的端点
// 对应后端: backend/app/api/mfa.py (Phase 2.1, task #17) admin_router 部分
//
// 管理员端点:
// GET /api/admin/mfa/users — 列出所有用户 MFA 状态
// POST /api/admin/mfa/reset/{employee_id} — 重置指定用户 MFA(丢手机兜底)
//
// 用户视角的 5 个端点(/api/mfa/*)由 frontend-agent 端 mfa.ts 封装
// 管理后台如需代理用户操作(管理员自己绑定)也可引用 frontend-agent 的 API
//
// 鉴权:
// - 全部用 require_role("admin")(管理员)
// - 响应格式: {code: 0, data: {}, message: "success"} 业务码 0 表示成功
//
// 典型管理员场景:
// 1. 进入 /mfa-manage → 调 GET /api/admin/mfa/users → 表格展示
// 2. 搜索 + 过滤 + 分页(支持按 bound/姓名/employee_id)
// 3. 点"重置 MFA" → 调 POST /api/admin/mfa/reset/{employee_id}
// 4. 弹 ElMessageBox 二次确认(防误操作) → 调重置端点
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义
// --------------------------------------------------------------------------
/** 单个用户的 MFA 状态条目 */
export interface MfaUserStatus {
/** 员工 ID(企微 userid) */
employee_id: string
/** 员工姓名 */
name?: string
/** 角色列表 */
roles?: string[]
/** 是否已绑定 MFA */
bound: boolean
/** 是否已启用 MFA(与 bound 等价) */
enabled: boolean
/** 首次绑定时间(ISO 8601,可空) */
bound_at?: string | null
/** 最近一次验证成功时间(ISO 8601,可空) */
last_verified_at?: string | null
}
/** GET /api/admin/mfa/users 响应 */
export interface MfaUserListData {
/** 用户 MFA 状态列表 */
items: MfaUserStatus[]
/** 总数 */
total: number
/** 当前页码 */
page: number
/** 每页大小 */
page_size: number
}
/** GET /api/admin/mfa/users 查询参数 */
export interface MfaUserListParams {
/** 按姓名或 employee_id 模糊搜索 */
keyword?: string
/** 过滤绑定状态(空/true/false) */
bound?: '' | 'true' | 'false'
/** 页码(从 1 开始) */
page?: number
/** 每页大小 */
page_size?: number
}
/** POST /api/admin/mfa/reset/{employee_id} 响应 */
export interface MfaAdminResetData {
/** 重置是否成功 */
success: boolean
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 1) 列出所有用户的 MFA 状态(支持搜索 + 过滤 + 分页)
* 管理员在 /mfa-manage 页面调这个
*
* @param params 查询参数
* @returns 分页数据
*/
export async function listMfaUsers(
params: MfaUserListParams = {}
): Promise<MfaUserListData> {
const response: AxiosResponse = await apiClient.get('/admin/mfa/users', { params })
return response.data.data
}
/**
* 2) 重置指定员工的 MFA 绑定(管理员特权,无 OTP 验证)
* 使用场景:
* - 员工丢手机/换手机 → 管理员在后台"重置 MFA"按钮
*
* @param employeeId 员工 ID(企微 userid)
* @returns 重置结果
*/
export async function resetMfa(employeeId: string): Promise<MfaAdminResetData> {
const response: AxiosResponse = await apiClient.post(
`/admin/mfa/reset/${encodeURIComponent(employeeId)}`
)
return response.data.data
}
+7
View File
@@ -126,6 +126,13 @@ const routes = [
component: () => import('@/views/Placeholder.vue'), component: () => import('@/views/Placeholder.vue'),
meta: { title: '知识库管理', requiresAuth: true, comingSoon: true }, meta: { title: '知识库管理', requiresAuth: true, comingSoon: true },
}, },
{
// Phase 2.4 task #20 — MFA 管理(管理员重置员工 MFA 绑定)
path: 'mfa-manage',
name: 'MfaManage',
component: () => import('@/views/MfaManage.vue'),
meta: { title: 'MFA 管理', requiresAuth: true },
},
], ],
}, },
{ {
+403
View File
@@ -0,0 +1,403 @@
<!--
=============================================================================
企微IT智能服务台 MFA 管理页 (Phase 2.4, task #20)
=============================================================================
说明:管理员管理所有用户 MFA 绑定的页面
功能:
- 表格列出所有用户的 MFA 状态(已绑/未绑)
- 搜索(姓名/employee_id)+ 过滤(已绑/未绑/全部)+ 分页
- "重置 MFA" 按钮 POST /api/admin/mfa/reset/{employee_id}
( OTP 验证,管理员特权,用于员工丢手机兜底)
- 重置前 ElMessageBox 二次确认(防误操作)
设计要点:
- 不在表格里直接显示 secret(安全考虑)
- 状态列用 el-tag 颜色区分:已绑=success,未绑=info
- 重置按钮在已绑行才显示(未绑无需重置)
- "最近验证时间"列给管理员做审计参考
-->
<template>
<div class="mfa-manage-page">
<!-- 页面标题 -->
<div class="page-header">
<div>
<div class="page-title">MFA 管理</div>
<div class="page-desc">管理所有用户的动态令牌(MFA)绑定状态,丢手机兜底重置</div>
</div>
</div>
<!-- ============================================================ -->
<!-- 筛选栏 -->
<!-- ============================================================ -->
<div class="filter-bar">
<el-input
v-model="filters.keyword"
placeholder="搜索员工姓名 / Employee ID"
:prefix-icon="Search"
clearable
style="width: 260px"
@keyup.enter="handleSearch"
/>
<el-select v-model="filters.bound" placeholder="绑定状态" clearable style="width: 140px">
<el-option label="全部" value="" />
<el-option label="已绑定" value="true" />
<el-option label="未绑定" value="false" />
</el-select>
<el-button type="primary" :icon="Search" @click="handleSearch">搜索</el-button>
<el-button :icon="Refresh" @click="handleReset">重置</el-button>
<el-button :icon="Refresh" @click="loadUsers">刷新</el-button>
</div>
<!-- ============================================================ -->
<!-- 统计卡片 -->
<!-- ============================================================ -->
<div class="stat-cards">
<div class="stat-card">
<div class="stat-label">总用户数</div>
<div class="stat-value">{{ pagination.total }}</div>
</div>
<div class="stat-card stat-card--success">
<div class="stat-label">已绑定</div>
<div class="stat-value">{{ boundCount }}</div>
</div>
<div class="stat-card stat-card--info">
<div class="stat-label">未绑定</div>
<div class="stat-value">{{ unboundCount }}</div>
</div>
</div>
<!-- ============================================================ -->
<!-- 用户 MFA 状态表格 -->
<!-- ============================================================ -->
<div class="data-table-wrapper">
<el-table
:data="users"
v-loading="loading"
stripe
size="small"
empty-text="暂无用户记录"
>
<el-table-column prop="employee_id" label="Employee ID" min-width="140" />
<el-table-column prop="name" label="姓名" min-width="100">
<template #default="{ row }">
<span v-if="row.name">{{ row.name }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column prop="roles" label="角色" min-width="120">
<template #default="{ row }">
<el-tag
v-for="role in (row.roles || [])"
:key="role"
size="small"
:type="getRoleTagType(role)"
class="role-tag"
>
{{ role }}
</el-tag>
<span v-if="!row.roles || row.roles.length === 0" class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="MFA 状态" min-width="100">
<template #default="{ row }">
<el-tag :type="row.bound ? 'success' : 'info'" size="small">
{{ row.bound ? '已绑定' : '未绑定' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="bound_at" label="首次绑定时间" min-width="160">
<template #default="{ row }">
<span v-if="row.bound_at">{{ formatTime(row.bound_at) }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column prop="last_verified_at" label="最近验证时间" min-width="160">
<template #default="{ row }">
<span v-if="row.last_verified_at">{{ formatTime(row.last_verified_at) }}</span>
<span v-else class="text-muted"></span>
</template>
</el-table-column>
<el-table-column label="操作" width="140" fixed="right">
<template #default="{ row }">
<el-button
v-if="row.bound"
type="danger"
size="small"
link
:disabled="resettingId === row.employee_id"
@click="handleResetMfa(row)"
>
{{ resettingId === row.employee_id ? '重置中...' : '重置 MFA' }}
</el-button>
<span v-else class="text-muted">无需操作</span>
</template>
</el-table-column>
</el-table>
</div>
<!-- ============================================================ -->
<!-- 分页 -->
<!-- ============================================================ -->
<div class="pagination-bar">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.pageSize"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@current-change="loadUsers"
@size-change="handleSizeChange"
/>
</div>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 依赖导入
// ============================================================================
import { ref, reactive, computed, onMounted } from 'vue'
import { Search, Refresh } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { listMfaUsers, resetMfa } from '@/api/mfa'
import type { MfaUserStatus } from '@/api/mfa'
// ============================================================================
// 状态
// ============================================================================
const loading = ref<boolean>(false)
const users = ref<MfaUserStatus[]>([])
const resettingId = ref<string>('') // 正在重置的 employee_id(给按钮 loading 用)
const filters = reactive({
keyword: '',
bound: '' as '' | 'true' | 'false',
})
const pagination = reactive({
page: 1,
pageSize: 20,
total: 0,
})
// ============================================================================
// 计算:已绑/未绑数量(基于当前页数据)
// ============================================================================
const boundCount = computed<number>(() =>
users.value.filter((u) => u.bound).length
)
const unboundCount = computed<number>(() =>
users.value.filter((u) => !u.bound).length
)
// ============================================================================
// 数据加载
// ============================================================================
async function loadUsers(): Promise<void> {
loading.value = true
try {
const params: {
keyword?: string
bound?: '' | 'true' | 'false'
page: number
page_size: number
} = {
page: pagination.page,
page_size: pagination.pageSize,
}
if (filters.keyword.trim()) params.keyword = filters.keyword.trim()
if (filters.bound) params.bound = filters.bound
const data = await listMfaUsers(params)
users.value = data.items || []
pagination.total = data.total || 0
} catch (err: any) {
// 静默失败,使用空数据
users.value = []
pagination.total = 0
const msg = err?.response?.data?.message || err?.message || '加载用户 MFA 列表失败'
ElMessage.error(msg)
} finally {
loading.value = false
}
}
// ============================================================================
// 事件处理
// ============================================================================
function handleSearch(): void {
pagination.page = 1
loadUsers()
}
function handleReset(): void {
filters.keyword = ''
filters.bound = ''
pagination.page = 1
loadUsers()
}
function handleSizeChange(): void {
pagination.page = 1
loadUsers()
}
/**
* 重置指定用户的 MFA
* 二次确认 → 调 API → 刷新列表
*/
async function handleResetMfa(row: MfaUserStatus): Promise<void> {
const name = row.name || row.employee_id
try {
await ElMessageBox.confirm(
`确认重置 ${name} 的 MFA 绑定?\n\n重置后该用户需要重新绑定才能使用 MFA 功能(用于员工丢手机兜底)。\n此操作不可恢复,请谨慎操作。`,
'重置 MFA 确认',
{
type: 'warning',
confirmButtonText: '确认重置',
cancelButtonText: '取消',
confirmButtonClass: 'el-button--danger',
}
)
} catch {
// 用户取消
return
}
resettingId.value = row.employee_id
try {
await resetMfa(row.employee_id)
ElMessage.success(`已重置 ${name} 的 MFA 绑定`)
// 刷新当前页
await loadUsers()
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || '重置失败'
ElMessage.error(msg)
} finally {
resettingId.value = ''
}
}
// ============================================================================
// 工具函数
// ============================================================================
function formatTime(iso: string | null | undefined): string {
if (!iso) return '—'
const d = new Date(iso)
if (isNaN(d.getTime())) return iso
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
}
function getRoleTagType(role: string): 'success' | 'warning' | 'danger' | 'info' {
const map: Record<string, 'success' | 'warning' | 'danger' | 'info'> = {
user: 'info',
agent: 'success',
admin: 'danger',
}
return map[role] || 'info'
}
// ============================================================================
// 生命周期
// ============================================================================
onMounted(() => {
loadUsers()
})
</script>
<style scoped>
.mfa-manage-page {
/* 页面容器 */
}
.page-header {
margin-bottom: 20px;
}
.page-title {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 4px;
}
.page-desc {
font-size: 13px;
color: var(--text-muted);
}
/* 筛选栏 */
.filter-bar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
/* 统计卡片 */
.stat-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 16px 20px;
transition: border-color 0.2s;
}
.stat-card:hover {
border-color: var(--border-hover);
}
.stat-card--success {
border-left: 3px solid var(--success);
}
.stat-card--info {
border-left: 3px solid var(--text-muted);
}
.stat-label {
font-size: 12px;
color: var(--text-muted);
margin-bottom: 6px;
}
.stat-value {
font-size: 22px;
font-weight: 600;
color: var(--text-primary);
line-height: 1.2;
}
/* 数据表格 */
.data-table-wrapper {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
overflow: hidden;
margin-bottom: 16px;
}
.role-tag {
margin-right: 4px;
}
.text-muted {
color: var(--text-muted);
font-size: 12px;
}
/* 分页 */
.pagination-bar {
display: flex;
justify-content: flex-end;
}
</style>
+162
View File
@@ -0,0 +1,162 @@
// =============================================================================
// 企微IT智能服务台 — MFA 二次认证 API 适配层 (Phase 2.4)
// =============================================================================
// 说明:封装 /api/mfa/* 5 个端点(用户视角的 MFA TOTP 操作)
// 对应后端: backend/app/api/mfa.py (Phase 2.1, task #17)
//
// 5 个端点(用户视角):
// GET /api/mfa/status — 查询绑定状态(路由守卫用)
// POST /api/mfa/bind/start — 生成 secret + 二维码(尚未启用)
// POST /api/mfa/bind/confirm — 输入 OTP 完成绑定(启用)
// POST /api/mfa/verify — 输入 OTP 通过验证(写 Redis 30 分钟)
// POST /api/mfa/disable — 用户主动关闭 MFA(需 OTP 二次确认)
//
// 管理员视角的重置端点(/api/admin/mfa/reset/{employee_id})由 admin 端 mfa.ts 单独封装
//
// 鉴权:
// - 全部用 get_current_user(任意已登录用户)
// - 响应格式: {code: 0, data: {}, message: "success"} 业务码 0 表示成功
//
// 典型用户流程:
// 1. 路由守卫调 GET /status,bound=false → 跳转 /mfa-bind
// 2. 绑定页:POST /bind/start → 展示二维码 + secret
// 3. 用户用 Authenticator 扫码 → 输入 6 位码 → POST /bind/confirm → 成功
// 4. 后续敏感操作前:POST /verify → Redis 30 分钟内免重复输
// 5. 主动关闭:POST /disable(需当前 OTP 码,防误操作)
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// TypeScript 类型定义 — 与后端 schema/mfa.py 保持一致
// --------------------------------------------------------------------------
/** GET /api/mfa/status 响应 */
export interface MfaStatusData {
/** 是否已绑定(已生成 secret 且首次验证通过) */
bound: boolean
/** 是否已启用(与 bound 等价,保留双字段便于前端路由守卫判断) */
enabled: boolean
/** 最近一次验证成功时间(ISO 8601,可空) */
last_verified_at?: string | null
}
/** POST /api/mfa/bind/start 响应 */
export interface MfaBindStartData {
/** TOTP 共享密钥(base32) — 用户可手动输入到 Authenticator */
secret: string
/** otpauth:// URI — 可生成二维码 */
otpauth_url: string
/** 二维码 PNG base64(不含 data: 前缀,前端自行拼接 data:image/png;base64,) */
qr_code_base64: string
}
/** POST /api/mfa/bind/start 请求体(本端点无 body,保留接口以备扩展) */
export interface MfaBindStartRequest {
// 当前为空(预留)
}
/** POST /api/mfa/bind/confirm 请求体 */
export interface MfaBindConfirmRequest {
/** 6 位 OTP 动态码 */
otp_code: string
}
/** POST /api/mfa/bind/confirm 响应 */
export interface MfaBindConfirmData {
/** 绑定是否成功 */
success: boolean
}
/** POST /api/mfa/verify 请求体 */
export interface MfaVerifyRequest {
/** 6 位 OTP 动态码 */
otp_code: string
}
/** POST /api/mfa/verify 响应 */
export interface MfaVerifyData {
/** 验证是否通过 */
verified: boolean
/** Redis 验证标记剩余秒数(1800s 滑动窗口);0 表示未通过或未启用 MFA */
expires_in: number
}
/** POST /api/mfa/disable 请求体 */
export interface MfaDisableRequest {
/** 6 位 OTP 动态码(必须先验证当前 OTP,防止误操作/账号被劫持) */
otp_code: string
}
/** POST /api/mfa/disable 响应 */
export interface MfaDisableData {
/** 关闭是否成功 */
success: boolean
}
// --------------------------------------------------------------------------
// API 函数
// --------------------------------------------------------------------------
/**
* 1) 查询当前用户的 MFA 绑定状态
* 给路由守卫 + 启动兜底用(类似 TwoFactorAuth.vue 的 getAuth2faStatus 模式)
*
* @returns MFA 状态
*/
export async function getMfaStatus(): Promise<MfaStatusData> {
const response: AxiosResponse = await apiClient.get('/mfa/status')
return response.data.data
}
/**
* 2) 启动 MFA 绑定 — 生成 secret + 二维码
* 用户点"绑定"时调,拿到二维码和 secret 后展示给用户
*
* 注意:后端会复用已存在的 secret(支持"刷新二维码"场景)
*
* @returns 二维码信息(secret + otpauth_url + base64 PNG)
*/
export async function bindStart(): Promise<MfaBindStartData> {
const response: AxiosResponse = await apiClient.post('/mfa/bind/start')
return response.data.data
}
/**
* 3) 确认绑定 — 输入 6 位 OTP 完成绑定
* 用户用 Authenticator 扫码后,输入 6 位码提交
*
* @param otpCode 6 位 OTP 动态码
* @returns 绑定结果
*/
export async function bindConfirm(otpCode: string): Promise<MfaBindConfirmData> {
const body: MfaBindConfirmRequest = { otp_code: otpCode }
const response: AxiosResponse = await apiClient.post('/mfa/bind/confirm', body)
return response.data.data
}
/**
* 4) 校验 6 位 OTP 码 — 高危操作前的二次确认
* 验证通过后会在 Redis 写 30 分钟复用标记,期内可免重复输
*
* @param otpCode 6 位 OTP 动态码
* @returns 验证结果(verified + expires_in)
*/
export async function verifyMfa(otpCode: string): Promise<MfaVerifyData> {
const body: MfaVerifyRequest = { otp_code: otpCode }
const response: AxiosResponse = await apiClient.post('/mfa/verify', body)
return response.data.data
}
/**
* 5) 主动关闭 MFA — 需先验证当前 OTP 码(防误操作/账号被劫持)
*
* @param otpCode 6 位 OTP 动态码
* @returns 关闭结果
*/
export async function disableMfa(otpCode: string): Promise<MfaDisableData> {
const body: MfaDisableRequest = { otp_code: otpCode }
const response: AxiosResponse = await apiClient.post('/mfa/disable', body)
return response.data.data
}
@@ -0,0 +1,266 @@
// =============================================================================
// 企微IT智能服务台 — 高危操作 OTP 二次确认 Composable (Phase 2.4)
// =============================================================================
// 说明:封装"高危操作前的 OTP 二次确认"流程
// 用法:
// const { requireOtpDialog } = useHighRiskOtp()
// async function onDangerClick() {
// try {
// await requireOtpDialog({ action: '删除会话' })
// // 用户通过了 OTP 验证,执行真正的危险操作
// await doDangerousThing()
// } catch (e) {
// // 用户取消或验证失败,不执行
// }
// }
//
// 适用场景(由 Phase 2.3 决策,见 otm-secondary-auth.md):
// - 改权限(改角色 / 改数据范围 / 改菜单)
// - 改配置(system_configs 关键配置)
// - 导出数据(批量导出用户/会话/审计)
// - 封号(封禁员工/坐席)
// - 新增账号 / 重置密码
//
// 流程:
// 1. 调用方触发高危操作前调 requireOtpDialog({action})
// 2. composable 弹 el-dialog,要求输入 6 位 OTP
// 3. 调 /api/mfa/verify
// 4. verified=true → 关闭弹窗,resolve(otp_code)
// verified=false → 弹窗内显示错误,允许重新输入
// 5. 用户点取消 / 关闭弹窗 → reject
//
// 错误处理:
// - 后端返回 verified=false → 弹窗内显示 "OTP 错误,请重新输入" + 清空输入框
// - 后端抛 5xx / 网络错误 → 弹窗内显示 "验证失败,请稍后重试"
// - 5 次错误(由后端限制) → 后端会返回 4xx,前端显示提示并禁用提交
// =============================================================================
import { ref, type Ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { verifyMfa } from '@/api/mfa'
import type { MfaVerifyData } from '@/api/mfa'
/** requireOtpDialog 配置项 */
export interface RequireOtpDialogOptions {
/** 高危操作描述(显示在弹窗标题里,如 "删除会话" / "修改角色") */
action: string
/** 弹窗宽度(默认 420px) */
width?: string
/** 成功 toast 文案(默认 "验证通过") */
successMessage?: string
/** 失败 toast 文案(默认 "操作已取消") */
cancelMessage?: string
}
/** useHighRiskOtp 返回值 */
export interface UseHighRiskOtpReturn {
/** 弹窗可见性(给 template 绑 v-model) */
dialogVisible: Ref<boolean>
/** 弹窗标题(给 template 显示用) */
dialogTitle: Ref<string>
/** 当前正在校验的操作描述 */
pendingAction: Ref<string>
/** OTP 输入框值 */
otpCode: Ref<string>
/** 校验 loading */
verifying: Ref<boolean>
/** 弹窗内错误信息 */
dialogError: Ref<string>
/**
* 触发 OTP 弹窗
* - 用户输入正确的 OTP → resolve(otp_code)
* - 用户取消 / 关闭弹窗 / 验证失败 5 次 → reject(error)
*
* @param options 配置项
* @returns Promise<string> resolve 时返回用户输入的 OTP 码(供业务调用方日志审计)
*/
requireOtpDialog: (options: RequireOtpDialogOptions) => Promise<string>
/** 内部使用:关闭弹窗(用户点取消或验证成功后) */
closeDialog: () => void
/**
* 内部使用:提交 OTP 验证(由弹窗内"确认"按钮调用)
* 一般不直接调,requireOtpDialog 内部会自动触发
*/
submitDialog: () => Promise<void>
}
/**
* 弹一个 OTP 输入对话框,要求用户输入 6 位码做二次确认
*
* @example
* const { requireOtpDialog } = useHighRiskOtp()
* await requireOtpDialog({ action: '删除会话' })
*/
export function useHighRiskOtp(): UseHighRiskOtpReturn {
// --------------------------------------------------------------------------
// 响应式状态(给 template 绑 v-model 用)
// --------------------------------------------------------------------------
const dialogVisible = ref<boolean>(false)
const dialogTitle = ref<string>('高危操作二次验证')
const pendingAction = ref<string>('')
const otpCode = ref<string>('')
const verifying = ref<boolean>(false)
const dialogError = ref<string>('')
// --------------------------------------------------------------------------
// 内部状态(不暴露给外部,用于 resolve/reject 跨 promise 传递)
// --------------------------------------------------------------------------
let resolver: ((value: string) => void) | null = null
let rejecter: ((reason: Error) => void) | null = null
let resolveTimer: ReturnType<typeof setTimeout> | null = null
/**
* 弹窗被关闭/取消时统一清理状态 + reject
* - resolver 存在 → 用户取消
* - verifying 中 → 也允许取消(给个"已取消"标记)
*/
function closeDialog(): void {
if (resolver) {
const r = resolver
const rj = rejecter
resolver = null
rejecter = null
rj?.(new Error('用户取消 OTP 验证'))
// 上面 r 是为了过 TS 用的(实际通过 rj 拒绝)
void r
}
dialogVisible.value = false
otpCode.value = ''
dialogError.value = ''
verifying.value = false
if (resolveTimer) {
clearTimeout(resolveTimer)
resolveTimer = null
}
}
/**
* 提交 OTP 验证(弹窗内的"确认"按钮触发)
*/
async function submitDialog(): Promise<void> {
if (!resolver) return
if (otpCode.value.length !== 6) {
dialogError.value = '请输入 6 位 OTP 动态码'
return
}
if (verifying.value) return
verifying.value = true
dialogError.value = ''
try {
const data: MfaVerifyData = await verifyMfa(otpCode.value)
if (data.verified) {
// 验证通过
const r = resolver
const code = otpCode.value
resolver = null
rejecter = null
dialogVisible.value = false
otpCode.value = ''
r?.(code)
} else {
// verified=false(不抛异常,前端可以重试)
dialogError.value = 'OTP 验证码错误,请重新输入'
otpCode.value = ''
}
} catch (err: any) {
// 网络错误 / 5xx 等
const msg =
err?.response?.data?.message ||
err?.message ||
'验证失败,请稍后重试'
dialogError.value = msg
} finally {
verifying.value = false
}
}
/**
* 触发 OTP 弹窗(主入口,业务代码调这个)
*
* @param options 配置项
* @returns Promise<string> resolve 时返回用户输入的 OTP 码
*/
function requireOtpDialog(options: RequireOtpDialogOptions): Promise<string> {
// 防止重复触发:如果已经有弹窗开着,reject 旧的再开新的
if (dialogVisible.value) {
if (rejecter) {
const rj = rejecter
rejecter = null
resolver = null
rj(new Error('新的 OTP 验证请求已发起'))
}
}
return new Promise<string>((resolve, reject) => {
resolver = resolve
rejecter = reject
dialogTitle.value = `高危操作二次验证 — ${options.action || '敏感操作'}`
pendingAction.value = options.action || '敏感操作'
otpCode.value = ''
dialogError.value = ''
verifying.value = false
dialogVisible.value = true
// 兜底:30 分钟超时(Redis 标记有效期),到时自动 reject
// 注意:这不是强制安全机制,只是避免 resolver 永久悬挂
resolveTimer = setTimeout(() => {
if (resolver) {
const rj = rejecter
resolver = null
rejecter = null
dialogVisible.value = false
rj?.(new Error('OTP 验证超时'))
ElMessage.warning('OTP 验证已超时,请重新发起操作')
}
}, 30 * 60 * 1000)
})
}
return {
dialogVisible,
dialogTitle,
pendingAction,
otpCode,
verifying,
dialogError,
requireOtpDialog,
closeDialog,
submitDialog,
}
}
// =============================================================================
// 工具:用 ElMessageBox 实现的"轻量"高危确认(无需 OTP 弹窗的场景)
// =============================================================================
// 适用:不是"高危"但需要确认的操作(如"确认关闭页签")
// 高危操作请用 requireOtpDialog(更安全)
// =============================================================================
/**
* 弹一个标准的 ElMessageBox.confirm 二次确认
*
* @param message 提示内容
* @param title 标题(默认"操作确认")
* @returns Promise<boolean> true=确认,false=取消
*/
export async function confirmDangerAction(
message: string,
title: string = '操作确认'
): Promise<boolean> {
try {
await ElMessageBox.confirm(message, title, {
type: 'warning',
confirmButtonText: '确认',
cancelButtonText: '取消',
})
return true
} catch {
// 用户点了取消
return false
}
}
/** 导出 API 包装,方便业务方直接调(可选) */
export { verifyMfa } from '@/api/mfa'
+9
View File
@@ -41,6 +41,15 @@ const routes = [
component: () => import('@/views/AgentPreviewView.vue'), component: () => import('@/views/AgentPreviewView.vue'),
meta: { title: '坐席助手', requiresAuth: false }, meta: { title: '坐席助手', requiresAuth: false },
}, },
// Phase 2.4 task #20 — MFA 首次绑定页(已登录用户访问,需 token 但不强制 mfa_bound)
{
path: '/mfa-bind',
name: 'MfaBind',
component: () => import('@/views/MfaBind.vue'),
// 不强制 requiresAuth:false,因为我们已经登录了(从 query token 拿到)
// 这里依靠守卫检查 token,但不强制 MFA 已绑定(否则永远进不去)
meta: { title: '绑定 MFA', requiresAuth: true },
},
] ]
// -------------------------------------------------------------------------- // --------------------------------------------------------------------------
+526
View File
@@ -0,0 +1,526 @@
<!--
=============================================================================
企微IT智能服务台 MFA 首次绑定页 (Phase 2.4, task #20)
=============================================================================
说明:用户首次使用 MFA TOTP 二次认证时的绑定流程
流程:
1) 进入页面 /api/mfa/status
- 已绑定 跳回上一页(redirect) /workspace
2) 显示"开始绑定"按钮(避免一进入就调 bind/start)
3) 用户点按钮 POST /api/mfa/bind/start 拿到二维码 + secret
4) 用户用 Authenticator / 微软 Authenticator 扫码
5) 用户输入 6 OTP POST /api/mfa/bind/confirm
6) 成功 ElMessage + 跳回上一页(redirect query) /workspace
7) 失败 显示错误(验证码错误),允许重输
设计要点:
- 不自动调 bind/start(避免一进入就生成 secret,减少无效请求)
- 二维码渲染:后端返回 base64 PNG,前端用 data:image/png;base64,{...} 拼装
- secret 兜底展示:用户扫码失败时可手动输入到 Authenticator
- 6 位码 maxlength=6,自动转数字
- 不用 backup codes(决策: backup codes,丢手机找管理员后台重置)
-->
<template>
<div class="mfa-bind-page">
<el-card class="mfa-card" shadow="hover">
<template #header>
<div class="card-header">
<el-icon class="header-icon"><Key /></el-icon>
<span class="header-title">绑定动态令牌(MFA)</span>
</div>
</template>
<!-- 加载中( status) -->
<div v-if="loading" class="state-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<span>正在检查绑定状态...</span>
</div>
<!-- 状态 1:已绑定(理论上守卫已放过,这里是兜底) -->
<div v-else-if="alreadyBound" class="state-success">
<el-result icon="success" title="已绑定 MFA" sub-title="正在跳转到工作台...">
</el-result>
</div>
<!-- 状态 2:未绑定,显示绑定流程 -->
<template v-else>
<el-steps :active="currentStep" finish-status="success" align-center class="steps">
<el-step title="开始绑定" />
<el-step title="扫码" />
<el-step title="验证" />
<el-step title="完成" />
</el-steps>
<!-- =========================================================== -->
<!-- Step 1:开始绑定(展示介绍 + "开始绑定"按钮) -->
<!-- =========================================================== -->
<div v-if="currentStep === 0" class="step-intro">
<el-alert
type="info"
:closable="false"
class="intro-alert"
>
<template #title>
<span>什么是 MFA 动态令牌?</span>
</template>
<div class="intro-content">
MFA(多因素认证)通过动态令牌为您的账号增加一层保护
绑定后,每次登录或执行敏感操作时,需要输入手机端 Authenticator 生成的 6 位动态码
</div>
</el-alert>
<div class="intro-steps">
<div class="intro-step">
<div class="intro-num">1</div>
<div class="intro-text">下载 Authenticator(Google / 微软 / Authy 均可)</div>
</div>
<div class="intro-step">
<div class="intro-num">2</div>
<div class="intro-text">点击下方"开始绑定",扫描二维码</div>
</div>
<div class="intro-step">
<div class="intro-num">3</div>
<div class="intro-text">输入 Authenticator 显示的 6 位动态码完成绑定</div>
</div>
</div>
<el-button
type="primary"
size="large"
:loading="starting"
:disabled="starting"
class="start-btn"
@click="startBind"
>
开始绑定
</el-button>
</div>
<!-- =========================================================== -->
<!-- Step 2:扫码(展示二维码 + secret) -->
<!-- =========================================================== -->
<div v-else-if="currentStep === 1" class="step-qrcode">
<p class="step-tip">
<el-icon><Iphone /></el-icon>
<span>请用 Authenticator 扫描下方二维码</span>
</p>
<!-- 二维码图片(base64 PNG) -->
<div class="qrcode-container">
<img
v-if="qrcodeBase64"
:src="`data:image/png;base64,${qrcodeBase64}`"
alt="MFA 二维码"
class="qrcode-image"
/>
<div v-else class="qrcode-placeholder">
<el-icon class="is-loading"><Loading /></el-icon>
<span>正在生成二维码...</span>
</div>
</div>
<!-- 手动输入 secret(扫码失败时用) -->
<el-collapse v-model="manualOpen" class="manual-collapse">
<el-collapse-item title="无法扫码?手动输入密钥" name="manual">
<div class="manual-secret">
<p class="manual-hint">将以下密钥手动添加到 Authenticator:</p>
<el-input
:model-value="secret"
readonly
class="secret-input"
>
<template #append>
<el-button @click="copySecret">复制</el-button>
</template>
</el-input>
<p class="manual-tip">
添加时类型选择"基于时间",其他保持默认
</p>
</div>
</el-collapse-item>
</el-collapse>
<div class="qrcode-actions">
<el-button @click="refreshQrcode">刷新二维码</el-button>
<el-button type="primary" @click="currentStep = 2">下一步</el-button>
</div>
</div>
<!-- =========================================================== -->
<!-- Step 3:输入 6 位码验证 -->
<!-- =========================================================== -->
<div v-else-if="currentStep === 2" class="step-verify">
<p class="step-tip">
<el-icon><InfoFilled /></el-icon>
<span>请输入 Authenticator 显示的 6 位动态码</span>
</p>
<el-input
v-model="otpCode"
maxlength="6"
placeholder="请输入 6 位动态码"
size="large"
class="code-input"
:disabled="confirming"
>
<template #prefix><el-icon><Key /></el-icon></template>
</el-input>
<!-- 错误提示 -->
<el-alert
v-if="lastError"
type="error"
:title="lastError"
:closable="false"
class="error-alert"
/>
<div class="verify-actions">
<el-button @click="currentStep = 1">上一步</el-button>
<el-button
type="primary"
size="large"
:loading="confirming"
:disabled="otpCode.length !== 6 || confirming"
@click="submitCode"
>
确认绑定
</el-button>
</div>
</div>
</template>
</el-card>
</div>
</template>
<script setup lang="ts">
// ============================================================================
// 依赖导入
// ============================================================================
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Key, Loading, InfoFilled, Iphone } from '@element-plus/icons-vue'
import {
getMfaStatus,
bindStart,
bindConfirm,
type MfaStatusData,
type MfaBindStartData,
} from '@/api/mfa'
// ============================================================================
// 状态
// ============================================================================
const route = useRoute()
const router = useRouter()
const loading = ref<boolean>(true)
const alreadyBound = ref<boolean>(false)
const currentStep = ref<number>(0)
const starting = ref<boolean>(false)
const confirming = ref<boolean>(false)
const qrcodeBase64 = ref<string>('')
const secret = ref<string>('')
const otpCode = ref<string>('')
const lastError = ref<string>('')
const manualOpen = ref<string[]>([])
// ============================================================================
// 初始化:查 MFA 状态
// ============================================================================
onMounted(async () => {
try {
const status: MfaStatusData = await getMfaStatus()
if (status.bound) {
// 已绑定,跳回 redirect 或 /workspace
alreadyBound.value = true
setTimeout(() => goBack(), 800)
return
}
// 未绑定 → 留在 step 0
} catch (e: any) {
handleError(e, '检查绑定状态失败')
} finally {
loading.value = false
}
})
// ============================================================================
// Step 1 → Step 2:开始绑定(调 bind/start)
// ============================================================================
async function startBind(): Promise<void> {
if (starting.value) return
starting.value = true
lastError.value = ''
try {
const data: MfaBindStartData = await bindStart()
qrcodeBase64.value = data.qr_code_base64
secret.value = data.secret
currentStep.value = 1
} catch (e: any) {
handleError(e, '生成二维码失败')
} finally {
starting.value = false
}
}
// ============================================================================
// Step 2:刷新二维码(再次调 bind/start,后端会复用已存在的 secret)
// ============================================================================
async function refreshQrcode(): Promise<void> {
await startBind()
}
// ============================================================================
// 复制 secret 到剪贴板
// ============================================================================
async function copySecret(): Promise<void> {
if (!secret.value) return
try {
await navigator.clipboard.writeText(secret.value)
ElMessage.success('密钥已复制到剪贴板')
} catch {
ElMessage.error('复制失败,请手动选中复制')
}
}
// ============================================================================
// Step 3:提交 6 位码验证
// ============================================================================
async function submitCode(): Promise<void> {
if (otpCode.value.length !== 6 || confirming.value) return
confirming.value = true
lastError.value = ''
try {
const result = await bindConfirm(otpCode.value)
if (result.success) {
ElMessage.success('MFA 绑定成功!')
currentStep.value = 3
// 短暂停留后跳回
setTimeout(() => goBack(), 800)
return
}
// 理论上后端失败会抛异常,这里兜底
lastError.value = '绑定失败,请重试'
} catch (e: any) {
// 后端 INVALID_PARAMETER(OTP 错误)走这里
const msg = e?.response?.data?.message || e?.message || '验证失败'
if (msg.includes('OTP') || msg.includes('验证码')) {
lastError.value = 'OTP 验证码错误,请重新输入'
otpCode.value = ''
} else {
lastError.value = msg
}
} finally {
confirming.value = false
}
}
// ============================================================================
// 工具:统一错误处理
// ============================================================================
function handleError(e: any, fallbackMsg: string): void {
const msg = e?.response?.data?.message || e?.message || fallbackMsg
ElMessage.error(msg)
lastError.value = msg
}
// ============================================================================
// 工具:跳回上一页(支持 ?redirect=xxx)
// ============================================================================
function goBack(): void {
const redirect = (route.query.redirect as string) || '/workspace'
router.replace(redirect)
}
</script>
<style scoped>
.mfa-bind-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
}
.mfa-card {
max-width: 520px;
width: 100%;
}
.card-header {
display: flex;
align-items: center;
gap: 8px;
}
.header-icon {
color: #409eff;
font-size: 20px;
}
.header-title {
font-size: 18px;
font-weight: 600;
}
.state-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40px 0;
gap: 8px;
color: #606266;
}
.steps {
margin-bottom: 32px;
}
.step-tip {
display: flex;
align-items: center;
gap: 6px;
color: #606266;
font-size: 14px;
margin-bottom: 16px;
justify-content: center;
}
/* Step 1:开始绑定 */
.intro-alert {
margin-bottom: 24px;
}
.intro-content {
font-size: 13px;
line-height: 1.6;
color: #606266;
margin-top: 4px;
}
.intro-steps {
display: flex;
flex-direction: column;
gap: 12px;
margin-bottom: 24px;
}
.intro-step {
display: flex;
align-items: center;
gap: 12px;
}
.intro-num {
width: 28px;
height: 28px;
border-radius: 50%;
background: #409eff;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.intro-text {
font-size: 14px;
color: #303133;
line-height: 1.5;
}
.start-btn {
width: 100%;
}
/* Step 2:扫码 */
.qrcode-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 220px;
margin-bottom: 16px;
}
.qrcode-image {
width: 200px;
height: 200px;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.qrcode-placeholder {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
color: #909399;
}
.manual-collapse {
margin-bottom: 16px;
}
.manual-hint {
font-size: 13px;
color: #606266;
margin: 0 0 8px;
}
.secret-input {
margin-bottom: 8px;
}
.secret-input :deep(.el-input__inner) {
font-family: monospace;
font-size: 12px;
letter-spacing: 1px;
}
.manual-tip {
font-size: 12px;
color: #909399;
margin: 0;
}
.qrcode-actions {
display: flex;
gap: 12px;
}
.qrcode-actions .el-button {
flex: 1;
}
/* Step 3:验证 */
.code-input {
margin-bottom: 16px;
}
.code-input :deep(.el-input__inner) {
font-size: 24px;
letter-spacing: 8px;
text-align: center;
font-family: monospace;
}
.verify-actions {
display: flex;
gap: 12px;
}
.verify-actions .el-button {
flex: 1;
}
.error-alert {
margin-bottom: 16px;
}
</style>