Add session idle and absolute timeout enforcement

Implements optional session idle and absolute timeout logic, including new environment variables for configuration. Adds last_activity_at to sessions, enforces timeouts on token refresh, and introduces a scheduler job to clean up idle sessions. Also introduces progressive lockout for failed logins and updates documentation and examples accordingly.
This commit is contained in:
João Vitória Silva
2025-12-18 10:28:22 +00:00
parent 23cf954a47
commit f6e06fb3e6
13 changed files with 500 additions and 99 deletions

View File

@@ -15,6 +15,12 @@ POSTGRES_USER=endurain # If you change this, you also have to change DB_USER
# DB_USER=endurain # Uncomment and set it to the same as POSTGRES_USER if you change it
PGDATA=/var/lib/postgresql/data/pgdata
# Optional: Enable session timeouts (default: false)
#SESSION_IDLE_TIMEOUT_ENABLED=true # Enable idle session timeout
# If enabled, configure timeout durations
#SESSION_IDLE_TIMEOUT_HOURS=1 # Idle timeout (no activity)
#SESSION_ABSOLUTE_TIMEOUT_HOURS=24 # Absolute max session lifetime
# Email configuration (for password reset functionality)
#SMTP_HOST=smtp.protonmail.ch
#SMTP_PORT=587

View File

@@ -21,6 +21,7 @@ depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
# Create oauth_states table
op.create_table(
"oauth_states",
sa.Column(
@@ -113,6 +114,7 @@ def upgrade() -> None:
op.create_index(
op.f("ix_oauth_states_user_id"), "oauth_states", ["user_id"], unique=False
)
# Add oauth_state_id and tokens_exchanged to users_sessions table
op.add_column(
"users_sessions",
sa.Column(
@@ -154,11 +156,24 @@ def upgrade() -> None:
op.create_foreign_key(
None, "users_sessions", "oauth_states", ["oauth_state_id"], ["id"]
)
# Add last_activity_at column with default value = created_at
op.add_column(
"users_sessions", sa.Column("last_activity_at", sa.DateTime(), nullable=True)
)
# Backfill existing sessions: set last_activity_at = created_at
op.execute(
"UPDATE users_sessions SET last_activity_at = created_at WHERE last_activity_at IS NULL"
)
# Make column non-nullable after backfill
op.alter_column("users_sessions", "last_activity_at", nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("users_sessions", "last_activity_at")
op.drop_constraint(None, "users_sessions", type_="foreignkey")
op.drop_index(op.f("ix_users_sessions_oauth_state_id"), table_name="users_sessions")
op.drop_column("users_sessions", "tokens_exchanged")

View File

@@ -6,12 +6,21 @@ import core.config as core_config
# JWT config (typed + validated)
JWT_ALGORITHM: Final[str] = os.environ.get("ALGORITHM", "HS256")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: Final[int] = int(
os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "15")
os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", 15)
)
JWT_REFRESH_TOKEN_EXPIRE_DAYS: Final[int] = int(
os.environ.get("REFRESH_TOKEN_EXPIRE_DAYS", "7")
os.environ.get("REFRESH_TOKEN_EXPIRE_DAYS", 7)
)
JWT_SECRET_KEY: Final[str | None] = core_config.read_secret("SECRET_KEY")
SESSION_IDLE_TIMEOUT_ENABLED: Final[bool] = (
os.getenv("SESSION_IDLE_TIMEOUT_ENABLED", "false").lower() == "true"
)
SESSION_IDLE_TIMEOUT_HOURS: Final[int] = int(
os.environ.get("SESSION_IDLE_TIMEOUT_HOURS", 1)
)
SESSION_ABSOLUTE_TIMEOUT_HOURS: Final[int] = int(
os.environ.get("SESSION_ABSOLUTE_TIMEOUT_HOURS", 24)
)
if JWT_ACCESS_TOKEN_EXPIRE_MINUTES <= 0:
raise ValueError("ACCESS_TOKEN_EXPIRE_MINUTES must be positive")

View File

@@ -42,6 +42,10 @@ async def login_for_access_token(
request: Request,
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
client_type: Annotated[str, Depends(auth_security.header_client_type_scheme)],
failed_attempts: Annotated[
auth_schema.FailedLoginAttempts,
Depends(auth_schema.get_failed_login_attempts),
],
pending_mfa_store: Annotated[
auth_schema.PendingMFALogin, Depends(auth_schema.get_pending_mfa_store)
],
@@ -61,7 +65,12 @@ async def login_for_access_token(
"""
Handles user login and access token generation, including Multi-Factor Authentication (MFA) flow.
Rate Limit: 5 requests per minute per IP
Protection Mechanisms:
- Rate limiting: 3 requests per minute per IP (prevents DoS attacks)
- Progressive lockout: Per-username tracking prevents targeted brute-force:
* 5 failures: 5 minute lockout
* 10 failures: 30 minute lockout
* 20 failures: 24 hour lockout
This endpoint authenticates a user using provided credentials, checks if the user is active,
and determines if MFA is required. If MFA is enabled for the user, it stores the pending login
@@ -73,6 +82,7 @@ async def login_for_access_token(
request: The HTTP request object
form_data: Form data containing username and password
client_type: The type of client making the request ("web" or "mobile")
failed_attempts: Failed login attempts tracker for progressive lockout
pending_mfa_store: Store for pending MFA logins
password_hasher: The password hasher instance used for verifying passwords
token_manager: The token manager instance used for token operations
@@ -84,11 +94,30 @@ async def login_for_access_token(
- If MFA is not required, proceeds with normal login via auth_utils.complete_login()
Raises:
HTTPException: If authentication fails or the user is inactive
HTTPException: If authentication fails, user is inactive, or account is locked
"""
user = auth_utils.authenticate_user(
form_data.username, form_data.password, password_hasher, db
)
# Check if username is locked out from too many failed login attempts
if failed_attempts.is_locked_out(form_data.username):
lockout_until = failed_attempts.get_lockout_time(form_data.username)
if lockout_until:
seconds_remaining = int(
(lockout_until - datetime.now(timezone.utc)).total_seconds()
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Account locked due to too many failed login attempts. Try again in {seconds_remaining} seconds.",
)
# Authenticate user
try:
user = auth_utils.authenticate_user(
form_data.username, form_data.password, password_hasher, db
)
except HTTPException as err:
# Record failed attempt on authentication errors (401 Unauthorized)
if err.status_code == 401:
failed_attempts.record_failed_attempt(form_data.username)
raise err
# Check if the user is active
users_utils.check_user_is_active(user)
@@ -98,6 +127,9 @@ async def login_for_access_token(
# Store the user for pending MFA verification
pending_mfa_store.add_pending_login(form_data.username, user.id)
# Don't reset failed login attempts yet - wait for MFA verification
# This prevents bypassing lockout by triggering MFA flow
# Return MFA required response
if client_type == "web":
response.status_code = status.HTTP_202_ACCEPTED
@@ -113,6 +145,10 @@ async def login_for_access_token(
"message": "MFA verification required",
}
# Password authentication successful and no MFA required
# Reset failed login attempts counter
failed_attempts.reset_attempts(form_data.username)
# If no MFA required, proceed with normal login
return auth_utils.complete_login(
response, request, user, client_type, password_hasher, token_manager, db
@@ -126,6 +162,10 @@ async def verify_mfa_and_login(
request: Request,
mfa_request: auth_schema.MFALoginRequest,
client_type: Annotated[str, Depends(auth_security.header_client_type_scheme)],
failed_attempts: Annotated[
auth_schema.FailedLoginAttempts,
Depends(auth_schema.get_failed_login_attempts),
],
pending_mfa_store: Annotated[
auth_schema.PendingMFALogin, Depends(auth_schema.get_pending_mfa_store)
],
@@ -151,6 +191,7 @@ async def verify_mfa_and_login(
Args:
response: The HTTP response object
request: The HTTP request object
failed_attempts: Failed login attempts tracker for progressive lockout
mfa_request: MFA login request containing username and MFA code
client_type: The type of client making the request ("web" or "mobile")
pending_mfa_store: Store for pending MFA logins
@@ -204,8 +245,9 @@ async def verify_mfa_and_login(
# Check if the user is still active
users_utils.check_user_is_active(user)
# MFA verification successful - reset failed attempts counter
# MFA verification successful - reset both MFA and login failed attempts counters
pending_mfa_store.reset_failed_attempts(mfa_request.username)
failed_attempts.reset_attempts(mfa_request.username)
# Clean up pending login
pending_mfa_store.delete_pending_login(mfa_request.username)
@@ -283,6 +325,9 @@ async def refresh_token(
headers={"WWW-Authenticate": "Bearer"},
)
# NEW: Validate session hasn't exceeded idle or absolute timeout
session_utils.validate_session_timeout(session)
# Validate CSRF token matches session
# Note: CSRF token is stored in session during initial authentication
# For now, we validate that a CSRF token was provided (checked by middleware)

View File

@@ -270,3 +270,156 @@ def get_pending_mfa_store():
pending_mfa_store = PendingMFALogin()
class FailedLoginAttempts:
"""
Track failed login attempts and apply progressive lockouts.
This class prevents brute-force attacks on user accounts by tracking failed
login attempts per username and applying increasingly strict lockouts.
Unlike rate limiting (which is per-IP), this tracks per-username to prevent
distributed attacks targeting a single account from multiple IPs.
Attributes:
_attempts (dict): Maps username to (failed_count, lockout_until) tuples
Lockout Policy:
- 5 failures: 5 minute lockout
- 10 failures: 30 minute lockout
- 20 failures: 24 hour lockout (requires admin intervention)
"""
def __init__(self):
# {username: (failed_count, lockout_until)}
self._attempts: dict[str, tuple[int, datetime | None]] = {}
def is_locked_out(self, username: str) -> bool:
"""
Check if username is locked out from failed login attempts.
Args:
username: Username to check
Returns:
True if username is currently locked out, False otherwise
"""
if username not in self._attempts:
return False
_, lockout_until = self._attempts[username]
if lockout_until is None:
return False
# Check if lockout has expired
if datetime.now(timezone.utc) > lockout_until:
# Lockout expired, reset
del self._attempts[username]
return False
return True
def get_lockout_time(self, username: str) -> datetime | None:
"""
Get lockout expiry time for username.
Args:
username: Username to check
Returns:
Lockout expiry datetime if locked out, None otherwise
"""
if username not in self._attempts:
return None
_, lockout_until = self._attempts[username]
if lockout_until and datetime.now(timezone.utc) <= lockout_until:
return lockout_until
return None
def record_failed_attempt(self, username: str) -> int:
"""
Record a failed login attempt and apply lockout if threshold exceeded.
Lockout policy:
- 5 failures: 5 minute lockout
- 10 failures: 30 minute lockout
- 20 failures: 24 hour lockout (severe - requires admin intervention)
Args:
username: Username that failed login
Returns:
Number of failed attempts
"""
now = datetime.now(timezone.utc)
if username in self._attempts:
failed_count, lockout_until = self._attempts[username]
# If still locked out, don't increment counter
if lockout_until and now <= lockout_until:
return failed_count
failed_count += 1
else:
failed_count = 1
# Determine lockout duration based on failure count
lockout_until = None
if failed_count >= 20:
lockout_until = now + timedelta(hours=24)
core_logger.print_to_log(
f"Login lockout (24 hours) applied to user {username} after {failed_count} failed attempts",
"warning",
context={"username": username, "failed_attempts": failed_count},
)
elif failed_count >= 10:
lockout_until = now + timedelta(minutes=30)
core_logger.print_to_log(
f"Login lockout (30 min) applied to user {username} after {failed_count} failed attempts",
"warning",
context={"username": username, "failed_attempts": failed_count},
)
elif failed_count >= 5:
lockout_until = now + timedelta(minutes=5)
core_logger.print_to_log(
f"Login lockout (5 min) applied to user {username} after {failed_count} failed attempts",
"warning",
context={"username": username, "failed_attempts": failed_count},
)
self._attempts[username] = (failed_count, lockout_until)
return failed_count
def reset_attempts(self, username: str) -> None:
"""
Clear failed attempts counter on successful login.
Args:
username: Username to reset
"""
if username in self._attempts:
del self._attempts[username]
def clear_all(self) -> None:
"""
Clear all failed attempt records.
Used for testing or admin operations.
"""
self._attempts.clear()
# Create singleton instance
failed_login_attempts = FailedLoginAttempts()
def get_failed_login_attempts():
"""
Dependency injection for FastAPI.
Returns:
FailedLoginAttempts: The global failed login attempts tracker
"""
return failed_login_attempts

View File

@@ -1,7 +1,6 @@
import os
from datetime import datetime, timedelta, timezone
from dataclasses import dataclass
from typing import Tuple
from fastapi import (
HTTPException,
@@ -14,18 +13,12 @@ from uuid import uuid4
from sqlalchemy.orm import Session
import auth.constants as auth_constants
import session.schema as session_schema
import session.crud as session_crud
import session.utils as session_utils
import auth.password_hasher as auth_password_hasher
import auth.token_manager as auth_token_manager
import users.user.crud as users_crud
import users.user.schema as users_schema
import users.user_identity_providers.crud as user_idp_crud
import auth.identity_providers.service as idp_service
import core.logger as core_logger
def authenticate_user(

View File

@@ -43,7 +43,9 @@ OAUTH_DISCONNECT_LIMIT = "5/minute" # Account disconnection (less frequent)
PKCE_TOKEN_EXCHANGE_LIMIT = "10/minute" # Mobile PKCE token exchange
# Session endpoints - stricter protection (potential brute-force target)
SESSION_LOGIN_LIMIT = "5/minute" # Login attempts
SESSION_LOGIN_LIMIT = (
"3/minute" # Login attempts (reduced from 5 to prevent brute-force)
)
SESSION_REFRESH_LIMIT = "20/minute" # Token refresh (more frequent but still limited)
SESSION_LOGOUT_LIMIT = "10/minute" # Logout requests

View File

@@ -11,6 +11,8 @@ import password_reset_tokens.utils as password_reset_tokens_utils
import sign_up_tokens.utils as sign_up_tokens_utils
import session.utils as session_utils
import auth.oauth_state.utils as oauth_state_utils
import core.logger as core_logger
@@ -80,6 +82,14 @@ def start_scheduler():
"delete expired OAuth states from the database",
)
add_scheduler_job(
session_utils.cleanup_idle_sessions,
"interval",
15,
[],
"delete expired sessions from the database",
)
def add_scheduler_job(func, interval, minutes, args, description):
try:

View File

@@ -105,6 +105,64 @@ def get_session_by_id(
) from err
def get_session_with_oauth_state(
session_id: str, db: Session
) -> tuple[session_models.UsersSessions, oauth_state_models.OAuthState | None] | None:
"""
Retrieve a session with its associated OAuthState for token exchange validation.
This function performs a query to retrieve a session along with its
linked OAuth state record (if any). Used during mobile token exchange to
validate PKCE and ensure the session is valid.
Args:
session_id (str): The unique identifier of the session.
db (Session): The SQLAlchemy database session.
Returns:
tuple[UsersSessions, OAuthState | None] | None: A tuple of (session, oauth_state)
where oauth_state may be None if not linked. Returns None if session not found.
Raises:
HTTPException: If an error occurs during retrieval (500).
"""
try:
# Query session
db_session = (
db.query(session_models.UsersSessions)
.filter(session_models.UsersSessions.id == session_id)
.filter(
session_models.UsersSessions.expires_at > datetime.now(timezone.utc)
)
.first()
)
if not db_session:
return None
# Get OAuth state if linked
oauth_state = None
if db_session.oauth_state_id:
oauth_state = (
db.query(oauth_state_models.OAuthState)
.filter(oauth_state_models.OAuthState.id == db_session.oauth_state_id)
.first()
)
return (db_session, oauth_state)
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in get_session_with_oauth_state: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve session with OAuth state",
) from err
def create_session(
session: session_schema.UsersSessions, db: Session
) -> session_models.UsersSessions:
@@ -146,6 +204,57 @@ def create_session(
) from err
def mark_tokens_exchanged(session_id: str, db: Session) -> None:
"""
Atomically mark tokens as exchanged for a session to prevent duplicate mobile token exchanges.
This function sets the tokens_exchanged flag to True for a specific session.
Prevents replay attacks where multiple token exchange requests could be made
for the same session.
Args:
session_id (str): The unique identifier of the session.
db (Session): The SQLAlchemy database session.
Raises:
SessionNotFoundError: If the session does not exist.
HTTPException: If an error occurs during the update (500).
"""
try:
# Get the session from the database
db_session = (
db.query(session_models.UsersSessions)
.filter(session_models.UsersSessions.id == session_id)
.first()
)
# Check if the session exists
if not db_session:
raise SessionNotFoundError(f"Session {session_id} not found")
# Mark tokens as exchanged
db_session.tokens_exchanged = True
db.commit()
except SessionNotFoundError as err:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(err)
) from err
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in mark_tokens_exchanged: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to mark tokens as exchanged",
) from err
def edit_session(session: session_schema.UsersSessions, db: Session) -> None:
"""
Edits an existing user session in the database.
@@ -252,110 +361,47 @@ def delete_session(session_id: str, user_id: int, db: Session) -> None:
) from err
def mark_tokens_exchanged(session_id: str, db: Session) -> None:
def delete_idle_sessions(cutoff_time: datetime, db: Session) -> int:
"""
Atomically mark tokens as exchanged for a session to prevent duplicate mobile token exchanges.
Delete sessions that have exceeded the idle timeout threshold.
This function sets the tokens_exchanged flag to True for a specific session.
Prevents replay attacks where multiple token exchange requests could be made
for the same session.
This function removes all sessions where the last_activity_at timestamp
is older than the provided cutoff time. Used by the cleanup scheduler
to periodically remove inactive sessions.
Args:
session_id (str): The unique identifier of the session.
cutoff_time (datetime): Sessions with last_activity_at before this time will be deleted.
db (Session): The SQLAlchemy database session.
Returns:
int: The number of sessions deleted.
Raises:
SessionNotFoundError: If the session does not exist.
HTTPException: If an error occurs during the update (500).
HTTPException: If an error occurs during deletion (500).
"""
try:
# Get the session from the database
db_session = (
# Delete sessions with last_activity_at older than cutoff_time
num_deleted = (
db.query(session_models.UsersSessions)
.filter(session_models.UsersSessions.id == session_id)
.first()
.filter(session_models.UsersSessions.last_activity_at < cutoff_time)
.delete()
)
# Check if the session exists
if not db_session:
raise SessionNotFoundError(f"Session {session_id} not found")
# Mark tokens as exchanged
db_session.tokens_exchanged = True
# Commit the transaction
db.commit()
except SessionNotFoundError as err:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail=str(err)
) from err
return num_deleted
except Exception as err:
# Rollback the transaction
db.rollback()
# Log the exception
core_logger.print_to_log(
f"Error in mark_tokens_exchanged: {err}", "error", exc=err
f"Error in delete_idle_sessions: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to mark tokens as exchanged",
) from err
def get_session_with_oauth_state(
session_id: str, db: Session
) -> tuple[session_models.UsersSessions, oauth_state_models.OAuthState | None] | None:
"""
Retrieve a session with its associated OAuthState for token exchange validation.
This function performs a query to retrieve a session along with its
linked OAuth state record (if any). Used during mobile token exchange to
validate PKCE and ensure the session is valid.
Args:
session_id (str): The unique identifier of the session.
db (Session): The SQLAlchemy database session.
Returns:
tuple[UsersSessions, OAuthState | None] | None: A tuple of (session, oauth_state)
where oauth_state may be None if not linked. Returns None if session not found.
Raises:
HTTPException: If an error occurs during retrieval (500).
"""
try:
# Query session
db_session = (
db.query(session_models.UsersSessions)
.filter(session_models.UsersSessions.id == session_id)
.filter(
session_models.UsersSessions.expires_at > datetime.now(timezone.utc)
)
.first()
)
if not db_session:
return None
# Get OAuth state if linked
oauth_state = None
if db_session.oauth_state_id:
oauth_state = (
db.query(oauth_state_models.OAuthState)
.filter(oauth_state_models.OAuthState.id == db_session.oauth_state_id)
.first()
)
return (db_session, oauth_state)
except Exception as err:
# Log the exception
core_logger.print_to_log(
f"Error in get_session_with_oauth_state: {err}", "error", exc=err
)
# Raise an HTTPException with a 500 Internal Server Error status code
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve session with OAuth state",
detail="Failed to delete idle sessions",
) from err

View File

@@ -25,6 +25,7 @@ class UsersSessions(Base):
browser (str): Browser used for the session.
browser_version (str): Version of the browser.
created_at (datetime): Timestamp when the session was created.
last_activity_at (datetime): Timestamp of last user activity (for idle timeout).
expires_at (datetime): Timestamp when the session expires.
user (User): Relationship to the User model.
"""
@@ -57,6 +58,9 @@ class UsersSessions(Base):
created_at = Column(
DateTime, nullable=False, comment="Session creation date (datetime)"
)
last_activity_at = Column(
DateTime, nullable=False, comment="Last activity timestamp for idle timeout"
)
expires_at = Column(
DateTime, nullable=False, comment="Session expiration date (datetime)"
)

View File

@@ -16,6 +16,7 @@ class UsersSessions(BaseModel):
browser (str): Browser name (max length: 45).
browser_version (str): Browser version (max length: 45).
created_at (datetime): Session creation timestamp.
last_activity_at (datetime): Last activity timestamp for idle timeout.
expires_at (datetime): Session expiration timestamp.
Config:
from_attributes (bool): Allows model initialization from attributes.
@@ -35,6 +36,9 @@ class UsersSessions(BaseModel):
browser: str = Field(..., max_length=45, description="Browser name")
browser_version: str = Field(..., max_length=45, description="Browser version")
created_at: datetime = Field(..., description="Session creation timestamp")
last_activity_at: datetime = Field(
..., description="Last activity timestamp for idle timeout"
)
expires_at: datetime = Field(..., description="Session expiration timestamp")
oauth_state_id: str | None = Field(
None, max_length=64, description="Link to OAuth state for PKCE validation"

View File

@@ -5,6 +5,8 @@ from datetime import datetime, timedelta, timezone
from dataclasses import dataclass
from fastapi import (
Request,
HTTPException,
status,
)
from user_agents import parse
@@ -17,6 +19,9 @@ import auth.password_hasher as auth_password_hasher
import users.user.schema as users_schema
import core.logger as core_logger
from core.database import SessionLocal
class DeviceType(Enum):
"""
@@ -53,6 +58,50 @@ class DeviceInfo:
browser_version: str
def validate_session_timeout(session: session_schema.UsersSessions) -> None:
"""
Validate session hasn't exceeded idle or absolute timeout.
Only enforces timeout when SESSION_IDLE_TIMEOUT_ENABLED=true.
Checks:
1. Idle timeout: last_activity_at must be within SESSION_IDLE_TIMEOUT_HOURS
2. Absolute timeout: created_at must be within SESSION_ABSOLUTE_TIMEOUT_HOURS
Args:
session: The session to validate
Raises:
HTTPException: 401 if session has timed out
"""
# Skip validation if timeouts are disabled
if not auth_constants.SESSION_IDLE_TIMEOUT_ENABLED:
return
now = datetime.now(timezone.utc)
# Check idle timeout
idle_limit = session.last_activity_at + timedelta(
hours=auth_constants.SESSION_IDLE_TIMEOUT_HOURS
)
if now > idle_limit:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired due to inactivity",
headers={"WWW-Authenticate": "Bearer"},
)
# Check absolute timeout
absolute_limit = session.created_at + timedelta(
hours=auth_constants.SESSION_ABSOLUTE_TIMEOUT_HOURS
)
if now > absolute_limit:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired. Please login again for security.",
headers={"WWW-Authenticate": "Bearer"},
)
def create_session_object(
session_id: str,
user: users_schema.UserRead,
@@ -78,6 +127,8 @@ def create_session_object(
user_agent = get_user_agent(request)
device_info = parse_user_agent(user_agent)
now = datetime.now(timezone.utc)
return session_schema.UsersSessions(
id=session_id,
user_id=user.id,
@@ -88,7 +139,8 @@ def create_session_object(
operating_system_version=device_info.operating_system_version,
browser=device_info.browser,
browser_version=device_info.browser_version,
created_at=datetime.now(timezone.utc),
created_at=now,
last_activity_at=now,
expires_at=refresh_token_exp,
oauth_state_id=oauth_state_id,
tokens_exchanged=False,
@@ -127,6 +179,7 @@ def edit_session_object(
browser=device_info.browser,
browser_version=device_info.browser_version,
created_at=session.created_at,
last_activity_at=datetime.now(timezone.utc),
expires_at=refresh_token_exp,
oauth_state_id=session.oauth_state_id,
tokens_exchanged=session.tokens_exchanged,
@@ -289,3 +342,41 @@ def parse_user_agent(user_agent: str) -> DeviceInfo:
browser=ua.browser.family or "Unknown",
browser_version=ua.browser.version_string or "Unknown",
)
def cleanup_idle_sessions():
"""
Clean up idle user sessions that have exceeded the timeout threshold.
This function removes sessions from the database that have been inactive for longer
than the configured idle timeout period. It only runs if session idle timeout is enabled.
The function:
1. Checks if session idle timeout is enabled via auth_constants
2. Calculates the cutoff time based on SESSION_IDLE_TIMEOUT_HOURS
3. Deletes sessions with last_activity_at older than the cutoff time
4. Logs the number of sessions cleaned up if any were removed
Returns:
None
Raises:
Any database-related exceptions from session_crud.delete_idle_sessions
are propagated to the caller.
Note:
The database session is always properly closed in the finally block.
"""
if not auth_constants.SESSION_IDLE_TIMEOUT_ENABLED:
return
db = SessionLocal()
try:
cutoff_time = datetime.now(timezone.utc) - timedelta(
hours=auth_constants.SESSION_IDLE_TIMEOUT_HOURS
)
# Delete sessions with last_activity_at older than cutoff
deleted_count = session_crud.delete_idle_sessions(cutoff_time, db)
if deleted_count > 0:
core_logger.print_to_log(
f"Cleaned up {deleted_count} idle sessions", "info"
)
finally:
db.close()

View File

@@ -41,6 +41,9 @@ Table below shows supported environment variables. Variables marked with optiona
| ALGORITHM | HS256 | Yes | Currently only HS256 is supported |
| ACCESS_TOKEN_EXPIRE_MINUTES | 15 | Yes | Time in minutes |
| REFRESH_TOKEN_EXPIRE_DAYS | 7 | Yes | Time in days |
| SESSION_IDLE_TIMEOUT_ENABLED | false | Yes | Enforce idle timeouts (supported values are `true` and `false`) |
| SESSION_IDLE_TIMEOUT_HOURS | 1 | Yes | Time in hours |
| SESSION_ABSOLUTE_TIMEOUT_HOURS | 24 | Yes | Time in hours |
| JAEGER_ENABLED | false | Yes | N/A |
| JAEGER_PROTOCOL | http | Yes | N/A |
| JAEGER_HOST | jaeger | Yes | N/A |
@@ -70,6 +73,26 @@ Frontend dependencies:
- To check npm dependencies used, use npm file (package.json)
- Logo created on Canva
## Session Timeout Configuration (Optional)
By default, Endurain sessions last 7 days without enforcing idle timeouts.
For enhanced security, you can enable automatic session expiration:
**Environment Variables:**
- `SESSION_IDLE_TIMEOUT_ENABLED`: Enable timeout enforcement (default: `false`)
- `SESSION_IDLE_TIMEOUT_HOURS`: Logout after inactivity (default: `1`)
- `SESSION_ABSOLUTE_TIMEOUT_HOURS`: Force re-login after duration (default: `24`)
**Example:**
```yaml
environment:
SESSION_IDLE_TIMEOUT_ENABLED: "true"
SESSION_IDLE_TIMEOUT_HOURS: "2"
SESSION_ABSOLUTE_TIMEOUT_HOURS: "48"
```
## Docker Secrets Support
Endurain supports [Docker secrets](https://docs.docker.com/compose/how-tos/use-secrets/) for securely managing sensitive environment variables. For the following environment variables, you can use `_FILE` variants that read the secret from a file instead of storing it directly in environment variables: