442 lines
18 KiB
Python
442 lines
18 KiB
Python
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
|