mirror of
https://github.com/joaovitoriasilva/endurain.git
synced 2026-01-08 23:38:01 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user