Files
LoveACE-EndF/loveace/service/remote/aufe/__init__.py
Sibuxiangx bbc86b8330 ⚒️ 重大重构 LoveACE V2
引入了 mongodb
对数据库进行了一定程度的数据加密
性能改善
代码简化
统一错误模型和响应
使用 apifox 作为文档
2025-11-20 20:44:25 +08:00

442 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import asyncio
import binascii
import logging
import re
import uuid
from asyncio import Task
from base64 import b64encode
from datetime import datetime
from typing import Dict, Type, TypeVar
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import padding as symmetric_padding
from cryptography.hazmat.primitives.asymmetric import padding, rsa
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from httpx import AsyncClient, RequestError
from httpx._types import HeaderTypes
from loveace.config.logger import LoggerMixin
from loveace.config.manager import config_manager
from loveace.service.model.service import Service
from loveace.service.remote.aufe.model.status import (
ECCheckStatus,
ECLoginStatus,
UAAPLoginStatus,
)
# 设置 HTTPX 日志级别为 CRITICAL
if not config_manager.get_settings().app.debug:
logging.getLogger("httpx").setLevel(logging.CRITICAL)
class SubClient:
async def aclose(self): ...
T_SubClient = TypeVar("T_SubClient", bound=SubClient)
class AUFEConnection:
userid: str
ec_password: str
password: str
_client: AsyncClient
twf_id: str
last_check: datetime
ec_logged: bool = False
uaap_logged: bool = False
trace_id: str
timeout: int = 30
_sub_clients: Dict[str, SubClient] = {}
def __init__(self, userid: str, ec_password: str, password: str):
self.userid = userid
self.ec_password = ec_password
self.password = password
self.last_check = datetime.now()
self.trace_id = str(uuid.uuid4().hex)
self.timeout = config_manager.get_settings().aufe.default_timeout
self.logger.info(
f"创建AUFE连接用户ID: {self.userid}, Trace ID: {self.trace_id},超时: {self.timeout}s"
)
@property
def logger(self) -> LoggerMixin:
return LoggerMixin(user_id=self.userid, trace_id=self.trace_id)
def start_client(self):
self._client = AsyncClient()
def health_checkpoint(self):
self.last_check = datetime.now()
async def health_check(self) -> bool:
delta = datetime.now() - self.last_check
self.logger.info(
f"AUFE连接健康检查距离上次检查时间: {delta.total_seconds()}"
)
if delta.total_seconds() > 300: # 5分钟未检查则视为不健康
self.logger.warning("AUFE连接不健康已超过5分钟未检查将自动关闭")
return False
if self._client.is_closed:
self.logger.warning("AUFE连接已关闭")
return False
check_uaap = await self.check_uaap_login_status()
if not check_uaap.logged_in:
self.logger.warning("UAAP登录状态无效可能需要重新登录")
return False
check_ec = await self.check_ec_login_status()
if not check_ec.logged_in:
self.logger.warning("EC登录状态无效可能需要重新登录")
return False
return True
def inject_subclient(self, name: str, sub_client: SubClient):
"""
注入子客户端
该方法用于将子客户端的关闭方法绑定到主客户端上
以便在关闭主客户端时也能关闭子客户端
Args:
sub_client (SubClient): 子客户端实例,必须实现 aclose 方法
"""
self.logger.info(f"注入子客户端 {name},类型: {type(sub_client).__name__}")
self._sub_clients[name] = sub_client
def get_subclient(
self, name: str, type_sub_client: Type[T_SubClient]
) -> T_SubClient | None:
"""
获取已注入的子客户端
Args:
name (str): 子客户端名称
type_sub_client (Type[T_SubClient]): 子客户端类型,用于类型检查
Returns:
T_SubClient: 子客户端实例
Raises:
ValueError: 如果子客户端不存在或类型不匹配
"""
if name not in self._sub_clients:
return None
sub_client = self._sub_clients[name]
if not isinstance(sub_client, type_sub_client):
return None
return sub_client
async def close_client(self):
await self._client.aclose()
for sub_client in self._sub_clients.values():
self.logger.info(f"正在关闭子客户端 {type(sub_client).__name__}")
await sub_client.aclose()
self._sub_clients.clear()
async def ec_login(self) -> ECLoginStatus:
"""
使用用户名和密码登录AUFE
"""
try:
# 初始请求获取认证参数
response = await self._client.get(
f"{config_manager.get_settings().aufe.server_url}/por/login_auth.csp?apiversion=1"
)
if twfid_g := re.search(r"<TwfID>(.*)</TwfID>", response.text):
self.twf_id = twfid_g.group(1)
else:
self.logger.error("错误: 响应中未找到TwfID。")
return ECLoginStatus(fail_not_found_twfid=True)
self.logger.info(f"Twf Id: {self.twf_id[:5]}******")
if rsa_key_g := re.search(
r"<RSA_ENCRYPT_KEY>(.*)</RSA_ENCRYPT_KEY>", response.text
):
rsa_key = rsa_key_g.group(1)
else:
self.logger.error("错误: 响应中未找到RSA_ENCRYPT_KEY。")
return ECLoginStatus(fail_not_found_rsa_key=True)
self.logger.info(f"RSA密钥: {rsa_key[:5]}******")
if rsa_exp_match := re.search(
r"<RSA_ENCRYPT_EXP>(.*)</RSA_ENCRYPT_EXP>", response.text
):
rsa_exp = rsa_exp_match.group(1)
else:
self.logger.error("错误: 响应中未找到RSA_ENCRYPT_EXP。")
return ECLoginStatus(fail_not_found_rsa_exp=True)
self.logger.info(f"RSA指数: {rsa_exp[:5]}******")
if csrf_match := re.search(
r"<CSRF_RAND_CODE>(.*)</CSRF_RAND_CODE>", response.text
):
csrf_code = csrf_match.group(1)
password_to_encrypt = self.password + "_" + csrf_code
else:
self.logger.error("错误: 响应中未找到CSRF_RAND_CODE。")
return ECLoginStatus(fail_not_found_csrf_code=True)
self.logger.info(f"CSRF代码: {csrf_code[:5]}******")
# 创建RSA密钥并加密密码
rsa_exp_int = int(rsa_exp)
rsa_modulus = int(rsa_key, 16)
public_numbers = rsa.RSAPublicNumbers(e=rsa_exp_int, n=rsa_modulus)
public_key = public_numbers.public_key(default_backend())
encrypted_password = public_key.encrypt(
password_to_encrypt.encode("utf-8"), padding.PKCS1v15()
)
encrypted_password_hex = binascii.hexlify(encrypted_password).decode(
"ascii"
)
self.logger.info(f"加密后密码: {encrypted_password_hex[:5]}******")
self.logger.info("开始执行登录请求")
login_response = await self._client.post(
f"{config_manager.get_settings().aufe.server_url}/por/login_psw.csp?anti_replay=1&encrypt=1&type=cs",
data={
"svpn_rand_code": "",
"mitm": "",
"svpn_req_randcode": csrf_code,
"svpn_name": self.userid,
"svpn_password": encrypted_password_hex,
},
cookies={"TWFID": self.twf_id},
timeout=10000,
)
self.logger.info(f"登录响应: {login_response.text[:10]}******")
# 检查登录结果
if "<Result>1</Result>" in login_response.text:
self.logger.info("登录成功")
self._client.cookies.set("TWFID", self.twf_id)
self.ec_logged = True
return ECLoginStatus(success=True)
elif "Invalid username or password!" in login_response.text:
self.logger.error("登录失败: 用户名或密码错误")
return ECLoginStatus(fail_invalid_credentials=True)
elif "[CDATA[maybe attacked]]" in login_response.text or "CAPTCHA required" in login_response.text:
self.logger.error("登录失败: 可能受到攻击或需要验证码")
return ECLoginStatus(fail_maybe_attacked=True)
else:
self.logger.error(f"登录失败: {login_response.text}")
return ECLoginStatus(fail_unknown_error=True)
except RequestError as e:
self.logger.error(f"登录连接错误: {str(e)}")
return ECLoginStatus(fail_network_error=True)
except Exception as e:
self.logger.error(f"登录失败: {e}")
return ECLoginStatus(fail_unknown_error=True)
async def check_ec_login_status(self) -> ECCheckStatus:
"""
检查当前登录状态
"""
if not self.ec_logged:
return ECCheckStatus(logged_in=False)
try:
response = await self._client.get(
config_manager.get_settings().aufe.ec_check_url,
)
if response.status_code == 200:
self.logger.info("登录状态有效")
return ECCheckStatus(logged_in=True)
else:
self.logger.warning("登录状态无效,可能需要重新登录")
return ECCheckStatus(logged_in=False)
except RequestError as e:
self.logger.error(f"检查登录状态连接错误: {str(e)}")
return ECCheckStatus(fail_network_error=True)
except Exception as e:
self.logger.error(f"检查登录状态失败: {e}")
return ECCheckStatus(fail_unknown_error=True)
async def uaap_login(self) -> UAAPLoginStatus:
"""
使用用户名和密码登录UAAP
"""
try:
# 初始请求获取登录页面
response = await self._client.get(
config_manager.get_settings().aufe.uaap_login_url
)
if lt_match := re.search(r'name="lt" value="(.*?)"', response.text):
lt_value = lt_match.group(1)
else:
self.logger.error("错误: 登录页面中未找到lt参数。")
return UAAPLoginStatus(fail_not_found_lt=True)
self.logger.info(f"lt参数: {lt_value[:5]}******")
if execution_match := re.search(
r'name="execution" value="(.*?)"', response.text
):
execution_value = execution_match.group(1)
else:
self.logger.error("错误: 登录页面中未找到execution参数。")
return UAAPLoginStatus(fail_not_found_execution=True)
self.logger.info(f"execution参数: {execution_value[:5]}******")
# 处理密钥 - CryptoJS使用的是8字节密钥
key_bytes = lt_value.encode("utf-8")[:8]
# 如果密钥不足8字节则用0填充
if len(key_bytes) < 8:
key_bytes = key_bytes + b"\0" * (8 - len(key_bytes))
# 处理明文数据 - 确保是字节类型
password_bytes = self.password.encode("utf-8")
# 使用PKCS7填充
padder = symmetric_padding.PKCS7(64).padder()
padded_data = padder.update(password_bytes) + padder.finalize()
# 创建DES加密器 - ECB模式
cipher = Cipher(
algorithms.TripleDES(key_bytes), modes.ECB(), backend=default_backend()
)
encryptor = cipher.encryptor()
# 加密数据
encrypted = encryptor.update(padded_data) + encryptor.finalize()
# 提交登录表单
login_response = await self._client.post(
config_manager.get_settings().aufe.uaap_login_url,
data={
"username": self.userid,
"password": b64encode(encrypted).decode("utf-8"),
"lt": lt_value,
"execution": execution_value,
"_eventId": "submit",
"submit": "LOGIN",
},
timeout=10000,
)
# 检查登录结果
if (
login_response.status_code == 302
and "Location" in login_response.headers
):
redirect_url = login_response.headers["Location"]
if redirect_url.startswith(
config_manager.get_settings().aufe.uaap_check_url
):
self.logger.info("UAAP登录成功")
self.uaap_logged = True
return UAAPLoginStatus(success=True)
elif "Invalid username or password" in login_response.text:
self.logger.error("UAAP登录失败: 用户名或密码错误")
return UAAPLoginStatus(fail_invalid_credentials=True)
else:
self.logger.error(f"UAAP登录失败: {login_response.text}")
return UAAPLoginStatus(fail_unknown_error=True)
return UAAPLoginStatus(fail_unknown_error=True)
except RequestError as e:
self.logger.error(f"UAAP登录连接错误: {str(e)}")
return UAAPLoginStatus(fail_network_error=True)
except Exception as e:
self.logger.error(f"UAAP登录失败: {e}")
return UAAPLoginStatus(fail_unknown_error=True)
async def check_uaap_login_status(self) -> ECCheckStatus:
"""
检查当前UAAP登录状态
"""
return ECCheckStatus(logged_in=self.uaap_logged)
@property
def client(self) -> AsyncClient:
"""
获取HTTP客户端实例
注意: 此客户端只适用于教务系统,其他系统请查看具体 Service 实现
"""
self.health_checkpoint()
return self._client
@property
def empty_client(self, headers: HeaderTypes | None = None) -> AsyncClient:
"""
获取一个新的空白HTTP客户端实例用于子系统构建请求
"""
self.health_checkpoint()
return AsyncClient(headers=headers)
class AUFEService(Service):
"""
AUFE服务类
该类用于管理多个AUFE连接实例提供获取或创建连接的功能
并定期清理不健康的连接
"""
sessions: dict[str, AUFEConnection] = {}
logger: LoggerMixin
task: Task
def __init__(self):
# AUFEService 的 logger 不需要 trace_id因为它是服务级别的日志
self.logger = LoggerMixin(user_id="AUFEService", trace_id="")
async def get_or_create_connection(
self, userid: str, ec_password: str, password: str
) -> AUFEConnection:
"""
获取或创建AUFE连接
该方法会检查现有连接的健康状态,如果不健康则重新创建连接
注意,获取实例后请尽快操作登录,否则可能因为连接不健康而需要重新创建
Args:
userid (str): 用户ID
ec_password (str): EC系统密码
password (str): UAAP密码
Returns:
AUFEConnection: AUFE连接实例
"""
if userid not in self.sessions:
self.sessions[userid] = AUFEConnection(
userid=userid, ec_password=ec_password, password=password
)
self.sessions[userid].start_client()
return self.sessions[userid]
return self.sessions[userid]
async def _loop_cleanup(self):
"""
清理不健康的AUFE连接
"""
to_remove = []
for userid, connection in self.sessions.items():
if not await connection.health_check():
self.logger.info(f"用户 {userid} 的AUFE连接不健康正在关闭连接")
await connection.close_client()
self.logger.info(f"用户 {userid} 的AUFE连接已关闭正在移除连接")
to_remove.append(userid)
self.logger.info(f"用户 {userid} 的AUFE连接已移除")
for userid in to_remove:
del self.sessions[userid]
async def loop_cleanup_task(self):
"""
定期清理不健康的AUFE连接 ASYNC TASK
该任务每5分钟运行一次检查所有连接的健康状态并清理不健康的连接
该任务应在应用启动时运行,并在应用关闭时取消
"""
while True:
await asyncio.sleep(60) # 每分钟检查一次
await self._loop_cleanup()
async def initialize(self):
"""
初始化AUFE服务
该方法在应用启动时调用,用于启动清理任务
"""
self.logger.info("初始化AUFE服务")
self.task = asyncio.create_task(self.loop_cleanup_task())
self.logger.info("AUFE服务初始化完成")
async def shutdown(self):
"""
关闭AUFE服务
该方法在应用关闭时调用,用于关闭所有连接
"""
self.logger.info("关闭AUFE服务")
for userid, connection in self.sessions.items():
self.logger.info(f"正在关闭用户 {userid} 的AUFE连接")
await connection.close_client()
self.logger.info(f"用户 {userid} 的AUFE连接已关闭")
self.sessions.clear()
self.task.cancel()
try:
await self.task
except asyncio.CancelledError:
self.logger.info("AUFE服务已关闭")
pass