from __future__ import annotations import re from dataclasses import dataclass from datetime import datetime, timedelta, timezone from typing import Any, Mapping from fastapi import HTTPException, Request, status from sqlalchemy import and_, or_ from sqlalchemy.orm import Session from src.core.logger import logger from src.models.database import AuditEventType, User, UserSession from src.services.system.audit import AuditService SESSION_TOUCH_INTERVAL_SECONDS = 300 CLIENT_DEVICE_ID_HEADER = "X-Client-Device-Id" MAX_SESSIONS_PER_USER = 20 TERMINAL_SESSION_RETENTION_DAYS = 30 _DEVICE_ID_PATTERN = re.compile(r"^[a-zA-Z0-9\-_]{1,128}$") def _strip_hint(value: str | None) -> str | None: if value is None: return None cleaned = value.strip().strip('"').strip() return cleaned or None def _parse_browser(user_agent: str) -> tuple[str | None, str | None]: patterns = [ ("Edge", r"Edg/([\d.]+)"), ("Opera", r"OPR/([\d.]+)"), ("Chrome", r"Chrome/([\d.]+)"), ("Firefox", r"Firefox/([\d.]+)"), ("Safari", r"Version/([\d.]+).*Safari"), ] for name, pattern in patterns: match = re.search(pattern, user_agent) if match: return name, match.group(1) return None, None def _parse_os( user_agent: str, client_hints: Mapping[str, str | None] ) -> tuple[str | None, str | None]: platform = _strip_hint(client_hints.get("sec-ch-ua-platform")) platform_version = _strip_hint(client_hints.get("sec-ch-ua-platform-version")) if platform: return platform, platform_version patterns = [ ("Windows", r"Windows NT ([\d.]+)"), ("macOS", r"Mac OS X ([\d_]+)"), ("iOS", r"(?:iPhone OS|CPU OS) ([\d_]+)"), ("Android", r"Android ([\d.]+)"), ("Linux", r"Linux"), ] for name, pattern in patterns: match = re.search(pattern, user_agent) if match: version = match.group(1).replace("_", ".") if match.lastindex else None return name, version return None, None def _parse_device_type(user_agent: str, client_hints: Mapping[str, str | None]) -> str: ch_mobile = _strip_hint(client_hints.get("sec-ch-ua-mobile")) ua_lower = user_agent.lower() if ch_mobile == "?1": return "mobile" if "ipad" in ua_lower or "tablet" in ua_lower: return "tablet" if any(marker in ua_lower for marker in ("iphone", "android", "mobile")): return "mobile" if any(marker in ua_lower for marker in ("macintosh", "windows", "linux", "x11")): return "desktop" return "unknown" def _build_device_label( *, browser_name: str | None, os_name: str | None, device_model: str | None, device_type: str, ) -> str: if device_model: return device_model if browser_name and os_name: return f"{browser_name} / {os_name}" if browser_name: return browser_name if os_name: return os_name if device_type == "mobile": return "移动设备" if device_type == "tablet": return "平板设备" if device_type == "desktop": return "桌面设备" return "未知设备" @dataclass(frozen=True) class SessionClientContext: client_device_id: str device_label: str device_type: str browser_name: str | None browser_version: str | None os_name: str | None os_version: str | None device_model: str | None client_hints: dict[str, str | None] ip_address: str | None user_agent: str class SessionService: """用户设备会话服务。""" @staticmethod def _utcnow() -> datetime: return datetime.now(timezone.utc) @staticmethod def _active_sessions_query(db: Session, *, user_id: str) -> Any: return db.query(UserSession).filter( UserSession.user_id == user_id, UserSession.revoked_at.is_(None), UserSession.expires_at > SessionService._utcnow(), ) @staticmethod def cleanup_user_sessions(db: Session, *, user_id: str) -> int: """清理该用户已进入终态且超过保留期的会话记录。""" cutoff = SessionService._utcnow() - timedelta(days=TERMINAL_SESSION_RETENTION_DAYS) deleted = ( db.query(UserSession) .filter(UserSession.user_id == user_id) .filter( or_( UserSession.expires_at < cutoff, and_(UserSession.revoked_at.is_not(None), UserSession.revoked_at < cutoff), ) ) .delete(synchronize_session=False) ) if deleted: db.flush() return int(deleted or 0) @staticmethod def _normalize_device_id(raw: str) -> str | None: """校验并规范化 device id,非法值返回 None。""" cleaned = raw.strip()[:128] if not cleaned: return None if _DEVICE_ID_PATTERN.match(cleaned): return cleaned # 不符合格式的视为无效 return None @staticmethod def extract_client_device_id(request: Request) -> str: header_value = request.headers.get(CLIENT_DEVICE_ID_HEADER) if header_value: normalized = SessionService._normalize_device_id(header_value) if normalized: return normalized query_value = request.query_params.get("client_device_id") if query_value: normalized = SessionService._normalize_device_id(query_value) if normalized: return normalized raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="缺少或无效的设备标识", ) @staticmethod def build_client_context( *, client_device_id: str, client_ip: str | None, user_agent: str, headers: Mapping[str, str], ) -> SessionClientContext: normalized_headers = {str(key).lower(): value for key, value in headers.items()} client_hints = { "sec-ch-ua": normalized_headers.get("sec-ch-ua"), "sec-ch-ua-platform": normalized_headers.get("sec-ch-ua-platform"), "sec-ch-ua-platform-version": normalized_headers.get("sec-ch-ua-platform-version"), "sec-ch-ua-model": normalized_headers.get("sec-ch-ua-model"), "sec-ch-ua-mobile": normalized_headers.get("sec-ch-ua-mobile"), } browser_name, browser_version = _parse_browser(user_agent) os_name, os_version = _parse_os(user_agent, client_hints) device_model = _strip_hint(client_hints.get("sec-ch-ua-model")) device_type = _parse_device_type(user_agent, client_hints) device_label = _build_device_label( browser_name=browser_name, os_name=os_name, device_model=device_model, device_type=device_type, ) return SessionClientContext( client_device_id=client_device_id, device_label=device_label, device_type=device_type, browser_name=browser_name, browser_version=browser_version, os_name=os_name, os_version=os_version, device_model=device_model, client_hints=client_hints, ip_address=client_ip, user_agent=user_agent, ) @staticmethod def get_active_session(db: Session, session_id: str, user_id: str) -> UserSession | None: session = ( db.query(UserSession) .filter(UserSession.id == session_id, UserSession.user_id == user_id) .first() ) if not session: return None if session.is_revoked or session.is_expired: return None return session @staticmethod def assert_session_device_matches(session: UserSession, client_device_id: str) -> None: if session.client_device_id != client_device_id: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="设备标识与登录会话不匹配", ) @staticmethod def create_session( db: Session, *, user: User, session_id: str, refresh_token: str, expires_at: datetime, client: SessionClientContext, revoke_existing_same_device: bool = True, ) -> UserSession: now = SessionService._utcnow() SessionService.cleanup_user_sessions(db, user_id=user.id) if revoke_existing_same_device: existing_sessions = ( SessionService._active_sessions_query(db, user_id=user.id) .filter(UserSession.client_device_id == client.client_device_id) .all() ) for existing in existing_sessions: existing.revoked_at = now existing.revoke_reason = "replaced_by_new_login" existing.updated_at = now # 如果活跃会话数超出限制,淘汰最旧的会话 active_count = SessionService._active_sessions_query(db, user_id=user.id).count() if active_count >= MAX_SESSIONS_PER_USER: oldest_sessions = ( SessionService._active_sessions_query(db, user_id=user.id) .order_by(UserSession.last_seen_at.asc()) .limit(active_count - MAX_SESSIONS_PER_USER + 1) .all() ) for old_session in oldest_sessions: old_session.revoked_at = now old_session.revoke_reason = "session_limit_exceeded" old_session.updated_at = now session = UserSession( id=session_id, user_id=user.id, client_device_id=client.client_device_id, device_label=client.device_label, device_type=client.device_type, browser_name=client.browser_name, browser_version=client.browser_version, os_name=client.os_name, os_version=client.os_version, device_model=client.device_model, ip_address=client.ip_address, user_agent=client.user_agent[:1000], client_hints=client.client_hints, last_seen_at=now, expires_at=expires_at, revoked_at=None, revoke_reason=None, ) session.set_refresh_token(refresh_token) db.add(session) db.flush() return session @staticmethod def rotate_refresh_token( session: UserSession, *, refresh_token: str, expires_at: datetime, client_ip: str | None, user_agent: str, ) -> None: session.set_refresh_token(refresh_token) session.expires_at = expires_at session.last_seen_at = datetime.now(timezone.utc) if client_ip: session.ip_address = client_ip if user_agent: session.user_agent = user_agent[:1000] @staticmethod def touch_session( session: UserSession, *, client_ip: str | None, user_agent: str, ) -> bool: now = datetime.now(timezone.utc) last_seen_at = session.last_seen_at if last_seen_at.tzinfo is None: last_seen_at = last_seen_at.replace(tzinfo=timezone.utc) if (now - last_seen_at).total_seconds() < SESSION_TOUCH_INTERVAL_SECONDS: return False session.last_seen_at = now if client_ip: session.ip_address = client_ip if user_agent: session.user_agent = user_agent[:1000] return True @staticmethod def revoke_session( db: Session, *, session: UserSession, reason: str, audit_user_id: str | None = None, ip_address: str | None = None, user_agent: str | None = None, ) -> None: if session.revoked_at is not None: return now = datetime.now(timezone.utc) session.revoked_at = now session.revoke_reason = reason[:100] session.updated_at = now if reason == "refresh_token_reused": AuditService.log_event( db=db, event_type=AuditEventType.SUSPICIOUS_ACTIVITY, description="Detected refresh token reuse; session revoked", user_id=audit_user_id or session.user_id, ip_address=ip_address, user_agent=user_agent, metadata={ "session_id": session.id, "client_device_id": session.client_device_id, "reason": reason, }, ) @staticmethod def revoke_all_user_sessions( db: Session, *, user_id: str, reason: str, exclude_session_id: str | None = None, ) -> int: now = SessionService._utcnow() SessionService.cleanup_user_sessions(db, user_id=user_id) sessions = SessionService._active_sessions_query(db, user_id=user_id).all() count = 0 for session in sessions: if exclude_session_id and session.id == exclude_session_id: continue session.revoked_at = now session.revoke_reason = reason[:100] session.updated_at = now count += 1 return count @staticmethod def list_user_sessions(db: Session, *, user_id: str) -> list[UserSession]: SessionService.cleanup_user_sessions(db, user_id=user_id) return ( SessionService._active_sessions_query(db, user_id=user_id) .order_by(UserSession.last_seen_at.desc(), UserSession.created_at.desc()) .all() ) @staticmethod def update_session_label(session: UserSession, device_label: str) -> None: normalized = device_label.strip() if not normalized: raise ValueError("设备名称不能为空") session.device_label = normalized[:120] session.updated_at = datetime.now(timezone.utc) @staticmethod def get_session_for_user( db: Session, *, user_id: str, session_id: str, lock_for_update: bool = False, ) -> UserSession | None: query = db.query(UserSession).filter( UserSession.user_id == user_id, UserSession.id == session_id, ) if lock_for_update: # 串行化 refresh token 轮换,避免并发刷新把合法会话误判为重放攻击。 query = query.with_for_update() return query.first() @staticmethod def validate_refresh_session( db: Session, *, user_id: str, session_id: str, refresh_token: str, ip_address: str | None = None, user_agent: str | None = None, ) -> tuple[UserSession | None, bool]: session = SessionService.get_session_for_user( db, user_id=user_id, session_id=session_id, lock_for_update=True, ) if not session or session.is_revoked or session.is_expired: return None, False is_valid, is_prev = session.verify_refresh_token(refresh_token) if not is_valid: logger.warning("Refresh token mismatch for session {}", session_id) SessionService.revoke_session( db, session=session, reason="refresh_token_reused", audit_user_id=user_id, ip_address=ip_address, user_agent=user_agent, ) db.flush() return None, False if is_prev: logger.info("Grace window hit for session {} (prev token used)", session_id) return session, is_prev