Files
syndarix/backend/app/utils/device.py
Felipe Cardoso b42a29faad Add user session tracking, schemas, utilities, and per-device session management
- Introduced `user_sessions` table with support for per-device authentication sessions.
- Added `UserSession` model, including fields for device metadata, IP, and session state.
- Created schemas (`SessionBase`, `SessionCreate`, `SessionResponse`) to manage session data and responses.
- Implemented utilities for extracting and parsing device information from HTTP requests.
- Added Alembic migration to define `user_sessions` table with indexes for performance and cleanup.
2025-10-31 07:56:35 +01:00

234 lines
6.9 KiB
Python

"""
Utility functions for extracting and parsing device information from HTTP requests.
"""
import re
from typing import Optional
from fastapi import Request
from app.schemas.sessions import DeviceInfo
def extract_device_info(request: Request) -> DeviceInfo:
"""
Extract device information from the HTTP request.
Args:
request: FastAPI Request object
Returns:
DeviceInfo object with parsed device information
"""
user_agent = request.headers.get('user-agent', '')
device_info = DeviceInfo(
device_name=parse_device_name(user_agent),
device_id=request.headers.get('x-device-id'), # Client must send this header
ip_address=get_client_ip(request),
user_agent=user_agent[:500] if user_agent else None, # Truncate to max length
location_city=None, # Can be populated via IP geolocation service
location_country=None, # Can be populated via IP geolocation service
)
return device_info
def parse_device_name(user_agent: str) -> Optional[str]:
"""
Parse user agent string to extract a friendly device name.
Args:
user_agent: User-Agent header string
Returns:
Friendly device name string or None
Examples:
"Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)" -> "iPhone"
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)" -> "Mac"
"Mozilla/5.0 (Windows NT 10.0; Win64; x64)" -> "Windows PC"
"""
if not user_agent:
return "Unknown device"
user_agent_lower = user_agent.lower()
# Mobile devices (check first, as they can contain desktop patterns too)
if 'iphone' in user_agent_lower:
return "iPhone"
elif 'ipad' in user_agent_lower:
return "iPad"
elif 'android' in user_agent_lower:
# Try to extract device model
android_match = re.search(r'android.*;\s*([^)]+)\s*build', user_agent_lower)
if android_match:
device_model = android_match.group(1).strip()
return f"Android ({device_model.title()})"
return "Android device"
elif 'windows phone' in user_agent_lower:
return "Windows Phone"
# Desktop operating systems
elif 'macintosh' in user_agent_lower or 'mac os x' in user_agent_lower:
# Try to extract browser
browser = extract_browser(user_agent)
return f"{browser} on Mac" if browser else "Mac"
elif 'windows' in user_agent_lower:
browser = extract_browser(user_agent)
return f"{browser} on Windows" if browser else "Windows PC"
elif 'linux' in user_agent_lower and 'android' not in user_agent_lower:
browser = extract_browser(user_agent)
return f"{browser} on Linux" if browser else "Linux"
elif 'cros' in user_agent_lower:
return "Chromebook"
# Tablets (not already caught)
elif 'tablet' in user_agent_lower:
return "Tablet"
# Smart TVs
elif any(tv in user_agent_lower for tv in ['smart-tv', 'smarttv', 'tv']):
return "Smart TV"
# Game consoles
elif 'playstation' in user_agent_lower:
return "PlayStation"
elif 'xbox' in user_agent_lower:
return "Xbox"
elif 'nintendo' in user_agent_lower:
return "Nintendo"
# Fallback: just return browser name if detected
browser = extract_browser(user_agent)
if browser:
return browser
return "Unknown device"
def extract_browser(user_agent: str) -> Optional[str]:
"""
Extract browser name from user agent string.
Args:
user_agent: User-Agent header string
Returns:
Browser name or None
Examples:
"Mozilla/5.0 ... Chrome/96.0" -> "Chrome"
"Mozilla/5.0 ... Firefox/94.0" -> "Firefox"
"""
if not user_agent:
return None
user_agent_lower = user_agent.lower()
# Check specific browsers (order matters - check Edge before Chrome!)
if 'edg/' in user_agent_lower or 'edge/' in user_agent_lower:
return "Edge"
elif 'opr/' in user_agent_lower or 'opera' in user_agent_lower:
return "Opera"
elif 'chrome/' in user_agent_lower:
return "Chrome"
elif 'safari/' in user_agent_lower:
# Make sure it's actually Safari, not Chrome (which also contains "Safari")
if 'chrome' not in user_agent_lower:
return "Safari"
return None
elif 'firefox/' in user_agent_lower:
return "Firefox"
elif 'msie' in user_agent_lower or 'trident/' in user_agent_lower:
return "Internet Explorer"
return None
def get_client_ip(request: Request) -> Optional[str]:
"""
Extract client IP address from request, considering proxy headers.
Checks X-Forwarded-For and X-Real-IP headers for proxy scenarios.
Args:
request: FastAPI Request object
Returns:
Client IP address string or None
Notes:
- In production behind a proxy/load balancer, X-Forwarded-For is often set
- The first IP in X-Forwarded-For is typically the real client IP
- request.client.host is fallback for direct connections
"""
# Check X-Forwarded-For (common in proxied environments)
x_forwarded_for = request.headers.get('x-forwarded-for')
if x_forwarded_for:
# Get the first IP (original client)
client_ip = x_forwarded_for.split(',')[0].strip()
return client_ip
# Check X-Real-IP (used by some proxies like nginx)
x_real_ip = request.headers.get('x-real-ip')
if x_real_ip:
return x_real_ip.strip()
# Fallback to direct connection IP
if request.client and request.client.host:
return request.client.host
return None
def is_mobile_device(user_agent: str) -> bool:
"""
Check if the device is a mobile device based on user agent.
Args:
user_agent: User-Agent header string
Returns:
True if mobile device, False otherwise
"""
if not user_agent:
return False
mobile_patterns = [
'mobile', 'android', 'iphone', 'ipad', 'ipod',
'blackberry', 'windows phone', 'webos', 'opera mini',
'iemobile', 'mobile safari'
]
user_agent_lower = user_agent.lower()
return any(pattern in user_agent_lower for pattern in mobile_patterns)
def get_device_type(user_agent: str) -> str:
"""
Determine the general device type.
Args:
user_agent: User-Agent header string
Returns:
Device type: "mobile", "tablet", "desktop", or "other"
"""
if not user_agent:
return "other"
user_agent_lower = user_agent.lower()
# Check for tablets first (they can contain "mobile" too)
if 'ipad' in user_agent_lower or 'tablet' in user_agent_lower:
return "tablet"
# Check for mobile
if is_mobile_device(user_agent):
return "mobile"
# Check for desktop OS patterns
if any(os in user_agent_lower for os in ['windows', 'macintosh', 'linux', 'cros']):
return "desktop"
return "other"