""" 客户端检测服务 用于识别不同类型的 osu! 客户端和设备 """ from __future__ import annotations from dataclasses import dataclass import hashlib import re from typing import ClassVar, Literal from app.log import logger @dataclass class ClientInfo: """客户端信息""" client_type: Literal["osu_stable", "osu_lazer", "osu_web", "mobile", "unknown"] platform: str | None = None version: str | None = None device_fingerprint: str | None = None is_trusted_client: bool = False class ClientDetectionService: """客户端检测服务""" # osu! 客户端的 User-Agent 模式 OSU_CLIENT_PATTERNS: ClassVar[dict[str, list[str]]] = { "osu_stable": [ r"osu!/(\d+(?:\.\d+)*)", # osu!/20241001 r"osu!", # 简单匹配 ], "osu_lazer": [ r"osu-lazer/(\d+(?:\.\d+)*)", # osu-lazer/2024.1009.0 r"osu!lazer/(\d+(?:\.\d+)*)", # osu!lazer/2024.1009.0 ], "osu_web": [ r"Mozilla.*osu\.ppy\.sh", # 网页客户端 ], "mobile": [ r"osu!.*mobile", r"osu.*Mobile", r"Mobile.*osu", ], } # 受信任的客户端类型(不需要频繁验证) TRUSTED_CLIENT_TYPES: ClassVar[set[str]] = {"osu_stable", "osu_lazer"} @staticmethod def detect_client(user_agent: str | None, client_id: int | None = None) -> ClientInfo: """ 检测客户端类型和信息 Args: user_agent: 用户代理字符串 client_id: OAuth 客户端 ID Returns: ClientInfo: 客户端信息 """ from app.config import settings # 导入在函数内部避免循环导入 if not user_agent: return ClientInfo(client_type="unknown") # 优先通过 client_id 判断客户端类型 if client_id is not None: if client_id == settings.osu_client_id: # osu! stable 客户端 return ClientInfo( client_type="osu_stable", platform=ClientDetectionService._extract_platform(user_agent), device_fingerprint=ClientDetectionService._generate_device_fingerprint(user_agent), is_trusted_client=True, ) elif client_id == settings.osu_web_client_id: # 检查 User-Agent 是否表明这是 Lazer 客户端 if user_agent and user_agent.strip() == "osu!": # Lazer 客户端使用 web client_id 但发送简单的 "osu!" User-Agent return ClientInfo( client_type="osu_lazer", platform=ClientDetectionService._extract_platform(user_agent), device_fingerprint=ClientDetectionService._generate_device_fingerprint(user_agent), is_trusted_client=True, ) else: # 真正的 web 客户端 return ClientInfo( client_type="osu_web", platform=ClientDetectionService._extract_platform(user_agent), device_fingerprint=ClientDetectionService._generate_device_fingerprint(user_agent), is_trusted_client=False, ) # 回退到基于 User-Agent 的检测 for client_type_str, patterns in ClientDetectionService.OSU_CLIENT_PATTERNS.items(): for pattern in patterns: match = re.search(pattern, user_agent, re.IGNORECASE) if match: version = match.group(1) if match.groups() else None platform = ClientDetectionService._extract_platform(user_agent) # 确保 client_type 是正确的 Literal 类型 client_type: Literal["osu_stable", "osu_lazer", "osu_web", "mobile", "unknown"] = client_type_str # type: ignore return ClientInfo( client_type=client_type, platform=platform, version=version, device_fingerprint=ClientDetectionService._generate_device_fingerprint(user_agent), is_trusted_client=client_type in ClientDetectionService.TRUSTED_CLIENT_TYPES, ) # 检测常见浏览器 if any(browser in user_agent.lower() for browser in ["chrome", "firefox", "safari", "edge"]): return ClientInfo( client_type="osu_web", platform=ClientDetectionService._extract_platform(user_agent), device_fingerprint=ClientDetectionService._generate_device_fingerprint(user_agent), is_trusted_client=False, ) return ClientInfo( client_type="unknown", device_fingerprint=ClientDetectionService._generate_device_fingerprint(user_agent), is_trusted_client=False, ) @staticmethod def _extract_platform(user_agent: str) -> str | None: """从 User-Agent 中提取平台信息""" platforms = { "windows": ["windows", "win32", "win64"], "macos": ["macintosh", "mac os", "darwin"], "linux": ["linux", "ubuntu", "debian"], "android": ["android"], "ios": ["iphone", "ipad", "ios"], } user_agent_lower = user_agent.lower() for platform, keywords in platforms.items(): if any(keyword in user_agent_lower for keyword in keywords): return platform return None @staticmethod def _generate_device_fingerprint(user_agent: str) -> str: """生成设备指纹""" # 使用 User-Agent 的哈希值作为简单的设备指纹 # 在实际应用中可以结合更多信息(IP、屏幕分辨率等) return hashlib.sha256(user_agent.encode()).hexdigest()[:16] @staticmethod def should_skip_email_verification( client_info: ClientInfo, is_new_location: bool, user_id: int, ) -> bool: """ 判断是否应该跳过邮件验证 Args: client_info: 客户端信息 is_new_location: 是否为新位置登录 user_id: 用户 ID Returns: bool: 是否应该跳过邮件验证 """ # 受信任的客户端类型可以减少验证频率 if client_info.is_trusted_client: logger.info( f"[Client Detection] Trusted client {client_info.client_type} for user {user_id}, " f"reducing verification requirements" ) return True # 如果不是新位置,跳过验证 if not is_new_location: return True return False @staticmethod def get_verification_cooldown(client_info: ClientInfo) -> int: """ 获取验证冷却时间(秒) Args: client_info: 客户端信息 Returns: int: 冷却时间(秒) """ # 受信任的客户端有更长的冷却时间 if client_info.is_trusted_client: return 3600 # 1小时 # 网页客户端较短的冷却时间 if client_info.client_type == "osu_web": return 1800 # 30分钟 # 未知客户端最短冷却时间 return 900 # 15分钟 @staticmethod def format_client_display_name(client_info: ClientInfo) -> str: """格式化客户端显示名称""" display_names = { "osu_stable": "osu! (stable)", "osu_lazer": "osu!(lazer)", "osu_web": "osu! web", "mobile": "osu! mobile", "unknown": "Unknown client", } base_name = display_names.get(client_info.client_type, "Unknown client") if client_info.version: base_name += f" v{client_info.version}" if client_info.platform: base_name += f" ({client_info.platform})" return base_name