bea288e414
== 已部署上线 (9项) == - 代办事项真实数据源集成 (企微审批API 8bug修复链) - H5/坐席端 Logo样式统一+绿色背景 - 视频引导页修复 (localStorage key v2) - 坐席端 v9 Vue版本修复 (ElMessage._context) - 截图按钮 v10 修复 (getDisplayMedia user gesture) - 扫码样式恢复+H5扫码登录跳转修复 - H5截图快捷键提示 == 代码完成待部署 (3项) == - 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查) - 会议室预定-小鱼易联终端 (40文件, 40/40测试通过) - IT资产升级审批推送 (asset_service.py) == 需求文档 (2项) == - 坐席端AI辅助消息框-PRD (4项新功能确认) - 坐席端布局优化建议 v2.0 (7天计划) == 新增文档 == - 日报-2026-07-11.md - 知识迭代Bug修复报告-20260711.md - 会议室预定-部署指南.md - CHANGELOG.md 更新 == 测试 == - test_todo_integration.py: 40/40 - test_meetingroom.py: 40/40 - test_bugfix_ki_suggestions.py: 21/21
522 lines
17 KiB
Python
522 lines
17 KiB
Python
# =============================================================================
|
||
# 企微IT智能服务台 — 统一认证 API
|
||
# =============================================================================
|
||
# 说明:统一认证入口,整合现有的扫码登录和企微SSO
|
||
# 三端认证重构:取消OTP/账号密码登录,统一为企微扫码认证
|
||
#
|
||
# API 端点列表:
|
||
# GET /api/auth/qrcode — 获取扫码登录二维码
|
||
# GET /api/auth/scan/status — 轮询扫码状态
|
||
# GET /api/auth/oauth2/callback — OAuth2回调处理
|
||
# POST /api/auth/bind — 账号绑定(互联企业)
|
||
# POST /api/auth/verify — 验证Token
|
||
# POST /api/auth/logout — 登出
|
||
# GET /api/auth/me — 获取当前用户信息
|
||
# POST /api/auth/switch-role — 切换角色
|
||
# =============================================================================
|
||
|
||
import logging
|
||
from typing import Optional
|
||
|
||
import redis.asyncio as aioredis
|
||
from fastapi import APIRouter, Depends, Query, Request
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.config import settings
|
||
from app.database import get_db
|
||
from app.dependencies import UserInfo, get_current_user, get_redis
|
||
from app.schemas.auth import (
|
||
BindAccountRequest,
|
||
CurrentUserResponse,
|
||
LogoutResponse,
|
||
SwitchRoleRequest,
|
||
SwitchRoleResponse,
|
||
TokenVerifyRequest,
|
||
TokenVerifyResponse,
|
||
UnifiedAuthResponse,
|
||
)
|
||
from app.services.auth_service import AuthService
|
||
from app.services.token_service import TokenService, TOKEN_TTL_SECONDS
|
||
from app.utils.response import AppException, success_response
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 创建路由器
|
||
router = APIRouter(prefix="/auth", tags=["统一认证"])
|
||
|
||
# Token 黑名单 Key 前缀
|
||
TOKEN_BLACKLIST_PREFIX = "token:blacklist:"
|
||
|
||
|
||
def _get_auth_service(
|
||
redis_client: aioredis.Redis,
|
||
db: AsyncSession = Depends(get_db),
|
||
) -> AuthService:
|
||
"""工厂函数:构造统一认证服务。
|
||
|
||
Args:
|
||
redis_client: Redis 客户端
|
||
db: 数据库会话
|
||
|
||
Returns:
|
||
AuthService: 统一认证服务实例
|
||
"""
|
||
return AuthService(redis_client, db)
|
||
|
||
|
||
def _get_client_info(request: Request) -> tuple:
|
||
"""获取客户端信息。
|
||
|
||
Args:
|
||
request: 请求对象
|
||
|
||
Returns:
|
||
tuple: (ip_address, user_agent)
|
||
"""
|
||
# 获取客户端 IP
|
||
ip_address = request.client.host if request.client else None
|
||
# 尝试从代理获取真实 IP
|
||
forwarded_for = request.headers.get("X-Forwarded-For")
|
||
if forwarded_for:
|
||
ip_address = forwarded_for.split(",")[0].strip()
|
||
|
||
# 获取 User-Agent
|
||
user_agent = request.headers.get("User-Agent", "")
|
||
|
||
return ip_address, user_agent
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/auth/qrcode — 获取扫码登录二维码
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/qrcode", response_model=None)
|
||
async def get_qrcode(
|
||
redis_client: aioredis.Redis = Depends(get_redis),
|
||
):
|
||
"""获取扫码登录二维码。
|
||
|
||
无需鉴权(用户尚未登录)。
|
||
返回 ticket + 企微 OAuth2 授权 URL,前端渲染二维码。
|
||
|
||
Returns:
|
||
Dict: 统一响应格式,data 字段包含 qrcode 信息
|
||
"""
|
||
try:
|
||
# 复用现有的扫码服务
|
||
from app.services.qrcode_service import QrcodeService
|
||
|
||
service = QrcodeService(redis_client)
|
||
result = await service.create_ticket()
|
||
|
||
return success_response(data={
|
||
"ticket": result["ticket"],
|
||
"qrcode_url": result["qrcode_url"],
|
||
"qrcode_png_base64": result["qrcode_png_base64"],
|
||
"expires_in": result["expires_in"],
|
||
"expires_at": result["expires_at"].isoformat(),
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.error(f"获取扫码二维码异常: {e}", exc_info=True)
|
||
raise AppException(1005, f"获取扫码二维码失败: {str(e)}")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/auth/scan/status — 轮询扫码状态
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/scan/status", response_model=None)
|
||
async def get_scan_status(
|
||
ticket: str = Query(..., description="扫码登录票据"),
|
||
redis_client: aioredis.Redis = Depends(get_redis),
|
||
):
|
||
"""轮询扫码状态。
|
||
|
||
无需鉴权(浏览器未登录态访问)。
|
||
|
||
状态机:
|
||
- waiting: ticket 有效,等待扫码
|
||
- scanned: 已扫码,等待 confirm
|
||
- confirmed: 已确认,返回 token
|
||
- expired: ticket 过期/不存在
|
||
|
||
Args:
|
||
ticket: 扫码登录票据
|
||
|
||
Returns:
|
||
Dict: 统一响应格式,data 字段包含状态信息
|
||
"""
|
||
try:
|
||
# 复用现有的扫码服务
|
||
from app.services.qrcode_service import QrcodeService
|
||
|
||
service = QrcodeService(redis_client)
|
||
result = await service.get_poll_state(ticket)
|
||
|
||
return success_response(data={
|
||
"status": result["status"],
|
||
"employee_id": result.get("employee_id"),
|
||
"name": result.get("name"),
|
||
"token": result.get("token"),
|
||
})
|
||
|
||
except Exception as e:
|
||
logger.error(f"轮询扫码状态异常: ticket={ticket[:8]}..., error={e}", exc_info=True)
|
||
raise AppException(1005, f"轮询扫码状态失败: {str(e)}")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/auth/oauth2/url — 获取 OAuth2 授权 URL
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/oauth2/url", response_model=None)
|
||
async def get_oauth2_url(
|
||
redirect_uri: str = Query(..., description="授权成功后的回调地址"),
|
||
):
|
||
"""获取企微 OAuth2 授权 URL。
|
||
|
||
前端在企微内打开时调用此接口,获取授权 URL 后跳转到企微授权页。
|
||
授权成功后企微会携带 code 回调到 redirect_uri。
|
||
|
||
Args:
|
||
redirect_uri: 授权成功后的回调地址(三端不同:/h5/、/itadmin/、/itagent/)
|
||
|
||
Returns:
|
||
Dict: 统一响应格式,data 字段包含 authorize_url
|
||
"""
|
||
import urllib.parse
|
||
|
||
# 构建企微 OAuth2 授权 URL
|
||
params = {
|
||
"appid": settings.wecom_corp_id,
|
||
"redirect_uri": redirect_uri,
|
||
"response_type": "code",
|
||
"scope": "snsapi_base",
|
||
"state": "auth_login",
|
||
}
|
||
if getattr(settings, "wecom_agent_id", None):
|
||
params["agentid"] = str(settings.wecom_agent_id)
|
||
|
||
query = urllib.parse.urlencode(params)
|
||
authorize_url = f"https://open.work.weixin.qq.com/connect/oauth2/authorize?{query}#wechat_redirect"
|
||
|
||
logger.info(f"生成统一认证OAuth授权URL: redirect_uri={redirect_uri}")
|
||
|
||
return success_response(data={
|
||
"authorize_url": authorize_url,
|
||
"redirect_uri": redirect_uri,
|
||
})
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/auth/oauth2/callback — OAuth2回调处理
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/oauth2/callback", response_model=None)
|
||
async def oauth2_callback(
|
||
request: Request,
|
||
code: Optional[str] = Query(None, description="企微 OAuth2 授权 code"),
|
||
state: Optional[str] = Query(None, description="防 CSRF state"),
|
||
errcode: Optional[int] = Query(None, description="企微 OAuth 错误码"),
|
||
errmsg: Optional[str] = Query(None, description="企微 OAuth 错误信息"),
|
||
redis_client: aioredis.Redis = Depends(get_redis),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""企微 OAuth2 回调处理。
|
||
|
||
用 code 换 userid → 查 role → 生成 token → 跳转到目标页面。
|
||
实际上是复用现有的 auth_wecom_sso 逻辑。
|
||
|
||
Args:
|
||
code: 企微 OAuth2 授权 code
|
||
state: 防 CSRF state
|
||
errcode: 企微 OAuth 错误码
|
||
errmsg: 企微 OAuth 错误信息
|
||
|
||
Returns:
|
||
RedirectResponse: 跳转到目标页面
|
||
"""
|
||
from fastapi.responses import RedirectResponse
|
||
|
||
# 复用 auth_wecom_sso 的逻辑
|
||
from app.api.auth_wecom_sso import sso_callback
|
||
|
||
# 调用现有的 SSO callback
|
||
return await sso_callback(
|
||
request=request,
|
||
code=code,
|
||
state=state,
|
||
errcode=errcode,
|
||
errmsg=errmsg,
|
||
next=None,
|
||
redis_client=redis_client,
|
||
db=db,
|
||
)
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# POST /api/auth/verify — 验证 Token
|
||
# --------------------------------------------------------------------------
|
||
@router.post("/verify", response_model=None)
|
||
async def verify_token(
|
||
body: TokenVerifyRequest,
|
||
redis_client: aioredis.Redis = Depends(get_redis),
|
||
):
|
||
"""验证 Token 有效性。
|
||
|
||
Args:
|
||
body: Token 验证请求
|
||
|
||
Returns:
|
||
Dict: 统一响应格式,data 字段包含验证结果
|
||
"""
|
||
try:
|
||
auth_service = _get_auth_service(redis_client)
|
||
user_info = await auth_service.verify_token(body.token)
|
||
|
||
if not user_info:
|
||
return success_response(data=TokenVerifyResponse(
|
||
valid=False,
|
||
))
|
||
|
||
# 获取 Token 剩余有效期
|
||
import hashlib
|
||
token_hash = hashlib.sha256(body.token.encode()).hexdigest()
|
||
key = f"user:token:{token_hash}"
|
||
ttl = await redis_client.ttl(key)
|
||
if ttl < 0:
|
||
ttl = TOKEN_TTL_SECONDS
|
||
|
||
return success_response(data=TokenVerifyResponse(
|
||
valid=True,
|
||
employee_id=user_info.get("employee_id"),
|
||
name=user_info.get("name"),
|
||
roles=user_info.get("roles", []),
|
||
current_role=user_info.get("current_role"),
|
||
login_source=user_info.get("login_source"),
|
||
expires_in=ttl,
|
||
))
|
||
|
||
except Exception as e:
|
||
logger.error(f"验证 Token 异常: {e}", exc_info=True)
|
||
raise AppException(1005, f"验证 Token 失败: {str(e)}")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# POST /api/auth/logout — 登出
|
||
# --------------------------------------------------------------------------
|
||
@router.post("/logout", response_model=None)
|
||
async def logout(
|
||
request: Request,
|
||
current_user: UserInfo = Depends(get_current_user),
|
||
redis_client: aioredis.Redis = Depends(get_redis),
|
||
):
|
||
"""登出:将 Token 加入黑名单并清除 Redis 中的 Token。
|
||
|
||
需要鉴权:已登录用户才能登出。
|
||
|
||
Args:
|
||
current_user: 当前用户信息
|
||
|
||
Returns:
|
||
Dict: 统一响应格式
|
||
"""
|
||
try:
|
||
# 从请求头获取 Token
|
||
auth_header = request.headers.get("Authorization", "")
|
||
if auth_header.startswith("Bearer "):
|
||
token = auth_header[7:]
|
||
|
||
# 将 Token 加入黑名单
|
||
import hashlib
|
||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||
key = f"{TOKEN_BLACKLIST_PREFIX}{token_hash}"
|
||
await redis_client.setex(key, TOKEN_TTL_SECONDS, "1")
|
||
|
||
# 清除 Redis 中的 Token
|
||
token_service = TokenService(redis_client)
|
||
await token_service.invalidate_token(token)
|
||
|
||
logger.info(f"用户登出: employee_id={current_user.employee_id}")
|
||
|
||
return success_response(data=LogoutResponse(
|
||
success=True,
|
||
message="登出成功",
|
||
))
|
||
|
||
except Exception as e:
|
||
logger.error(f"登出异常: {e}", exc_info=True)
|
||
raise AppException(1005, f"登出失败: {str(e)}")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# GET /api/auth/me — 获取当前用户信息
|
||
# --------------------------------------------------------------------------
|
||
@router.get("/me", response_model=None)
|
||
async def get_current_user_info(
|
||
current_user: UserInfo = Depends(get_current_user),
|
||
):
|
||
"""获取当前用户信息。
|
||
|
||
需要鉴权:已登录用户才能调用。
|
||
|
||
Args:
|
||
current_user: 当前用户信息
|
||
|
||
Returns:
|
||
Dict: 统一响应格式,data 字段包含用户信息
|
||
"""
|
||
return success_response(data=CurrentUserResponse(
|
||
employee_id=current_user.employee_id,
|
||
name=current_user.name,
|
||
avatar=current_user.avatar,
|
||
department=current_user.department,
|
||
roles=current_user.roles,
|
||
current_role=current_user.current_role,
|
||
login_source=current_user.login_source,
|
||
))
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# POST /api/auth/switch-role — 切换角色
|
||
# --------------------------------------------------------------------------
|
||
@router.post("/switch-role", response_model=None)
|
||
async def switch_role(
|
||
body: SwitchRoleRequest,
|
||
current_user: UserInfo = Depends(get_current_user),
|
||
redis_client: aioredis.Redis = Depends(get_redis),
|
||
request: Request = None,
|
||
):
|
||
"""切换当前角色。
|
||
|
||
需要鉴权:已登录用户才能调用。
|
||
|
||
Args:
|
||
body: 角色切换请求
|
||
current_user: 当前用户信息
|
||
|
||
Returns:
|
||
Dict: 统一响应格式
|
||
"""
|
||
try:
|
||
# 从请求头获取 Token
|
||
token = None
|
||
auth_header = request.headers.get("Authorization", "")
|
||
if auth_header.startswith("Bearer "):
|
||
token = auth_header[7:]
|
||
|
||
if not token:
|
||
raise AppException(1001, "无法获取 Token")
|
||
|
||
auth_service = _get_auth_service(redis_client)
|
||
success = await auth_service.switch_role(token, body.role)
|
||
|
||
if not success:
|
||
raise AppException(1003, f"切换角色失败:用户没有 {body.role} 角色")
|
||
|
||
# 获取更新后的用户信息
|
||
user_info = await auth_service.get_current_user(token)
|
||
|
||
logger.info(
|
||
f"用户切换角色: employee_id={current_user.employee_id}, "
|
||
f"new_role={body.role}"
|
||
)
|
||
|
||
return success_response(data=SwitchRoleResponse(
|
||
success=True,
|
||
current_role=body.role,
|
||
message="角色切换成功",
|
||
))
|
||
|
||
except AppException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"切换角色异常: {e}", exc_info=True)
|
||
raise AppException(1005, f"切换角色失败: {str(e)}")
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# POST /api/auth/bind — 账号绑定(互联企业)
|
||
# --------------------------------------------------------------------------
|
||
@router.post("/bind", response_model=None)
|
||
async def bind_account(
|
||
body: BindAccountRequest,
|
||
request: Request,
|
||
redis_client: aioredis.Redis = Depends(get_redis),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""账号绑定(互联企业)。
|
||
|
||
用于互联企业用户绑定已有账号。
|
||
|
||
Args:
|
||
body: 账号绑定请求(含 employee_id, corp_id 等)
|
||
request: 请求对象(用于获取客户端信息)
|
||
|
||
Returns:
|
||
Dict: 统一响应格式
|
||
"""
|
||
try:
|
||
from sqlalchemy import select
|
||
from app.models.employee import Employee
|
||
|
||
# corp_id 默认使用系统配置的主企业ID
|
||
corp_id = body.corp_id or settings.wecom_corp_id
|
||
employee_id = body.employee_id
|
||
|
||
# 查询员工信息
|
||
stmt = select(Employee).where(
|
||
Employee.corp_id == corp_id,
|
||
Employee.employee_id == employee_id,
|
||
)
|
||
result = await db.execute(stmt)
|
||
employee = result.scalar_one_or_none()
|
||
|
||
if not employee:
|
||
raise AppException(1003, "员工信息不存在,请先通过企微认证")
|
||
|
||
# 查询角色
|
||
from app.models.role import Role
|
||
from app.models.user_role import UserRole
|
||
|
||
role_stmt = (
|
||
select(Role)
|
||
.join(UserRole, Role.id == UserRole.role_id)
|
||
.where(UserRole.employee_id == employee_id)
|
||
)
|
||
role_result = await db.execute(role_stmt)
|
||
roles = role_result.scalars().all()
|
||
|
||
if not roles:
|
||
# 没有绑定角色:跳"无权限"页
|
||
raise AppException(1003, "该员工没有绑定角色,请联系管理员")
|
||
|
||
# 选择最高权限角色
|
||
role_priority = {"admin": 3, "agent": 2, "user": 1}
|
||
best_role = max(roles, key=lambda r: role_priority.get(r.name, 0))
|
||
role_name = best_role.name
|
||
role_list = [r.name for r in roles]
|
||
|
||
# 获取客户端信息
|
||
ip_address, user_agent = _get_client_info(request)
|
||
|
||
# 创建 Token 并记录登录日志
|
||
auth_service = _get_auth_service(redis_client, db)
|
||
result = await auth_service.create_token_and_login(
|
||
employee_id=employee_id,
|
||
name=employee.name,
|
||
roles=role_list,
|
||
corp_id=corp_id,
|
||
login_method="bind",
|
||
login_source="h5",
|
||
department=employee.department,
|
||
avatar=employee.avatar,
|
||
ip_address=ip_address,
|
||
user_agent=user_agent,
|
||
)
|
||
|
||
return success_response(data=UnifiedAuthResponse(**result))
|
||
|
||
except AppException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"账号绑定异常: {e}", exc_info=True)
|
||
raise AppException(1005, f"账号绑定失败: {str(e)}")
|