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.
This commit is contained in:
233
backend/app/utils/device.py
Normal file
233
backend/app/utils/device.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""
|
||||
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"
|
||||
Reference in New Issue
Block a user