- 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.
81 lines
3.1 KiB
Python
81 lines
3.1 KiB
Python
"""
|
|
User session model for tracking per-device authentication sessions.
|
|
|
|
This allows users to:
|
|
- See where they're logged in
|
|
- Logout from specific devices
|
|
- Manage their active sessions
|
|
"""
|
|
from sqlalchemy import Column, String, Boolean, DateTime, ForeignKey, Index
|
|
from sqlalchemy.dialects.postgresql import UUID
|
|
from sqlalchemy.orm import relationship
|
|
|
|
from .base import Base, TimestampMixin, UUIDMixin
|
|
|
|
|
|
class UserSession(Base, UUIDMixin, TimestampMixin):
|
|
"""
|
|
Tracks individual user sessions (per-device).
|
|
|
|
Each time a user logs in from a device, a new session is created.
|
|
Sessions are identified by the refresh token JTI (JWT ID).
|
|
"""
|
|
__tablename__ = 'user_sessions'
|
|
|
|
# Foreign key to user
|
|
user_id = Column(UUID(as_uuid=True), ForeignKey('users.id', ondelete='CASCADE'), nullable=False, index=True)
|
|
|
|
# Refresh token identifier (JWT ID from the refresh token)
|
|
refresh_token_jti = Column(String(255), unique=True, nullable=False, index=True)
|
|
|
|
# Device information
|
|
device_name = Column(String(255), nullable=True) # "iPhone 14", "Chrome on MacBook"
|
|
device_id = Column(String(255), nullable=True) # Persistent device identifier (from client)
|
|
ip_address = Column(String(45), nullable=True) # IPv4 (15 chars) or IPv6 (45 chars)
|
|
user_agent = Column(String(500), nullable=True) # Browser/app user agent
|
|
|
|
# Session timing
|
|
last_used_at = Column(DateTime(timezone=True), nullable=False)
|
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
|
|
|
# Session state
|
|
is_active = Column(Boolean, default=True, nullable=False, index=True)
|
|
|
|
# Geographic information (optional, can be populated from IP)
|
|
location_city = Column(String(100), nullable=True)
|
|
location_country = Column(String(100), nullable=True)
|
|
|
|
# Relationship to user
|
|
user = relationship("User", backref="sessions")
|
|
|
|
# Composite indexes for performance (defined in migration)
|
|
__table_args__ = (
|
|
Index('ix_user_sessions_user_active', 'user_id', 'is_active'),
|
|
Index('ix_user_sessions_jti_active', 'refresh_token_jti', 'is_active'),
|
|
)
|
|
|
|
def __repr__(self):
|
|
return f"<UserSession {self.device_name} ({self.ip_address})>"
|
|
|
|
@property
|
|
def is_expired(self) -> bool:
|
|
"""Check if session has expired."""
|
|
from datetime import datetime, timezone
|
|
return self.expires_at < datetime.now(timezone.utc)
|
|
|
|
def to_dict(self):
|
|
"""Convert session to dictionary for serialization."""
|
|
return {
|
|
'id': str(self.id),
|
|
'user_id': str(self.user_id),
|
|
'device_name': self.device_name,
|
|
'device_id': self.device_id,
|
|
'ip_address': self.ip_address,
|
|
'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None,
|
|
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
|
|
'is_active': self.is_active,
|
|
'location_city': self.location_city,
|
|
'location_country': self.location_country,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
}
|