revert: 回滚认证模块到上午版本(400ce3d) - 恢复扫码登录和账号密码+OTP
This commit is contained in:
@@ -291,20 +291,14 @@ async def agent_login(
|
||||
# BUG-001 修复: 签发半认证 token,使前端可以调用 otp-bind / otp-verify
|
||||
# 这些端点需要 Bearer token(get_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",
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user