feat(mfa-ui): 前端 MFA UI - 绑定+验证+高危弹窗+管理 (Phase 2.4 task #20)
This commit is contained in:
@@ -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'
|
||||
@@ -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 },
|
||||
},
|
||||
]
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user