revert: 回滚认证模块到上午版本(400ce3d) - 恢复扫码登录和账号密码+OTP

This commit is contained in:
Simon
2026-07-09 19:09:46 +08:00
parent 4bd4a9068b
commit bceaebde98
4 changed files with 37 additions and 152 deletions
+1 -7
View File
@@ -291,20 +291,14 @@ async def agent_login(
# BUG-001 修复: 签发半认证 token,使前端可以调用 otp-bind / otp-verify
# 这些端点需要 Bearer tokenget_current_user 认证),否则流程完全阻断
from app.services.token_service import TokenService
from app.services.role_mapping_service import RoleMappingService
from app.dependencies import get_redis
redis_client = await get_redis()
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(
employee_id=agent.user_id,
name=agent.name,
roles=roles,
roles=["agent"],
avatar=avatar,
login_source="agent_pending_otp",
)
+36 -23
View File
@@ -236,46 +236,59 @@ async def scan_qrcode(
f"employee_id={result['employee_id']}, name={result['name']}"
)
# GET 请求(企微 OAuth 回调)→ 已自动确认,显示成功页 + 自动关闭
# GET 请求(企微 OAuth 回调)→ 重定向到前端选择页
# 因为企微 OAuth 流程不在这个端点完成最终登录,只标记 scanned,
# 等用户在坐席端点 confirm 后才能拿到 token。
# 但企微 WebView 期望看到跳转后的页面,所以这里给个提示页。
from fastapi.responses import HTMLResponse
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 lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录成功 - IT智能服务台</title>
<title>扫码成功 - IT智能服务台</title>
<style>
* {{ 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); }}
.check {{ width: 64px; height: 64px; margin: 0 auto 16px; }}
.title {{ color: #1f2937; font-size: 24px; font-weight: 600; margin-bottom: 8px; }}
.subtitle {{ color: #6b7280; font-size: 14px; 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; }}
.logo {{ width: 80px; height: 80px; margin-bottom: 24px; }}
.title {{ color: #1f2937; font-size: 28px; font-weight: 600; margin-bottom: 8px; }}
.subtitle {{ color: #6b7280; font-size: 14px; line-height: 1.6; margin-bottom: 24px; }}
.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; }}
</style>
</head>
<body>
<div class="card">
<svg class="check" viewBox="0 0 64 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="32" cy="32" r="30" fill="#07C160" stroke="#06AD56" stroke-width="4"/>
<path d="M20 32l8 8 16-16" stroke="white" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<svg class="logo" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="80" height="80" rx="16" fill="#07C160"/>
<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>
<h1 class="title">登录成功</h1>
<div class="status">已自动确认登录</div>
<p class="subtitle">你好,{user_name}<br>请返回电脑端查看</p>
<div class="footer">页面可安全关闭 · 税友集团</div>
<h1 class="title">扫码成功</h1>
<div class="status">
<span class="spinner"></span>
等待确认登录...
</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>
<script>
// 尝试关闭企微 WebView(如果支持的话)
setTimeout(function() {{
if (typeof wx !== 'undefined' && wx.closeWindow) {{
wx.closeWindow();
}}
}}, 1500);
</script>
</body>
</html>"""
return HTMLResponse(content=html, status_code=200)
-60
View File
@@ -23,42 +23,11 @@
// =============================================================================
import apiClient from './index'
import type { AxiosResponse } from 'axios'
// --------------------------------------------------------------------------
// 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 状态条目 */
export interface MfaUserStatus {
/** 员工 ID(企微 userid) */
@@ -137,32 +106,3 @@ export async function resetMfa(employeeId: string): Promise<MfaAdminResetData> {
)
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)
}
-62
View File
@@ -130,15 +130,6 @@ IT智能服务台 — 管理员登录页 (v1.2, 2026-07-06)
</el-form>
</div>
<!-- OTP 首次绑定面板 -->
<OtpBindPanel
v-if="requireOtpBind && otpBindUser"
:user-id="otpBindUser!.user_id"
:name="otpBindUser!.name"
@bind-success="onBindSuccess"
@cancel="onBindCancel"
/>
<!-- 错误提示 -->
<el-alert
v-if="errorMsg"
@@ -169,7 +160,6 @@ import { ElMessage } from 'element-plus'
import { useAdminStore } from '@/stores/admin'
import type { FormInstance, FormRules } from 'element-plus'
import { Loading, User, Lock, Key, InfoFilled, Headset } from '@element-plus/icons-vue'
import OtpBindPanel from '@/components/OtpBindPanel.vue'
// ==========================================================================
// Store
@@ -194,12 +184,6 @@ const loginForm = reactive({
/** 是否需要 OTP 验证 */
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>('')
@@ -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> {
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) {
// 检查是否需要 OTP 验证
// @see docs/system_design.md AUTH-10 验收标准
if (error instanceof Error && error.message === 'require_otp') {
requireOtp.value = true
loginForm.otpCode = ''
@@ -509,20 +461,6 @@ async function handleLogin(): Promise<void> {
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 : '登录失败,请重试'
errorMsg.value = errMsg
}