diff --git a/frontend-admin/src/api/mfa.ts b/frontend-admin/src/api/mfa.ts new file mode 100644 index 0000000..37e8508 --- /dev/null +++ b/frontend-admin/src/api/mfa.ts @@ -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 { + 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 { + const response: AxiosResponse = await apiClient.post( + `/admin/mfa/reset/${encodeURIComponent(employeeId)}` + ) + return response.data.data +} diff --git a/frontend-admin/src/router/index.ts b/frontend-admin/src/router/index.ts index eb1ef12..fdfc915 100644 --- a/frontend-admin/src/router/index.ts +++ b/frontend-admin/src/router/index.ts @@ -126,6 +126,13 @@ const routes = [ component: () => import('@/views/Placeholder.vue'), 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 }, + }, ], }, { diff --git a/frontend-admin/src/views/MfaManage.vue b/frontend-admin/src/views/MfaManage.vue new file mode 100644 index 0000000..1d851c8 --- /dev/null +++ b/frontend-admin/src/views/MfaManage.vue @@ -0,0 +1,403 @@ + + + + + + diff --git a/frontend-agent/src/api/mfa.ts b/frontend-agent/src/api/mfa.ts new file mode 100644 index 0000000..f7584af --- /dev/null +++ b/frontend-agent/src/api/mfa.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + const body: MfaDisableRequest = { otp_code: otpCode } + const response: AxiosResponse = await apiClient.post('/mfa/disable', body) + return response.data.data +} diff --git a/frontend-agent/src/composables/useHighRiskOtp.ts b/frontend-agent/src/composables/useHighRiskOtp.ts new file mode 100644 index 0000000..5c76899 --- /dev/null +++ b/frontend-agent/src/composables/useHighRiskOtp.ts @@ -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 + /** 弹窗标题(给 template 显示用) */ + dialogTitle: Ref + /** 当前正在校验的操作描述 */ + pendingAction: Ref + /** OTP 输入框值 */ + otpCode: Ref + /** 校验 loading */ + verifying: Ref + /** 弹窗内错误信息 */ + dialogError: Ref + /** + * 触发 OTP 弹窗 + * - 用户输入正确的 OTP → resolve(otp_code) + * - 用户取消 / 关闭弹窗 / 验证失败 5 次 → reject(error) + * + * @param options 配置项 + * @returns Promise resolve 时返回用户输入的 OTP 码(供业务调用方日志审计) + */ + requireOtpDialog: (options: RequireOtpDialogOptions) => Promise + /** 内部使用:关闭弹窗(用户点取消或验证成功后) */ + closeDialog: () => void + /** + * 内部使用:提交 OTP 验证(由弹窗内"确认"按钮调用) + * 一般不直接调,requireOtpDialog 内部会自动触发 + */ + submitDialog: () => Promise +} + +/** + * 弹一个 OTP 输入对话框,要求用户输入 6 位码做二次确认 + * + * @example + * const { requireOtpDialog } = useHighRiskOtp() + * await requireOtpDialog({ action: '删除会话' }) + */ +export function useHighRiskOtp(): UseHighRiskOtpReturn { + // -------------------------------------------------------------------------- + // 响应式状态(给 template 绑 v-model 用) + // -------------------------------------------------------------------------- + const dialogVisible = ref(false) + const dialogTitle = ref('高危操作二次验证') + const pendingAction = ref('') + const otpCode = ref('') + const verifying = ref(false) + const dialogError = ref('') + + // -------------------------------------------------------------------------- + // 内部状态(不暴露给外部,用于 resolve/reject 跨 promise 传递) + // -------------------------------------------------------------------------- + let resolver: ((value: string) => void) | null = null + let rejecter: ((reason: Error) => void) | null = null + let resolveTimer: ReturnType | 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 { + 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 resolve 时返回用户输入的 OTP 码 + */ + function requireOtpDialog(options: RequireOtpDialogOptions): Promise { + // 防止重复触发:如果已经有弹窗开着,reject 旧的再开新的 + if (dialogVisible.value) { + if (rejecter) { + const rj = rejecter + rejecter = null + resolver = null + rj(new Error('新的 OTP 验证请求已发起')) + } + } + + return new Promise((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 true=确认,false=取消 + */ +export async function confirmDangerAction( + message: string, + title: string = '操作确认' +): Promise { + try { + await ElMessageBox.confirm(message, title, { + type: 'warning', + confirmButtonText: '确认', + cancelButtonText: '取消', + }) + return true + } catch { + // 用户点了取消 + return false + } +} + +/** 导出 API 包装,方便业务方直接调(可选) */ +export { verifyMfa } from '@/api/mfa' diff --git a/frontend-agent/src/router/index.ts b/frontend-agent/src/router/index.ts index 6b7f03a..62922ef 100644 --- a/frontend-agent/src/router/index.ts +++ b/frontend-agent/src/router/index.ts @@ -41,6 +41,15 @@ const routes = [ component: () => import('@/views/AgentPreviewView.vue'), 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 }, + }, ] // -------------------------------------------------------------------------- diff --git a/frontend-agent/src/views/MfaBind.vue b/frontend-agent/src/views/MfaBind.vue new file mode 100644 index 0000000..74edc6f --- /dev/null +++ b/frontend-agent/src/views/MfaBind.vue @@ -0,0 +1,526 @@ + + + + + +