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
297 lines
9.0 KiB
Python
297 lines
9.0 KiB
Python
# =============================================================================
|
||
# 企微IT智能服务台 — 统一认证服务
|
||
# =============================================================================
|
||
# 说明:统一认证核心逻辑,支持:
|
||
# 1. Token 创建、验证、刷新、失效
|
||
# 2. 登录日志记录
|
||
# 3. 角色切换
|
||
# 4. 登出功能(Token 黑名单)
|
||
# 三端认证重构:取消OTP/账号密码登录,统一为企微扫码认证
|
||
# =============================================================================
|
||
|
||
import json
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Dict, List, Optional
|
||
|
||
import redis.asyncio as aioredis
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.models.login_log import LoginLog
|
||
from app.services.token_service import TokenService
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# Token TTL(8小时)
|
||
TOKEN_TTL_SECONDS = 8 * 60 * 60
|
||
|
||
# Token 黑名单 Key 前缀
|
||
TOKEN_BLACKLIST_PREFIX = "token:blacklist:"
|
||
|
||
|
||
class AuthService:
|
||
"""统一认证服务。
|
||
|
||
管理用户认证的完整流程,包括 Token 管理和登录日志。
|
||
"""
|
||
|
||
def __init__(self, redis_client: aioredis.Redis, db: Optional[AsyncSession] = None):
|
||
"""初始化认证服务。
|
||
|
||
Args:
|
||
redis_client: Redis 异步客户端
|
||
db: 数据库会话(可选,用于记录登录日志)
|
||
"""
|
||
self.redis = redis_client
|
||
self.db = db
|
||
self.token_service = TokenService(redis_client)
|
||
|
||
async def create_token_and_login(
|
||
self,
|
||
employee_id: str,
|
||
name: str,
|
||
roles: List[str],
|
||
corp_id: str,
|
||
login_method: str,
|
||
login_source: str,
|
||
department: Optional[str] = None,
|
||
avatar: Optional[str] = None,
|
||
ip_address: Optional[str] = None,
|
||
user_agent: Optional[str] = None,
|
||
) -> Dict:
|
||
"""创建 Token 并记录登录日志。
|
||
|
||
Args:
|
||
employee_id: 企微 UserID
|
||
name: 用户姓名
|
||
roles: 角色列表
|
||
corp_id: 企业ID
|
||
login_method: 登录方式 (oauth/qrcode/bind)
|
||
login_source: 登录来源 (h5/agent/admin)
|
||
department: 部门(可选)
|
||
avatar: 头像URL(可选)
|
||
ip_address: 客户端IP(可选)
|
||
user_agent: 客户端User-Agent(可选)
|
||
|
||
Returns:
|
||
Dict: 包含 token 和用户信息的字典
|
||
"""
|
||
# 创建 Token
|
||
token = await self.token_service.create_token(
|
||
employee_id=employee_id,
|
||
name=name,
|
||
roles=roles,
|
||
department=department,
|
||
avatar=avatar,
|
||
login_source=login_source,
|
||
)
|
||
|
||
# 记录登录日志
|
||
await self._record_login_log(
|
||
employee_id=employee_id,
|
||
corp_id=corp_id,
|
||
login_method=login_method,
|
||
login_source=login_source,
|
||
status="success",
|
||
ip_address=ip_address,
|
||
user_agent=user_agent,
|
||
)
|
||
|
||
# 获取当前角色
|
||
current_role = self.token_service._get_default_role(roles)
|
||
|
||
logger.info(
|
||
f"用户登录成功: employee_id={employee_id}, "
|
||
f"login_method={login_method}, login_source={login_source}"
|
||
)
|
||
|
||
return {
|
||
"token": token,
|
||
"employee_id": employee_id,
|
||
"name": name,
|
||
"avatar": avatar or "",
|
||
"department": department or "",
|
||
"roles": roles,
|
||
"current_role": current_role,
|
||
"login_source": login_source,
|
||
"expires_in": TOKEN_TTL_SECONDS,
|
||
}
|
||
|
||
async def verify_token(self, token: str) -> Optional[Dict]:
|
||
"""验证 Token 并返回用户信息。
|
||
|
||
Args:
|
||
token: Token 字符串
|
||
|
||
Returns:
|
||
Optional[Dict]: 用户信息,如果 Token 无效返回 None
|
||
"""
|
||
# 检查是否在黑名单中
|
||
is_blacklisted = await self._is_token_blacklisted(token)
|
||
if is_blacklisted:
|
||
logger.warning(f"Token 在黑名单中: {token[:10]}...")
|
||
return None
|
||
|
||
# 验证 Token
|
||
user_info = await self.token_service.get_user_info(token)
|
||
return user_info
|
||
|
||
async def logout(self, token: str) -> bool:
|
||
"""登出:将 Token 加入黑名单。
|
||
|
||
Args:
|
||
token: 要失效的 Token 字符串
|
||
|
||
Returns:
|
||
bool: 是否成功
|
||
"""
|
||
# 将 Token 加入黑名单
|
||
await self._add_to_blacklist(token)
|
||
|
||
# 使 Redis 中的 Token 失效
|
||
await self.token_service.invalidate_token(token)
|
||
|
||
logger.info(f"用户登出: token={token[:10]}...")
|
||
return True
|
||
|
||
async def switch_role(self, token: str, new_role: str) -> bool:
|
||
"""切换当前角色。
|
||
|
||
Args:
|
||
token: Token 字符串
|
||
new_role: 目标角色标识
|
||
|
||
Returns:
|
||
bool: 是否切换成功
|
||
"""
|
||
return await self.token_service.switch_role(token, new_role)
|
||
|
||
async def get_current_user(self, token: str) -> Optional[Dict]:
|
||
"""获取当前用户信息。
|
||
|
||
Args:
|
||
token: Token 字符串
|
||
|
||
Returns:
|
||
Optional[Dict]: 用户信息
|
||
"""
|
||
return await self.verify_token(token)
|
||
|
||
async def record_failed_login(
|
||
self,
|
||
corp_id: str,
|
||
login_method: str,
|
||
login_source: str,
|
||
fail_reason: str,
|
||
ip_address: Optional[str] = None,
|
||
user_agent: Optional[str] = None,
|
||
) -> None:
|
||
"""记录失败的登录尝试。
|
||
|
||
Args:
|
||
corp_id: 企业ID
|
||
login_method: 登录方式
|
||
login_source: 登录来源
|
||
fail_reason: 失败原因
|
||
ip_address: 客户端IP(可选)
|
||
user_agent: 客户端User-Agent(可选)
|
||
"""
|
||
await self._record_login_log(
|
||
employee_id=None,
|
||
corp_id=corp_id,
|
||
login_method=login_method,
|
||
login_source=login_source,
|
||
status="failed",
|
||
fail_reason=fail_reason,
|
||
ip_address=ip_address,
|
||
user_agent=user_agent,
|
||
)
|
||
|
||
logger.warning(
|
||
f"登录失败: login_method={login_method}, "
|
||
f"login_source={login_source}, reason={fail_reason}"
|
||
)
|
||
|
||
async def _record_login_log(
|
||
self,
|
||
employee_id: Optional[str],
|
||
corp_id: str,
|
||
login_method: str,
|
||
login_source: str,
|
||
status: str,
|
||
fail_reason: Optional[str] = None,
|
||
ip_address: Optional[str] = None,
|
||
user_agent: Optional[str] = None,
|
||
) -> None:
|
||
"""记录登录日志到数据库。
|
||
|
||
Args:
|
||
employee_id: 员工ID
|
||
corp_id: 企业ID
|
||
login_method: 登录方式
|
||
login_source: 登录来源
|
||
status: 登录状态
|
||
fail_reason: 失败原因
|
||
ip_address: 客户端IP
|
||
user_agent: 客户端User-Agent
|
||
"""
|
||
if not self.db:
|
||
logger.debug("未配置数据库会话,跳过登录日志记录")
|
||
return
|
||
|
||
try:
|
||
login_log = LoginLog(
|
||
employee_id=employee_id,
|
||
corp_id=corp_id,
|
||
login_method=login_method,
|
||
login_source=login_source,
|
||
ip_address=ip_address,
|
||
user_agent=user_agent,
|
||
status=status,
|
||
fail_reason=fail_reason,
|
||
)
|
||
self.db.add(login_log)
|
||
await self.db.commit()
|
||
logger.debug(f"登录日志已记录: employee_id={employee_id}, status={status}")
|
||
except Exception as e:
|
||
logger.error(f"记录登录日志失败: {e}")
|
||
await self.db.rollback()
|
||
|
||
async def _add_to_blacklist(self, token: str) -> None:
|
||
"""将 Token 加入黑名单。
|
||
|
||
Args:
|
||
token: Token 字符串
|
||
"""
|
||
try:
|
||
# 使用 Token 的 hash 作为 key,避免存储明文 Token
|
||
import hashlib
|
||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||
key = f"{TOKEN_BLACKLIST_PREFIX}{token_hash}"
|
||
|
||
# 设置黑名单过期时间为 Token 原始过期时间
|
||
await self.redis.setex(key, TOKEN_TTL_SECONDS, "1")
|
||
logger.debug(f"Token 已加入黑名单: {token[:10]}...")
|
||
except Exception as e:
|
||
logger.error(f"添加 Token 到黑名单失败: {e}")
|
||
|
||
async def _is_token_blacklisted(self, token: str) -> bool:
|
||
"""检查 Token 是否在黑名单中。
|
||
|
||
Args:
|
||
token: Token 字符串
|
||
|
||
Returns:
|
||
bool: 是否在黑名单中
|
||
"""
|
||
try:
|
||
import hashlib
|
||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||
key = f"{TOKEN_BLACKLIST_PREFIX}{token_hash}"
|
||
|
||
result = await self.redis.get(key)
|
||
return result is not None
|
||
except Exception as e:
|
||
logger.error(f"检查 Token 黑名单失败: {e}")
|
||
return False
|