revert: 回滚认证模块到上午版本(400ce3d) - 恢复扫码登录和账号密码+OTP
This commit is contained in:
@@ -291,20 +291,14 @@ async def agent_login(
|
|||||||
# BUG-001 修复: 签发半认证 token,使前端可以调用 otp-bind / otp-verify
|
# BUG-001 修复: 签发半认证 token,使前端可以调用 otp-bind / otp-verify
|
||||||
# 这些端点需要 Bearer token(get_current_user 认证),否则流程完全阻断
|
# 这些端点需要 Bearer token(get_current_user 认证),否则流程完全阻断
|
||||||
from app.services.token_service import TokenService
|
from app.services.token_service import TokenService
|
||||||
from app.services.role_mapping_service import RoleMappingService
|
|
||||||
from app.dependencies import get_redis
|
from app.dependencies import get_redis
|
||||||
|
|
||||||
redis_client = await get_redis()
|
redis_client = await get_redis()
|
||||||
token_service = TokenService(redis_client)
|
token_service = TokenService(redis_client)
|
||||||
# BUGFIX: 从 UserRole 表查询真实角色,而非硬编码 ["agent"]
|
|
||||||
role_service = RoleMappingService(db)
|
|
||||||
roles = await role_service.get_user_roles(agent.user_id)
|
|
||||||
if not roles:
|
|
||||||
roles = ["agent"] # 无角色时默认 fallback
|
|
||||||
bind_token = await token_service.create_token(
|
bind_token = await token_service.create_token(
|
||||||
employee_id=agent.user_id,
|
employee_id=agent.user_id,
|
||||||
name=agent.name,
|
name=agent.name,
|
||||||
roles=roles,
|
roles=["agent"],
|
||||||
avatar=avatar,
|
avatar=avatar,
|
||||||
login_source="agent_pending_otp",
|
login_source="agent_pending_otp",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -236,46 +236,59 @@ async def scan_qrcode(
|
|||||||
f"employee_id={result['employee_id']}, name={result['name']}"
|
f"employee_id={result['employee_id']}, name={result['name']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# GET 请求(企微 OAuth 回调)→ 已自动确认,显示成功页 + 自动关闭
|
# GET 请求(企微 OAuth 回调)→ 重定向到前端选择页
|
||||||
|
# 因为企微 OAuth 流程不在这个端点完成最终登录,只标记 scanned,
|
||||||
|
# 等用户在坐席端点 confirm 后才能拿到 token。
|
||||||
|
# 但企微 WebView 期望看到跳转后的页面,所以这里给个提示页。
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
if final_code is not None and final_ticket is not None and body is None:
|
if final_code is not None and final_ticket is not None and body is None:
|
||||||
user_name = result.get('name', '')
|
# GET 模式:渲染一个 "扫码成功" 的 HTML 提示页 + 引导用户到登录页
|
||||||
html = f"""<!DOCTYPE html>
|
html = f"""<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>登录成功 - IT智能服务台</title>
|
<title>扫码成功 - IT智能服务台</title>
|
||||||
<style>
|
<style>
|
||||||
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
||||||
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #07C160 0%, #06AD56 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }}
|
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 20px; }}
|
||||||
.card {{ background: rgba(255,255,255,0.95); border-radius: 20px; padding: 48px 40px; max-width: 360px; width: 100%; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }}
|
.card {{ background: rgba(255,255,255,0.95); border-radius: 20px; padding: 48px 40px; max-width: 360px; width: 100%; text-align: center; box-shadow: 0 20px 60px rgba(0,0,0,0.3); }}
|
||||||
.check {{ width: 64px; height: 64px; margin: 0 auto 16px; }}
|
.logo {{ width: 80px; height: 80px; margin-bottom: 24px; }}
|
||||||
.title {{ color: #1f2937; font-size: 24px; font-weight: 600; margin-bottom: 8px; }}
|
.title {{ color: #1f2937; font-size: 28px; font-weight: 600; margin-bottom: 8px; }}
|
||||||
.subtitle {{ color: #6b7280; font-size: 14px; margin-bottom: 24px; }}
|
.subtitle {{ color: #6b7280; font-size: 14px; line-height: 1.6; margin-bottom: 24px; }}
|
||||||
.status {{ display: inline-flex; align-items: center; gap: 6px; background: #dcfce7; color: #166534; padding: 10px 20px; border-radius: 50px; font-size: 14px; font-weight: 500; }}
|
.status {{ display: inline-flex; align-items: center; gap: 8px; background: #dcfce7; color: #166534; padding: 12px 24px; border-radius: 50px; font-size: 14px; font-weight: 500; margin-bottom: 24px; }}
|
||||||
|
.spinner {{ width: 20px; height: 20px; border: 2px solid #86efac; border-top-color: #166534; border-radius: 50%; animation: spin 1s linear infinite; }}
|
||||||
|
@keyframes spin {{ to {{ transform: rotate(360deg); }} }}
|
||||||
|
.tips {{ background: #f3f4f6; border-radius: 12px; padding: 16px; text-align: left; }}
|
||||||
|
.tips-title {{ color: #374151; font-size: 13px; font-weight: 600; margin-bottom: 8px; }}
|
||||||
|
.tips-item {{ color: #6b7280; font-size: 12px; line-height: 1.8; display: flex; align-items: flex-start; gap: 6px; }}
|
||||||
|
.tips-item::before {{ content: '•'; color: #9ca3af; }}
|
||||||
.footer {{ margin-top: 24px; color: #9ca3af; font-size: 12px; }}
|
.footer {{ margin-top: 24px; color: #9ca3af; font-size: 12px; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<svg class="check" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg class="logo" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<circle cx="32" cy="32" r="30" fill="#07C160" stroke="#06AD56" stroke-width="4"/>
|
<rect width="80" height="80" rx="16" fill="#07C160"/>
|
||||||
<path d="M20 32l8 8 16-16" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
<path d="M24 40c0-8.837 7.163-16 16-16s16 7.163 16 16-7.163 16-16 16-16-7.163-16-16zm16 0c0 4.418 3.582 8 8 8s8-3.582 8-8-3.582-8-8-8-8-3.582-8-8z" fill="white"/>
|
||||||
|
<path d="M24 28c0-6.627 5.373-12 12-12s12 5.373 12 12V40c0 6.627-5.373 12-12 12s-12-5.373-12-12z" fill="white" opacity="0.7"/>
|
||||||
|
<circle cx="40" cy="48" r="4" fill="white"/>
|
||||||
|
<rect x="36" y="54" width="8" height="12" rx="2" fill="white"/>
|
||||||
</svg>
|
</svg>
|
||||||
<h1 class="title">登录成功</h1>
|
<h1 class="title">扫码成功</h1>
|
||||||
<div class="status">已自动确认登录</div>
|
<div class="status">
|
||||||
<p class="subtitle">你好,{user_name}<br>请返回电脑端查看</p>
|
<span class="spinner"></span>
|
||||||
<div class="footer">页面可安全关闭 · 税友集团</div>
|
等待确认登录...
|
||||||
|
</div>
|
||||||
|
<p class="subtitle">请在电脑端的登录页面点击<br><strong style="color:#07C160">「确认登录」</strong> 按钮完成登录</p>
|
||||||
|
<div class="tips">
|
||||||
|
<div class="tips-title">📋 操作指引</div>
|
||||||
|
<div class="tips-item">已在电脑上打开登录页面</div>
|
||||||
|
<div class="tips-item">点击页面上的「确认登录」按钮</div>
|
||||||
|
<div class="tips-item">登录成功后可关闭此页面</div>
|
||||||
|
</div>
|
||||||
|
<div class="footer">IT智能服务台 · 税友集团</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
|
||||||
// 尝试关闭企微 WebView(如果支持的话)
|
|
||||||
setTimeout(function() {{
|
|
||||||
if (typeof wx !== 'undefined' && wx.closeWindow) {{
|
|
||||||
wx.closeWindow();
|
|
||||||
}}
|
|
||||||
}}, 1500);
|
|
||||||
</script>
|
|
||||||
</body>
|
</body>
|
||||||
</html>"""
|
</html>"""
|
||||||
return HTMLResponse(content=html, status_code=200)
|
return HTMLResponse(content=html, status_code=200)
|
||||||
|
|||||||
@@ -23,42 +23,11 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import apiClient from './index'
|
import apiClient from './index'
|
||||||
import type { AxiosResponse } from 'axios'
|
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
// TypeScript 类型定义
|
// TypeScript 类型定义
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
/** POST /auth/otp-bind 响应(用户绑定用) */
|
|
||||||
export interface OtpBindData {
|
|
||||||
/** TOTP 共享密钥(base32) */
|
|
||||||
secret: string
|
|
||||||
/** otpauth:// URI */
|
|
||||||
otpauth_url: string
|
|
||||||
/** 二维码 PNG base64(不含 data: 前缀) */
|
|
||||||
qr_code_base64: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /auth/otp-verify 请求体 */
|
|
||||||
export interface OtpVerifyRequest {
|
|
||||||
/** 6 位 OTP 动态码 */
|
|
||||||
otp_code: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** POST /auth/otp-verify 响应 */
|
|
||||||
export interface OtpVerifyData {
|
|
||||||
/** 验证是否通过 */
|
|
||||||
verified: boolean
|
|
||||||
/** 登录 token(首次绑定成功后返回) */
|
|
||||||
token?: string
|
|
||||||
/** 用户 ID */
|
|
||||||
user_id?: string
|
|
||||||
/** 用户姓名 */
|
|
||||||
name?: string
|
|
||||||
/** 用户角色 */
|
|
||||||
role?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 单个用户的 OTP 状态条目 */
|
/** 单个用户的 OTP 状态条目 */
|
||||||
export interface MfaUserStatus {
|
export interface MfaUserStatus {
|
||||||
/** 员工 ID(企微 userid) */
|
/** 员工 ID(企微 userid) */
|
||||||
@@ -137,32 +106,3 @@ export async function resetMfa(employeeId: string): Promise<MfaAdminResetData> {
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
// 用户端 OTP 绑定/验证函数(供登录绑定面板使用)
|
|
||||||
// --------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 绑定 OTP — 生成 secret + 二维码
|
|
||||||
* 用户首次登录时调用,获取 TOTP 密钥和二维码
|
|
||||||
*
|
|
||||||
* @returns OTP 绑定信息(secret + otpauth_url + base64 PNG)
|
|
||||||
*/
|
|
||||||
export async function bindOtp(): Promise<OtpBindData> {
|
|
||||||
// 拦截器已返回 inner data,直接返回
|
|
||||||
return await apiClient.post('/auth/otp-bind')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 验证 OTP 并完成绑定
|
|
||||||
* 用户扫码后输入 6 位验证码,验证通过后完成绑定
|
|
||||||
* 如果是登录流程中的首次绑定,返回 token 等登录信息
|
|
||||||
*
|
|
||||||
* @param otpCode - 6 位 OTP 动态码
|
|
||||||
* @returns 验证结果(verified + 可选的登录 token)
|
|
||||||
*/
|
|
||||||
export async function verifyOtp(otpCode: string): Promise<OtpVerifyData> {
|
|
||||||
const body: OtpVerifyRequest = { otp_code: otpCode }
|
|
||||||
// 拦截器已返回 inner data,直接返回
|
|
||||||
return await apiClient.post('/auth/otp-verify', body)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -130,15 +130,6 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)
|
|||||||
</el-form>
|
</el-form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- OTP 首次绑定面板 -->
|
|
||||||
<OtpBindPanel
|
|
||||||
v-if="requireOtpBind && otpBindUser"
|
|
||||||
:user-id="otpBindUser!.user_id"
|
|
||||||
:name="otpBindUser!.name"
|
|
||||||
@bind-success="onBindSuccess"
|
|
||||||
@cancel="onBindCancel"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- 错误提示 -->
|
<!-- 错误提示 -->
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="errorMsg"
|
v-if="errorMsg"
|
||||||
@@ -169,7 +160,6 @@ import { ElMessage } from 'element-plus'
|
|||||||
import { useAdminStore } from '@/stores/admin'
|
import { useAdminStore } from '@/stores/admin'
|
||||||
import type { FormInstance, FormRules } from 'element-plus'
|
import type { FormInstance, FormRules } from 'element-plus'
|
||||||
import { Loading, User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
|
import { Loading, User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
|
||||||
import OtpBindPanel from '@/components/OtpBindPanel.vue'
|
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Store
|
// Store
|
||||||
@@ -194,12 +184,6 @@ const loginForm = reactive({
|
|||||||
/** 是否需要 OTP 验证 */
|
/** 是否需要 OTP 验证 */
|
||||||
const requireOtp = ref(false)
|
const requireOtp = ref(false)
|
||||||
|
|
||||||
/** 是否需要 OTP 首次绑定 */
|
|
||||||
const requireOtpBind = ref(false)
|
|
||||||
|
|
||||||
/** OTP 绑定用户信息(来自 require_otp_bind 响应) */
|
|
||||||
const otpBindUser = ref<{ user_id: string; name: string; role: string } | null>(null)
|
|
||||||
|
|
||||||
/** 错误信息 */
|
/** 错误信息 */
|
||||||
const errorMsg = ref<string>('')
|
const errorMsg = ref<string>('')
|
||||||
|
|
||||||
@@ -344,9 +328,6 @@ async function checkWecomClient(): Promise<void> {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 企微免密登录
|
* 企微免密登录
|
||||||
* @deprecated 已废弃 - AUTH-04 任务移除免密分支,后端 /api/auth_wecom/jsdk-login 接口已删除
|
|
||||||
* @see docs/system_design.md 第347行 AUTH-04 验收标准
|
|
||||||
* 此函数仅作占位,暂未删除以保持代码可追溯性
|
|
||||||
*/
|
*/
|
||||||
async function handleWecomQuickLogin(): Promise<void> {
|
async function handleWecomQuickLogin(): Promise<void> {
|
||||||
wecomQuickLoading.value = true
|
wecomQuickLoading.value = true
|
||||||
@@ -455,34 +436,6 @@ function stopPolling(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* OTP 绑定成功回调
|
|
||||||
* @see docs/system_design.md AUTH-10 验收标准
|
|
||||||
*/
|
|
||||||
function onBindSuccess(token: string, userId: string, name: string, role: string): void {
|
|
||||||
// 保存 token
|
|
||||||
localStorage.setItem('admin_token', token)
|
|
||||||
localStorage.setItem('admin_user_id', userId)
|
|
||||||
|
|
||||||
// 更新 store
|
|
||||||
adminStore.token = token
|
|
||||||
adminStore.adminUserId = userId
|
|
||||||
|
|
||||||
ElMessage.success('OTP 绑定成功,已登录')
|
|
||||||
router.push('/')
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OTP 绑定取消回调
|
|
||||||
* 清除绑定状态,返回登录表单
|
|
||||||
*/
|
|
||||||
function onBindCancel(): void {
|
|
||||||
requireOtpBind.value = false
|
|
||||||
otpBindUser.value = null
|
|
||||||
// 登出以清除半认证 token
|
|
||||||
adminStore.logout()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理登录
|
* 处理登录
|
||||||
*/
|
*/
|
||||||
@@ -501,7 +454,6 @@ async function handleLogin(): Promise<void> {
|
|||||||
)
|
)
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
// 检查是否需要 OTP 验证
|
// 检查是否需要 OTP 验证
|
||||||
// @see docs/system_design.md AUTH-10 验收标准
|
|
||||||
if (error instanceof Error && error.message === 'require_otp') {
|
if (error instanceof Error && error.message === 'require_otp') {
|
||||||
requireOtp.value = true
|
requireOtp.value = true
|
||||||
loginForm.otpCode = ''
|
loginForm.otpCode = ''
|
||||||
@@ -509,20 +461,6 @@ async function handleLogin(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否需要 OTP 首次绑定
|
|
||||||
if (error instanceof Error && error.message === 'require_otp_bind') {
|
|
||||||
// 从 adminStore 获取用户信息(登录时已保存)
|
|
||||||
requireOtpBind.value = true
|
|
||||||
otpBindUser.value = {
|
|
||||||
user_id: adminStore.adminUserId || loginForm.userId,
|
|
||||||
name: adminStore.adminInfo?.name || '',
|
|
||||||
role: adminStore.adminInfo?.role || 'admin',
|
|
||||||
}
|
|
||||||
// 隐藏登录表单
|
|
||||||
showPasswordPanel.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
|
const errMsg = error instanceof Error ? error.message : '登录失败,请重试'
|
||||||
errorMsg.value = errMsg
|
errorMsg.value = errMsg
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user