refactor: consolidate user and superadmin sessions into unified model
This commit is contained in:
@@ -15,7 +15,7 @@ def superadmin_required(f):
|
||||
"""Decorator to require superadmin Bearer token authentication.
|
||||
|
||||
Extracts token from Authorization: Bearer {token} header,
|
||||
validates the session against SuperadminSession table,
|
||||
validates the session against the unified sessions table,
|
||||
and sets g.current_superadmin and g.superadmin_session.
|
||||
|
||||
Returns 401 if no valid session, 403 if not a superadmin.
|
||||
@@ -46,10 +46,14 @@ def superadmin_required(f):
|
||||
token = parts[1]
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from gatehouse_app.models.superadmin import SuperadminSession, Superadmin
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.models.superadmin import Superadmin
|
||||
from gatehouse_app.utils.constants import SessionType
|
||||
|
||||
# Get active session by token
|
||||
session = SuperadminSession.query.filter_by(token=token).first()
|
||||
# Get active session by token, scoped to superadmin
|
||||
session = Session.query.filter_by(
|
||||
token=token, owner_type=SessionType.SUPERADMIN
|
||||
).first()
|
||||
|
||||
if not session:
|
||||
return api_response(
|
||||
@@ -68,8 +72,8 @@ def superadmin_required(f):
|
||||
error_type="SESSION_INACTIVE"
|
||||
)
|
||||
|
||||
# Get the superadmin
|
||||
superadmin = session.superadmin
|
||||
# Get the superadmin by owner_id
|
||||
superadmin = Superadmin.query.get(session.owner_id)
|
||||
if not superadmin:
|
||||
return api_response(
|
||||
success=False,
|
||||
|
||||
@@ -118,7 +118,6 @@ from gatehouse_app.models.zerotier import ( # noqa: F401
|
||||
from gatehouse_app.models.superadmin import ( # noqa: F401
|
||||
Superadmin,
|
||||
SuperadminSession,
|
||||
SuperadminSessionStatus,
|
||||
)
|
||||
from gatehouse_app.models.superadmin_audit_log import SuperadminAuditLog # noqa: F401
|
||||
from gatehouse_app.models.security.user_security_policy import ( # noqa: F401
|
||||
@@ -186,6 +185,5 @@ __all__ = [
|
||||
# Superadmin
|
||||
"Superadmin",
|
||||
"SuperadminSession",
|
||||
"SuperadminSessionStatus",
|
||||
"SuperadminAuditLog",
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""Superadmin models."""
|
||||
from gatehouse_app.models.superadmin.superadmin import Superadmin
|
||||
from gatehouse_app.models.superadmin.superadmin_session import SuperadminSession, SuperadminSessionStatus
|
||||
from gatehouse_app.models.user.session import Session as SuperadminSession
|
||||
|
||||
__all__ = ["Superadmin", "SuperadminSession", "SuperadminSessionStatus"]
|
||||
__all__ = ["Superadmin", "SuperadminSession"]
|
||||
|
||||
@@ -23,11 +23,15 @@ class Superadmin(BaseModel):
|
||||
is_active = db.Column(db.Boolean, default=True, nullable=False)
|
||||
last_login_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationship to sessions
|
||||
# Relationship to sessions (unified model, scoped to superadmin owner_type)
|
||||
sessions = db.relationship(
|
||||
"SuperadminSession",
|
||||
back_populates="superadmin",
|
||||
cascade="all, delete-orphan"
|
||||
"Session",
|
||||
primaryjoin=(
|
||||
"and_(Superadmin.id == foreign(Session.owner_id), "
|
||||
"Session.owner_type == 'superadmin')"
|
||||
),
|
||||
cascade="all, delete-orphan",
|
||||
lazy="dynamic",
|
||||
)
|
||||
|
||||
# Relationship to audit logs
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Superadmin session model."""
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.base import BaseModel
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SuperadminSessionStatus:
|
||||
"""Session status constants."""
|
||||
ACTIVE = "active"
|
||||
REVOKED = "revoked"
|
||||
EXPIRED = "expired"
|
||||
|
||||
|
||||
class SuperadminSession(BaseModel):
|
||||
"""Session model for superadmin authentication."""
|
||||
|
||||
__tablename__ = "superadmin_sessions"
|
||||
|
||||
superadmin_id = db.Column(
|
||||
db.String(36),
|
||||
db.ForeignKey("superadmins.id"),
|
||||
nullable=False,
|
||||
index=True
|
||||
)
|
||||
token = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
expires_at = db.Column(db.DateTime, nullable=False)
|
||||
last_activity_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
default=lambda: datetime.now(timezone.utc)
|
||||
)
|
||||
ip_address = db.Column(db.String(45), nullable=True)
|
||||
user_agent = db.Column(db.Text, nullable=True)
|
||||
revoked_at = db.Column(db.DateTime, nullable=True)
|
||||
revoked_reason = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Relationship
|
||||
superadmin = db.relationship("Superadmin", back_populates="sessions")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SuperadminSession superadmin_id={self.superadmin_id}>"
|
||||
|
||||
def is_active(self):
|
||||
"""Check if session is currently active."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = self.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
return (
|
||||
self.deleted_at is None
|
||||
and self.revoked_at is None
|
||||
and expires_at > now
|
||||
)
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if session has expired."""
|
||||
now = datetime.now(timezone.utc)
|
||||
expires_at = self.expires_at
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
return now > expires_at
|
||||
|
||||
def revoke(self, reason: str = None):
|
||||
"""Revoke the session."""
|
||||
self.revoked_at = datetime.now(timezone.utc)
|
||||
if reason:
|
||||
self.revoked_reason = reason
|
||||
from gatehouse_app import db
|
||||
db.session.commit()
|
||||
|
||||
def to_dict(self, exclude=None):
|
||||
"""Convert to dictionary, excluding sensitive fields."""
|
||||
exclude = exclude or []
|
||||
exclude.append("token")
|
||||
return super().to_dict(exclude=exclude)
|
||||
@@ -3,15 +3,24 @@ from datetime import datetime, timedelta, timezone
|
||||
from flask import current_app
|
||||
from gatehouse_app.extensions import db
|
||||
from gatehouse_app.models.base import BaseModel
|
||||
from gatehouse_app.utils.constants import SessionStatus
|
||||
from gatehouse_app.utils.constants import SessionStatus, SessionType
|
||||
|
||||
|
||||
class Session(BaseModel):
|
||||
"""Session model for tracking user sessions."""
|
||||
"""Session model for tracking user and superadmin sessions."""
|
||||
|
||||
__tablename__ = "sessions"
|
||||
|
||||
user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=False, index=True)
|
||||
# Owner discriminator — determines which table the owner_id references
|
||||
owner_type = db.Column(
|
||||
db.String(20), nullable=False, default=SessionType.USER, index=True
|
||||
)
|
||||
owner_id = db.Column(db.String(36), nullable=False, index=True)
|
||||
|
||||
# Legacy column kept for backward compatibility during migration;
|
||||
# new code should use owner_id / owner_type.
|
||||
user_id = db.Column(db.String(36), db.ForeignKey("users.id"), nullable=True, index=True)
|
||||
|
||||
token = db.Column(db.String(255), unique=True, nullable=False, index=True)
|
||||
status = db.Column(db.Enum(SessionStatus), default=SessionStatus.ACTIVE, nullable=False)
|
||||
|
||||
@@ -34,21 +43,37 @@ class Session(BaseModel):
|
||||
# Relationships
|
||||
user = db.relationship("User", back_populates="sessions")
|
||||
|
||||
# Composite index for owner-scoped queries
|
||||
__table_args__ = (
|
||||
db.Index("ix_sessions_owner_type_owner_id", "owner_type", "owner_id"),
|
||||
)
|
||||
|
||||
# ---- Convenience properties ------------------------------------------------
|
||||
|
||||
@property
|
||||
def is_user(self):
|
||||
return self.owner_type == SessionType.USER
|
||||
|
||||
@property
|
||||
def is_superadmin(self):
|
||||
return self.owner_type == SessionType.SUPERADMIN
|
||||
|
||||
# ---- Core methods ----------------------------------------------------------
|
||||
|
||||
def __repr__(self):
|
||||
"""String representation of Session."""
|
||||
return f"<Session user_id={self.user_id} status={self.status}>"
|
||||
return f"<Session owner_type={self.owner_type} owner_id={self.owner_id} status={self.status}>"
|
||||
|
||||
def is_active(self):
|
||||
"""Check if session is currently active.
|
||||
|
||||
Sessions are evaluated against two independent timeouts:
|
||||
User sessions are evaluated against two independent timeouts:
|
||||
- Idle timeout: expires if no request has been made within
|
||||
SESSION_IDLE_TIMEOUT seconds (default 15 min).
|
||||
- Absolute timeout: expires if SESSION_ABSOLUTE_TIMEOUT seconds
|
||||
have elapsed since the session was created (default 8 h),
|
||||
regardless of activity.
|
||||
have elapsed since the session was created (default 8 h).
|
||||
|
||||
A session must satisfy *both* constraints to remain active.
|
||||
Superadmin sessions use absolute timeout only (no idle timeout).
|
||||
A session must satisfy *all* applicable constraints to remain active.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
created_at = self.created_at
|
||||
@@ -59,12 +84,21 @@ class Session(BaseModel):
|
||||
if last_activity_at.tzinfo is None:
|
||||
last_activity_at = last_activity_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
idle_timeout = current_app.config.get("SESSION_IDLE_TIMEOUT", 900)
|
||||
absolute_timeout = current_app.config.get("SESSION_ABSOLUTE_TIMEOUT", 28800)
|
||||
|
||||
idle_expires_at = last_activity_at + timedelta(seconds=idle_timeout)
|
||||
absolute_expires_at = created_at + timedelta(seconds=absolute_timeout)
|
||||
|
||||
if self.is_superadmin:
|
||||
# Superadmin: absolute timeout only
|
||||
return (
|
||||
self.status == SessionStatus.ACTIVE
|
||||
and now < absolute_expires_at
|
||||
and self.deleted_at is None
|
||||
)
|
||||
|
||||
# User: idle + absolute timeout
|
||||
idle_timeout = current_app.config.get("SESSION_IDLE_TIMEOUT", 900)
|
||||
idle_expires_at = last_activity_at + timedelta(seconds=idle_timeout)
|
||||
|
||||
return (
|
||||
self.status == SessionStatus.ACTIVE
|
||||
and now < idle_expires_at
|
||||
@@ -83,6 +117,8 @@ class Session(BaseModel):
|
||||
capped so that the session never exceeds the absolute lifetime
|
||||
(``created_at + absolute timeout``).
|
||||
|
||||
Superadmin sessions only update last_activity_at (no sliding window).
|
||||
|
||||
Args:
|
||||
duration_seconds: Override for the idle timeout. When *None*
|
||||
(the common case), the value is read from
|
||||
@@ -90,6 +126,12 @@ class Session(BaseModel):
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
if self.is_superadmin:
|
||||
# Superadmin: just bump last_activity_at, no sliding window
|
||||
self.last_activity_at = now
|
||||
db.session.commit()
|
||||
return
|
||||
|
||||
if duration_seconds is None:
|
||||
duration_seconds = current_app.config.get("SESSION_IDLE_TIMEOUT", 900)
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ from gatehouse_app.extensions import db, bcrypt
|
||||
from gatehouse_app.models.user.user import User
|
||||
from gatehouse_app.models.auth.authentication_method import AuthenticationMethod
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, UserStatus, AuditAction
|
||||
from gatehouse_app.utils.constants import AuthMethodType, SessionStatus, SessionType, UserStatus, AuditAction
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError, AccountSuspendedError, AccountInactiveError
|
||||
from gatehouse_app.exceptions.validation_exceptions import EmailAlreadyExistsError
|
||||
from gatehouse_app.services.audit_service import AuditService
|
||||
@@ -165,6 +165,8 @@ class AuthService:
|
||||
|
||||
# Create session
|
||||
session = Session(
|
||||
owner_type=SessionType.USER,
|
||||
owner_id=user.id,
|
||||
user_id=user.id,
|
||||
token=token,
|
||||
status=SessionStatus.ACTIVE,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Session service."""
|
||||
from datetime import datetime, timezone
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.utils.constants import SessionStatus
|
||||
from gatehouse_app.utils.constants import SessionStatus, SessionType
|
||||
|
||||
|
||||
class SessionService:
|
||||
@@ -28,18 +28,22 @@ class SessionService:
|
||||
).first()
|
||||
|
||||
@staticmethod
|
||||
def get_user_sessions(user_id, active_only=True):
|
||||
"""
|
||||
Get all sessions for a user.
|
||||
def get_owner_sessions(owner_type, owner_id, active_only=True):
|
||||
"""Get all sessions for an owner (user or superadmin).
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
owner_type: SessionType.USER or SessionType.SUPERADMIN
|
||||
owner_id: Owner ID
|
||||
active_only: If True, only return active sessions
|
||||
|
||||
Returns:
|
||||
List of Session instances
|
||||
"""
|
||||
query = Session.query.filter_by(user_id=user_id, deleted_at=None)
|
||||
query = Session.query.filter_by(
|
||||
owner_type=owner_type,
|
||||
owner_id=owner_id,
|
||||
deleted_at=None,
|
||||
)
|
||||
|
||||
if active_only:
|
||||
query = query.filter_by(status=SessionStatus.ACTIVE).filter(
|
||||
@@ -49,18 +53,67 @@ class SessionService:
|
||||
return query.all()
|
||||
|
||||
@staticmethod
|
||||
def revoke_user_sessions(user_id, reason="User logged out from all devices"):
|
||||
def get_user_sessions(user_id, active_only=True):
|
||||
"""Get all sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
active_only: If True, only return active sessions
|
||||
|
||||
Returns:
|
||||
List of Session instances
|
||||
"""
|
||||
Revoke all active sessions for a user.
|
||||
return SessionService.get_owner_sessions(
|
||||
SessionType.USER, user_id, active_only=active_only
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_superadmin_sessions(superadmin_id, active_only=True):
|
||||
"""Get all sessions for a superadmin.
|
||||
|
||||
Args:
|
||||
superadmin_id: Superadmin ID
|
||||
active_only: If True, only return active sessions
|
||||
|
||||
Returns:
|
||||
List of Session instances
|
||||
"""
|
||||
return SessionService.get_owner_sessions(
|
||||
SessionType.SUPERADMIN, superadmin_id, active_only=active_only
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def revoke_owner_sessions(owner_type, owner_id, reason="Logged out from all devices"):
|
||||
"""Revoke all active sessions for an owner.
|
||||
|
||||
Args:
|
||||
owner_type: SessionType.USER or SessionType.SUPERADMIN
|
||||
owner_id: Owner ID
|
||||
reason: Reason for revocation
|
||||
"""
|
||||
sessions = SessionService.get_owner_sessions(owner_type, owner_id, active_only=True)
|
||||
for session in sessions:
|
||||
session.revoke(reason=reason)
|
||||
|
||||
@staticmethod
|
||||
def revoke_user_sessions(user_id, reason="User logged out from all devices"):
|
||||
"""Revoke all active sessions for a user.
|
||||
|
||||
Args:
|
||||
user_id: User ID
|
||||
reason: Reason for revocation
|
||||
"""
|
||||
sessions = SessionService.get_user_sessions(user_id, active_only=True)
|
||||
SessionService.revoke_owner_sessions(SessionType.USER, user_id, reason=reason)
|
||||
|
||||
for session in sessions:
|
||||
session.revoke(reason=reason)
|
||||
@staticmethod
|
||||
def revoke_superadmin_sessions(superadmin_id, reason="Superadmin logged out"):
|
||||
"""Revoke all active sessions for a superadmin.
|
||||
|
||||
Args:
|
||||
superadmin_id: Superadmin ID
|
||||
reason: Reason for revocation
|
||||
"""
|
||||
SessionService.revoke_owner_sessions(SessionType.SUPERADMIN, superadmin_id, reason=reason)
|
||||
|
||||
@staticmethod
|
||||
def cleanup_expired_sessions():
|
||||
|
||||
@@ -6,7 +6,9 @@ from typing import Optional
|
||||
|
||||
from flask import request, current_app
|
||||
from gatehouse_app.extensions import db, bcrypt
|
||||
from gatehouse_app.models.superadmin import Superadmin, SuperadminSession
|
||||
from gatehouse_app.models.superadmin import Superadmin
|
||||
from gatehouse_app.models.user.session import Session
|
||||
from gatehouse_app.utils.constants import SessionType
|
||||
from gatehouse_app.exceptions.auth_exceptions import InvalidCredentialsError
|
||||
|
||||
|
||||
@@ -70,15 +72,17 @@ class SuperadminAuthService:
|
||||
duration_seconds: Session duration in seconds (default 8 hours)
|
||||
|
||||
Returns:
|
||||
SuperadminSession instance
|
||||
Session instance
|
||||
"""
|
||||
# Generate secure token
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
||||
# Create session
|
||||
session = SuperadminSession(
|
||||
superadmin_id=superadmin_id,
|
||||
# Create session using unified model
|
||||
session = Session(
|
||||
owner_type=SessionType.SUPERADMIN,
|
||||
owner_id=superadmin_id,
|
||||
token=token,
|
||||
status="active",
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(seconds=duration_seconds),
|
||||
last_activity_at=datetime.now(timezone.utc),
|
||||
ip_address=request.remote_addr,
|
||||
@@ -97,7 +101,9 @@ class SuperadminAuthService:
|
||||
session_id: Session ID to revoke
|
||||
reason: Optional revocation reason
|
||||
"""
|
||||
session = SuperadminSession.query.get(session_id)
|
||||
session = Session.query.filter_by(
|
||||
id=session_id, owner_type=SessionType.SUPERADMIN
|
||||
).first()
|
||||
if session:
|
||||
session.revoke(reason=reason)
|
||||
logger.info(f"[SuperadminAuth] Session {session_id} revoked: {reason or 'No reason'}")
|
||||
@@ -111,9 +117,11 @@ class SuperadminAuthService:
|
||||
except_token: Optional token to keep (current session)
|
||||
reason: Optional revocation reason
|
||||
"""
|
||||
query = SuperadminSession.query.filter_by(superadmin_id=superadmin_id)
|
||||
query = Session.query.filter_by(
|
||||
owner_type=SessionType.SUPERADMIN, owner_id=superadmin_id
|
||||
)
|
||||
if except_token:
|
||||
query = query.filter(SuperadminSession.token != except_token)
|
||||
query = query.filter(Session.token != except_token)
|
||||
|
||||
sessions = query.all()
|
||||
for session in sessions:
|
||||
|
||||
@@ -52,6 +52,13 @@ class SessionStatus(str, Enum):
|
||||
REVOKED = "revoked"
|
||||
|
||||
|
||||
class SessionType(str, Enum):
|
||||
"""Session owner type discriminator."""
|
||||
|
||||
USER = "user"
|
||||
SUPERADMIN = "superadmin"
|
||||
|
||||
|
||||
class AuditAction(str, Enum):
|
||||
"""Audit log action types."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user