feat(auth): support trusted device (#52)

New API to maintain sessions and devices:

- GET /api/private/admin/sessions
- DELETE /api/private/admin/sessions/{session_id}
- GET /api/private/admin/trusted-devices
- DELETE /api/private/admin/trusted-devices/{device_id}

Auth:

web clients request `/oauth/token` and `/api/v2/session/verify` with `X-UUID` header to save the client as trusted device.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
MingxuanGame
2025-10-03 11:26:43 +08:00
committed by GitHub
parent f34ed53a55
commit 40670c094b
28 changed files with 897 additions and 1456 deletions

View File

@@ -6,11 +6,15 @@ from datetime import UTC, datetime
import functools
import inspect
from io import BytesIO
from typing import Any, ParamSpec, TypeVar
import re
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
from fastapi import HTTPException
from PIL import Image
if TYPE_CHECKING:
from app.models.model import UserAgentInfo
def unix_timestamp_to_windows(timestamp: int) -> int:
"""Convert a Unix timestamp to a Windows timestamp."""
@@ -154,81 +158,79 @@ def check_image(content: bytes, size: int, width: int, height: int) -> str:
raise HTTPException(status_code=400, detail=f"Error processing image: {e}")
def simplify_user_agent(user_agent: str | None, max_length: int = 200) -> str | None:
"""
简化 User-Agent 字符串,只保留 osu! 和关键设备系统信息浏览器
def extract_user_agent(user_agent: str | None) -> "UserAgentInfo":
from app.models.model import UserAgentInfo
Args:
user_agent: 原始 User-Agent 字符串
max_length: 最大长度限制
raw_ua = user_agent or ""
ua = raw_ua.strip()
lower_ua = ua.lower()
Returns:
简化后的 User-Agent 字符串,或 None
"""
import re
info = UserAgentInfo(raw_ua=raw_ua)
if not user_agent:
return None
if not ua:
return info
# 如果长度在限制内,直接返回
if len(user_agent) <= max_length:
return user_agent
client_identifiers = ("osu!", "osu!lazer", "osu-framework")
if any(identifier in lower_ua for identifier in client_identifiers):
info.browser = "osu!"
info.is_client = True
return info
# 提取操作系统信息
os_info = ""
os_patterns = [
r"(Windows[^;)]*)",
r"(Mac OS[^;)]*)",
r"(Linux[^;)]*)",
r"(Android[^;)]*)",
r"(iOS[^;)]*)",
r"(iPhone[^;)]*)",
r"(iPad[^;)]*)",
]
browser_patterns: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"OPR/(\d+(?:\.\d+)*)"), "Opera"),
(re.compile(r"Edg/(\d+(?:\.\d+)*)"), "Edge"),
(re.compile(r"Chrome/(\d+(?:\.\d+)*)"), "Chrome"),
(re.compile(r"Firefox/(\d+(?:\.\d+)*)"), "Firefox"),
(re.compile(r"Version/(\d+(?:\.\d+)*).*Safari"), "Safari"),
(re.compile(r"Safari/(\d+(?:\.\d+)*)"), "Safari"),
(re.compile(r"MSIE (\d+(?:\.\d+)*)"), "Internet Explorer"),
(re.compile(r"Trident/.*rv:(\d+(?:\.\d+)*)"), "Internet Explorer"),
)
for pattern in os_patterns:
match = re.search(pattern, user_agent, re.IGNORECASE)
for pattern, name in browser_patterns:
match = pattern.search(ua)
if match:
os_info = match.group(1).strip()
info.browser = name
info.version = match.group(1)
break
# 提取浏览器信息
browser_info = ""
browser_patterns = [
r"(osu![^)]*)", # osu! 客户端
r"(Chrome/[\d.]+)",
r"(Firefox/[\d.]+)",
r"(Safari/[\d.]+)",
r"(Edge/[\d.]+)",
r"(Opera/[\d.]+)",
]
os_patterns: tuple[tuple[re.Pattern[str], str], ...] = (
(re.compile(r"windows nt 10"), "Windows 10"),
(re.compile(r"windows nt 6\.3"), "Windows 8.1"),
(re.compile(r"windows nt 6\.2"), "Windows 8"),
(re.compile(r"windows nt 6\.1"), "Windows 7"),
(re.compile(r"windows nt 6\.0"), "Windows Vista"),
(re.compile(r"windows nt 5\.1"), "Windows XP"),
(re.compile(r"mac os x"), "macOS"),
(re.compile(r"iphone os"), "iOS"),
(re.compile(r"ipad;"), "iPadOS"),
(re.compile(r"android"), "Android"),
(re.compile(r"linux"), "Linux"),
)
for pattern in browser_patterns:
match = re.search(pattern, user_agent, re.IGNORECASE)
if match:
browser_info = match.group(1).strip()
# 如果找到了 osu! 客户端,优先使用
if "osu!" in browser_info.lower():
break
for pattern, name in os_patterns:
if pattern.search(lower_ua):
info.os = name
break
# 构建简化的 User-Agent
parts = []
if os_info:
parts.append(os_info)
if browser_info:
parts.append(browser_info)
info.is_mobile = any(keyword in lower_ua for keyword in ("mobile", "iphone", "android", "ipod"))
info.is_tablet = any(keyword in lower_ua for keyword in ("ipad", "tablet"))
# Only classify as PC if not mobile or tablet
if (
not info.is_mobile
and not info.is_tablet
and any(keyword in lower_ua for keyword in ("windows", "macintosh", "linux", "x11"))
):
info.is_pc = True
if parts:
simplified = "; ".join(parts)
else:
# 如果没有识别到关键信息,截断原始字符串
simplified = user_agent[: max_length - 3] + "..."
if info.is_tablet:
info.platform = "tablet"
elif info.is_mobile:
info.platform = "mobile"
elif info.is_pc:
info.platform = "pc"
# 确保不超过最大长度
if len(simplified) > max_length:
simplified = simplified[: max_length - 3] + "..."
return simplified
return info
# https://github.com/encode/starlette/blob/master/starlette/_utils.py