mdb-engine 0.5.1__tar.gz → 0.6.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {mdb_engine-0.5.1/mdb_engine.egg-info → mdb_engine-0.6.0}/PKG-INFO +1 -1
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/__init__.py +13 -9
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/__init__.py +9 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/csrf.py +273 -40
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/shared_users.py +32 -2
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/utils.py +31 -6
- mdb_engine-0.6.0/mdb_engine/auth/websocket_sessions.py +433 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/engine.py +41 -1
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/manifest.py +12 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/types.py +1 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/routing/websockets.py +54 -10
- {mdb_engine-0.5.1 → mdb_engine-0.6.0/mdb_engine.egg-info}/PKG-INFO +1 -1
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine.egg-info/SOURCES.txt +1 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/pyproject.toml +1 -1
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/setup.py +1 -1
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/LICENSE +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/MANIFEST.in +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/README.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/README.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/ARCHITECTURE.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/README.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/audit.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/base.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/casbin_factory.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/casbin_models.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/config_defaults.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/config_helpers.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/cookie_utils.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/decorators.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/dependencies.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/helpers.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/integration.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/jwt.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/middleware.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/oso_factory.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/provider.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/rate_limiter.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/restrictions.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/session_manager.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/shared_middleware.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/token_lifecycle.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/token_store.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/auth/users.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/cli/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/cli/commands/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/cli/commands/generate.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/cli/commands/migrate.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/cli/commands/show.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/cli/commands/validate.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/cli/main.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/cli/utils.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/config.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/constants.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/README.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/app_registration.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/app_secrets.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/connection.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/encryption.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/index_management.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/ray_integration.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/seeding.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/core/service_initialization.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/database/README.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/database/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/database/abstraction.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/database/connection.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/database/query_validator.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/database/resource_limiter.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/database/scoped_wrapper.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/dependencies.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/di/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/di/container.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/di/providers.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/di/scopes.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/embeddings/README.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/embeddings/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/embeddings/dependencies.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/embeddings/service.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/exceptions.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/indexes/README.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/indexes/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/indexes/helpers.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/indexes/manager.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/memory/README.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/memory/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/memory/service.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/observability/README.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/observability/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/observability/health.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/observability/logging.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/observability/metrics.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/repositories/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/repositories/base.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/repositories/mongo.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/repositories/unit_of_work.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/routing/README.md +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/routing/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/utils/__init__.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine/utils/mongo.py +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine.egg-info/dependency_links.txt +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine.egg-info/entry_points.txt +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine.egg-info/requires.txt +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/mdb_engine.egg-info/top_level.txt +0 -0
- {mdb_engine-0.5.1 → mdb_engine-0.6.0}/setup.cfg +0 -0
|
@@ -82,15 +82,19 @@ from .repositories import Entity, MongoRepository, Repository, UnitOfWork
|
|
|
82
82
|
from .utils import clean_mongo_doc, clean_mongo_docs
|
|
83
83
|
|
|
84
84
|
__version__ = (
|
|
85
|
-
"0.
|
|
86
|
-
# -
|
|
87
|
-
# - NEW:
|
|
88
|
-
# -
|
|
89
|
-
# -
|
|
90
|
-
# -
|
|
91
|
-
# -
|
|
92
|
-
# -
|
|
93
|
-
# -
|
|
85
|
+
"0.6.0" # Secure-by-default WebSocket authentication with encrypted session keys
|
|
86
|
+
# - NEW: WebSocket session key generation and management
|
|
87
|
+
# - NEW: Envelope encryption for WebSocket session keys
|
|
88
|
+
# - NEW: Secure-by-default CSRF protection (csrf_required: true)
|
|
89
|
+
# - NEW: WebSocketSessionManager with private collection storage
|
|
90
|
+
# - NEW: Session key endpoint (/auth/websocket-session)
|
|
91
|
+
# - NEW: Session key integration in login flow
|
|
92
|
+
# - ENHANCED: WebSocket authentication with session key support
|
|
93
|
+
# - ENHANCED: CSRF middleware session key validation
|
|
94
|
+
# - ENHANCED: Multi-app WebSocket routing with session keys
|
|
95
|
+
# - BACKWARD COMPATIBLE: Cookie-based authentication fallback
|
|
96
|
+
# - UPDATED: All documentation for secure-by-default approach
|
|
97
|
+
# - COMPREHENSIVE: Unit and integration tests for session keys
|
|
94
98
|
)
|
|
95
99
|
|
|
96
100
|
__all__ = [
|
|
@@ -125,6 +125,12 @@ from .utils import (
|
|
|
125
125
|
validate_password_strength_async,
|
|
126
126
|
)
|
|
127
127
|
|
|
128
|
+
# WebSocket sessions
|
|
129
|
+
from .websocket_sessions import (
|
|
130
|
+
WebSocketSessionManager,
|
|
131
|
+
create_websocket_session_endpoint,
|
|
132
|
+
)
|
|
133
|
+
|
|
128
134
|
__all__ = [
|
|
129
135
|
# Base classes
|
|
130
136
|
"BaseAuthorizationProvider",
|
|
@@ -232,4 +238,7 @@ __all__ = [
|
|
|
232
238
|
"generate_csrf_token",
|
|
233
239
|
"validate_csrf_token",
|
|
234
240
|
"get_csrf_token",
|
|
241
|
+
# WebSocket sessions
|
|
242
|
+
"WebSocketSessionManager",
|
|
243
|
+
"create_websocket_session_endpoint",
|
|
235
244
|
]
|
|
@@ -102,14 +102,16 @@ def validate_csrf_token(
|
|
|
102
102
|
try:
|
|
103
103
|
parts = token.split(":")
|
|
104
104
|
if len(parts) != 3:
|
|
105
|
+
logger.debug("CSRF token has wrong format (expected 3 parts)")
|
|
105
106
|
return False
|
|
106
107
|
|
|
107
108
|
raw_token, timestamp_str, signature = parts
|
|
108
109
|
timestamp = int(timestamp_str)
|
|
109
110
|
|
|
110
111
|
# Check age
|
|
111
|
-
|
|
112
|
-
|
|
112
|
+
age = time.time() - timestamp
|
|
113
|
+
if age > max_age:
|
|
114
|
+
logger.debug(f"CSRF token expired (age: {age:.0f}s, max: {max_age}s)")
|
|
113
115
|
return False
|
|
114
116
|
|
|
115
117
|
# Verify signature
|
|
@@ -119,7 +121,10 @@ def validate_csrf_token(
|
|
|
119
121
|
]
|
|
120
122
|
|
|
121
123
|
if not hmac.compare_digest(signature, expected_sig):
|
|
122
|
-
logger.warning(
|
|
124
|
+
logger.warning(
|
|
125
|
+
f"CSRF token signature mismatch. "
|
|
126
|
+
f"Token format: signed, Has secret: {bool(secret)}"
|
|
127
|
+
)
|
|
123
128
|
return False
|
|
124
129
|
|
|
125
130
|
return True
|
|
@@ -128,7 +133,10 @@ def validate_csrf_token(
|
|
|
128
133
|
return False
|
|
129
134
|
|
|
130
135
|
# Simple token validation (just check it exists and has reasonable length)
|
|
131
|
-
|
|
136
|
+
is_valid = len(token) >= CSRF_TOKEN_LENGTH
|
|
137
|
+
if not is_valid:
|
|
138
|
+
logger.debug(f"CSRF token too short (length: {len(token)}, required: {CSRF_TOKEN_LENGTH})")
|
|
139
|
+
return is_valid
|
|
132
140
|
|
|
133
141
|
|
|
134
142
|
class CSRFMiddleware(BaseHTTPMiddleware):
|
|
@@ -197,10 +205,116 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|
|
197
205
|
return True
|
|
198
206
|
return False
|
|
199
207
|
|
|
208
|
+
def _websocket_requires_csrf(self, request: Request, path: str) -> bool:
|
|
209
|
+
"""
|
|
210
|
+
Check if WebSocket endpoint requires CSRF validation.
|
|
211
|
+
|
|
212
|
+
Defaults to True (security by default). Can be disabled per-endpoint via manifest.json:
|
|
213
|
+
websockets.{endpoint}.auth.csrf_required = false
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
request: FastAPI request
|
|
217
|
+
path: WebSocket path (e.g., "/app-3/ws")
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
True if CSRF validation is required, False otherwise
|
|
221
|
+
"""
|
|
222
|
+
# Check parent app state for WebSocket configs
|
|
223
|
+
websocket_configs = getattr(request.app.state, "websocket_configs", None)
|
|
224
|
+
if not websocket_configs:
|
|
225
|
+
# No WebSocket configs found - use default (CSRF required for security by default)
|
|
226
|
+
return True
|
|
227
|
+
|
|
228
|
+
# Normalize path for matching
|
|
229
|
+
normalized_path = path.rstrip("/")
|
|
230
|
+
|
|
231
|
+
# Try to find matching app config
|
|
232
|
+
# WebSocket paths are registered as /app-slug/endpoint-path
|
|
233
|
+
# e.g., /app-3/ws where app_slug="app-3" and endpoint_path="/ws"
|
|
234
|
+
for app_slug, config in websocket_configs.items():
|
|
235
|
+
# Check each endpoint in this app's config
|
|
236
|
+
for endpoint_name, endpoint_config in config.items():
|
|
237
|
+
endpoint_path = endpoint_config.get("path", "")
|
|
238
|
+
# Normalize endpoint path
|
|
239
|
+
normalized_endpoint = endpoint_path.rstrip("/")
|
|
240
|
+
|
|
241
|
+
# Match patterns:
|
|
242
|
+
# 1. Full path match: /app-slug/endpoint-path
|
|
243
|
+
# 2. Endpoint-only match: /endpoint-path (if path starts with endpoint)
|
|
244
|
+
expected_full_path = f"/{app_slug}{normalized_endpoint}"
|
|
245
|
+
if (
|
|
246
|
+
normalized_path == expected_full_path
|
|
247
|
+
or normalized_path.endswith(normalized_endpoint)
|
|
248
|
+
or normalized_path == normalized_endpoint
|
|
249
|
+
):
|
|
250
|
+
auth_config = endpoint_config.get("auth", {})
|
|
251
|
+
if isinstance(auth_config, dict):
|
|
252
|
+
# Return csrf_required setting (defaults to True - security by default)
|
|
253
|
+
csrf_required = auth_config.get("csrf_required", True)
|
|
254
|
+
logger.debug(
|
|
255
|
+
f"WebSocket {path} csrf_required={csrf_required} "
|
|
256
|
+
f"(from app={app_slug}, endpoint={endpoint_name})"
|
|
257
|
+
)
|
|
258
|
+
return csrf_required
|
|
259
|
+
|
|
260
|
+
# No matching config found - use default (CSRF required for security by default)
|
|
261
|
+
logger.debug(f"No WebSocket config match for {path}, using default csrf_required=true")
|
|
262
|
+
return True
|
|
263
|
+
|
|
200
264
|
def _is_websocket_upgrade(self, request: Request) -> bool:
|
|
201
265
|
"""Check if request is a WebSocket upgrade request."""
|
|
202
266
|
upgrade_header = request.headers.get("upgrade", "").lower()
|
|
203
|
-
|
|
267
|
+
connection_header = request.headers.get("connection", "").lower()
|
|
268
|
+
|
|
269
|
+
# Primary check: WebSocket upgrade requires both Upgrade: websocket
|
|
270
|
+
# and Connection: Upgrade headers
|
|
271
|
+
has_upgrade_header = upgrade_header == "websocket"
|
|
272
|
+
has_connection_upgrade = "upgrade" in connection_header or "websocket" in connection_header
|
|
273
|
+
|
|
274
|
+
# Secondary check: If upgrade header is present but connection is
|
|
275
|
+
# overridden (e.g., by TestClient), check if path matches a known
|
|
276
|
+
# WebSocket route pattern
|
|
277
|
+
path_matches_websocket_route = False
|
|
278
|
+
if has_upgrade_header and not has_connection_upgrade:
|
|
279
|
+
# Check if path matches any configured WebSocket route
|
|
280
|
+
websocket_configs = getattr(request.app.state, "websocket_configs", None)
|
|
281
|
+
if websocket_configs:
|
|
282
|
+
path = request.url.path.rstrip("/") or "/"
|
|
283
|
+
for app_slug, config in websocket_configs.items():
|
|
284
|
+
for _endpoint_name, endpoint_config in config.items():
|
|
285
|
+
endpoint_path = endpoint_config.get("path", "").rstrip("/") or "/"
|
|
286
|
+
# Try various path matching patterns
|
|
287
|
+
expected_full_path = (
|
|
288
|
+
f"/{app_slug}{endpoint_path}"
|
|
289
|
+
if endpoint_path != "/"
|
|
290
|
+
else f"/{app_slug}"
|
|
291
|
+
)
|
|
292
|
+
# Match patterns:
|
|
293
|
+
# 1. Exact match with app prefix: /app-slug/endpoint-path
|
|
294
|
+
# 2. Endpoint-only match: /endpoint-path (if path ends with endpoint)
|
|
295
|
+
# 3. Root match: / matches / or /app-slug
|
|
296
|
+
if (
|
|
297
|
+
path == expected_full_path
|
|
298
|
+
or path.endswith(endpoint_path)
|
|
299
|
+
or path == endpoint_path
|
|
300
|
+
or (path == "/" and endpoint_path == "/")
|
|
301
|
+
or (path == f"/{app_slug}" and endpoint_path == "/")
|
|
302
|
+
):
|
|
303
|
+
path_matches_websocket_route = True
|
|
304
|
+
break
|
|
305
|
+
if path_matches_websocket_route:
|
|
306
|
+
break
|
|
307
|
+
|
|
308
|
+
is_websocket = has_upgrade_header and (
|
|
309
|
+
has_connection_upgrade or path_matches_websocket_route
|
|
310
|
+
)
|
|
311
|
+
if is_websocket:
|
|
312
|
+
logger.debug(
|
|
313
|
+
f"WebSocket upgrade detected: path={request.url.path}, "
|
|
314
|
+
f"upgrade={upgrade_header}, connection={connection_header}, "
|
|
315
|
+
f"path_match={path_matches_websocket_route}"
|
|
316
|
+
)
|
|
317
|
+
return is_websocket
|
|
204
318
|
|
|
205
319
|
def _get_allowed_origins(self, request: Request) -> list[str]:
|
|
206
320
|
"""
|
|
@@ -298,9 +412,22 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|
|
298
412
|
path = request.url.path
|
|
299
413
|
method = request.method
|
|
300
414
|
|
|
415
|
+
# Debug: Log all requests to see what's happening
|
|
416
|
+
upgrade_header = request.headers.get("upgrade", "").lower()
|
|
417
|
+
connection_header = request.headers.get("connection", "").lower()
|
|
418
|
+
if upgrade_header or "websocket" in path.lower():
|
|
419
|
+
logger.info(
|
|
420
|
+
f"🔍 CSRF middleware: {method} {path}, "
|
|
421
|
+
f"upgrade={upgrade_header}, connection={connection_header}"
|
|
422
|
+
)
|
|
423
|
+
|
|
301
424
|
# CRITICAL: Handle WebSocket upgrade requests BEFORE other CSRF checks
|
|
302
425
|
# WebSocket upgrades use cookie-based authentication and require CSRF validation
|
|
303
426
|
if self._is_websocket_upgrade(request):
|
|
427
|
+
logger.info(
|
|
428
|
+
f"🔌 CSRF middleware processing WebSocket upgrade: {path}, "
|
|
429
|
+
f"origin: {request.headers.get('origin')}"
|
|
430
|
+
)
|
|
304
431
|
# Always validate origin for WebSocket connections (CSWSH protection)
|
|
305
432
|
if not self._validate_websocket_origin(request):
|
|
306
433
|
logger.warning(
|
|
@@ -320,49 +447,155 @@ class CSRFMiddleware(BaseHTTPMiddleware):
|
|
|
320
447
|
|
|
321
448
|
auth_token_cookie = request.cookies.get(AUTH_COOKIE_NAME)
|
|
322
449
|
if auth_token_cookie:
|
|
323
|
-
#
|
|
450
|
+
# SECURITY BY DEFAULT: WebSocket CSRF protection uses encrypted session keys
|
|
451
|
+
# stored in private collection via envelope encryption.
|
|
452
|
+
#
|
|
453
|
+
# Security Model:
|
|
324
454
|
# 1. Origin validation (already done above) - primary defense
|
|
325
|
-
# 2.
|
|
326
|
-
# 3.
|
|
455
|
+
# 2. Encrypted session key validation - CSRF protection via database
|
|
456
|
+
# 3. SameSite cookies - prevents cross-site cookie sending
|
|
327
457
|
#
|
|
328
|
-
#
|
|
329
|
-
#
|
|
330
|
-
#
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
458
|
+
# Session keys are:
|
|
459
|
+
# - Generated on authentication
|
|
460
|
+
# - Encrypted using envelope encryption (same as app secrets)
|
|
461
|
+
# - Stored in _mdb_engine_websocket_sessions private collection
|
|
462
|
+
# - Validated during WebSocket upgrade
|
|
463
|
+
|
|
464
|
+
# Check if this WebSocket endpoint requires CSRF validation
|
|
465
|
+
csrf_required = self._websocket_requires_csrf(request, path)
|
|
466
|
+
|
|
467
|
+
if csrf_required:
|
|
468
|
+
# Try to get WebSocket session manager from app state
|
|
469
|
+
websocket_session_manager = getattr(
|
|
470
|
+
request.app.state, "websocket_session_manager", None
|
|
337
471
|
)
|
|
338
472
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
473
|
+
if websocket_session_manager:
|
|
474
|
+
# Use encrypted session key validation (secure-by-default)
|
|
475
|
+
session_key = request.query_params.get(
|
|
476
|
+
"session_key"
|
|
477
|
+
) or request.headers.get("X-WebSocket-Session-Key")
|
|
478
|
+
|
|
479
|
+
if not session_key:
|
|
480
|
+
logger.error(
|
|
481
|
+
f"❌ WebSocket upgrade missing session key for {path}. "
|
|
482
|
+
f"Auth cookie present: {bool(auth_token_cookie)}. "
|
|
483
|
+
f"Tip: Generate session key via /auth/websocket-session endpoint."
|
|
484
|
+
)
|
|
485
|
+
return JSONResponse(
|
|
486
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
487
|
+
content={
|
|
488
|
+
"detail": (
|
|
489
|
+
"WebSocket session key missing. "
|
|
490
|
+
"Generate session key via /auth/websocket-session endpoint."
|
|
491
|
+
)
|
|
492
|
+
},
|
|
493
|
+
)
|
|
494
|
+
|
|
495
|
+
# Validate session key against encrypted storage
|
|
496
|
+
try:
|
|
497
|
+
session_data = await websocket_session_manager.validate_session(
|
|
498
|
+
session_key
|
|
499
|
+
)
|
|
500
|
+
if not session_data:
|
|
501
|
+
logger.error(
|
|
502
|
+
f"❌ WebSocket session key validation failed for {path}. "
|
|
503
|
+
f"Session key: {session_key[:16]}..."
|
|
504
|
+
)
|
|
505
|
+
return JSONResponse(
|
|
506
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
507
|
+
content={
|
|
508
|
+
"detail": (
|
|
509
|
+
"WebSocket session key expired or invalid. "
|
|
510
|
+
"Generate a new session key."
|
|
511
|
+
)
|
|
512
|
+
},
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
# Store session data in request state for WebSocket handler
|
|
516
|
+
request.state.websocket_session = session_data
|
|
517
|
+
logger.debug(
|
|
518
|
+
f"✅ WebSocket session key validated for {path} "
|
|
519
|
+
f"(user: {session_data.get('user_id')})"
|
|
520
|
+
)
|
|
521
|
+
except (
|
|
522
|
+
ValueError,
|
|
523
|
+
TypeError,
|
|
524
|
+
AttributeError,
|
|
525
|
+
RuntimeError,
|
|
526
|
+
):
|
|
527
|
+
logger.exception("Error validating WebSocket session key")
|
|
528
|
+
return JSONResponse(
|
|
529
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
530
|
+
content={"detail": "WebSocket session validation error"},
|
|
531
|
+
)
|
|
532
|
+
else:
|
|
533
|
+
# Fallback to cookie-based CSRF (backward compatibility)
|
|
534
|
+
csrf_cookie_token = request.cookies.get(self.cookie_name)
|
|
535
|
+
if not csrf_cookie_token:
|
|
536
|
+
logger.error(
|
|
537
|
+
f"❌ WebSocket upgrade missing CSRF cookie for {path}. "
|
|
538
|
+
f"Auth cookie present: {bool(auth_token_cookie)}, "
|
|
539
|
+
f"CSRF cookie name: {self.cookie_name}, "
|
|
540
|
+
f"Available cookies: {list(request.cookies.keys())}. "
|
|
541
|
+
f"Tip: Make a GET request first to receive CSRF cookie."
|
|
542
|
+
)
|
|
543
|
+
return JSONResponse(
|
|
544
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
545
|
+
content={
|
|
546
|
+
"detail": (
|
|
547
|
+
"CSRF token missing for WebSocket authentication. "
|
|
548
|
+
"Make a GET request first to receive the CSRF cookie."
|
|
549
|
+
)
|
|
550
|
+
},
|
|
551
|
+
)
|
|
552
|
+
|
|
553
|
+
# Validate CSRF token signature if secret is used
|
|
554
|
+
if self.secret and not validate_csrf_token(
|
|
555
|
+
csrf_cookie_token, self.secret, self.token_ttl
|
|
556
|
+
):
|
|
557
|
+
logger.error(f"❌ WebSocket CSRF token validation failed for {path}.")
|
|
558
|
+
return JSONResponse(
|
|
559
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
560
|
+
content={
|
|
561
|
+
"detail": (
|
|
562
|
+
"CSRF token expired or invalid for WebSocket connection"
|
|
563
|
+
)
|
|
564
|
+
},
|
|
565
|
+
)
|
|
566
|
+
|
|
567
|
+
# If CSRF header is provided, validate it matches the cookie
|
|
568
|
+
# (Header is optional for WebSocket, but if present, must match cookie)
|
|
569
|
+
csrf_header_token = request.headers.get(self.header_name)
|
|
570
|
+
if csrf_header_token:
|
|
571
|
+
if not hmac.compare_digest(csrf_cookie_token, csrf_header_token):
|
|
572
|
+
logger.error(
|
|
573
|
+
f"❌ WebSocket CSRF header mismatch for {path}. "
|
|
574
|
+
f"Cookie token and header token do not match."
|
|
575
|
+
)
|
|
576
|
+
return JSONResponse(
|
|
577
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
578
|
+
content={
|
|
579
|
+
"detail": (
|
|
580
|
+
"CSRF token mismatch: header token does not "
|
|
581
|
+
"match cookie token"
|
|
582
|
+
)
|
|
583
|
+
},
|
|
584
|
+
)
|
|
585
|
+
logger.debug(
|
|
586
|
+
f"✅ CSRF header validated and matches cookie for WebSocket {path}"
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
logger.debug(f"✅ CSRF cookie validation passed for WebSocket {path}")
|
|
590
|
+
else:
|
|
591
|
+
logger.debug(
|
|
592
|
+
f"✅ WebSocket CSRF validation skipped for {path} "
|
|
593
|
+
f"(csrf_required=false, Origin validation sufficient)"
|
|
349
594
|
)
|
|
350
595
|
|
|
351
|
-
# Optional: If CSRF header is provided, validate it matches cookie
|
|
352
|
-
# (Some clients may send it, but it's not required for WebSocket upgrades)
|
|
353
|
-
header_token = request.headers.get(self.header_name)
|
|
354
|
-
if header_token:
|
|
355
|
-
# If header is provided, validate it matches cookie (double-submit pattern)
|
|
356
|
-
if not hmac.compare_digest(csrf_cookie_token, header_token):
|
|
357
|
-
logger.warning(f"WebSocket CSRF token mismatch for {path}")
|
|
358
|
-
return JSONResponse(
|
|
359
|
-
status_code=status.HTTP_403_FORBIDDEN,
|
|
360
|
-
content={"detail": "CSRF token invalid for WebSocket connection"},
|
|
361
|
-
)
|
|
362
|
-
|
|
363
596
|
logger.debug(
|
|
364
597
|
f"WebSocket upgrade CSRF validation passed for {path} "
|
|
365
|
-
f"(Origin validated, CSRF
|
|
598
|
+
f"(Origin validated, CSRF validated)"
|
|
366
599
|
)
|
|
367
600
|
|
|
368
601
|
# Origin validated (and CSRF validated if authenticated)
|
|
@@ -120,6 +120,7 @@ class SharedUserPool:
|
|
|
120
120
|
token_expiry_hours: int = DEFAULT_TOKEN_EXPIRY_HOURS,
|
|
121
121
|
allow_insecure_dev: bool = False,
|
|
122
122
|
blacklist_fail_closed: bool = True,
|
|
123
|
+
websocket_session_manager: Any | None = None,
|
|
123
124
|
):
|
|
124
125
|
"""
|
|
125
126
|
Initialize the shared user pool.
|
|
@@ -174,6 +175,7 @@ class SharedUserPool:
|
|
|
174
175
|
|
|
175
176
|
self._token_expiry_hours = token_expiry_hours
|
|
176
177
|
self._blacklist_indexes_created = False
|
|
178
|
+
self._websocket_session_manager = websocket_session_manager
|
|
177
179
|
|
|
178
180
|
logger.info(f"SharedUserPool initialized (algorithm={jwt_algorithm})")
|
|
179
181
|
|
|
@@ -340,7 +342,9 @@ class SharedUserPool:
|
|
|
340
342
|
ip_address: str | None = None,
|
|
341
343
|
fingerprint: str | None = None,
|
|
342
344
|
session_binding: dict[str, Any] | None = None,
|
|
343
|
-
|
|
345
|
+
generate_websocket_session: bool = True,
|
|
346
|
+
app_slug: str | None = None,
|
|
347
|
+
) -> str | tuple[str, str] | None:
|
|
344
348
|
"""
|
|
345
349
|
Authenticate user and return JWT token.
|
|
346
350
|
|
|
@@ -352,9 +356,14 @@ class SharedUserPool:
|
|
|
352
356
|
session_binding: Session binding config from manifest:
|
|
353
357
|
- bind_ip: Include IP in token claims
|
|
354
358
|
- bind_fingerprint: Include fingerprint in token claims
|
|
359
|
+
generate_websocket_session: If True and WebSocket session manager available,
|
|
360
|
+
also generate WebSocket session key (default: True)
|
|
361
|
+
app_slug: Optional app slug for WebSocket session scoping
|
|
355
362
|
|
|
356
363
|
Returns:
|
|
357
|
-
JWT token if authentication succeeds, None otherwise
|
|
364
|
+
JWT token if authentication succeeds, None otherwise.
|
|
365
|
+
If generate_websocket_session=True and session manager available,
|
|
366
|
+
returns tuple (jwt_token, websocket_session_key), otherwise just jwt_token.
|
|
358
367
|
"""
|
|
359
368
|
user = await self._collection.find_one(
|
|
360
369
|
{
|
|
@@ -392,7 +401,28 @@ class SharedUserPool:
|
|
|
392
401
|
# Generate JWT token with session binding claims
|
|
393
402
|
token = self._generate_token(user, extra_claims=extra_claims or None)
|
|
394
403
|
|
|
404
|
+
# Generate WebSocket session key if requested and manager available
|
|
405
|
+
websocket_session_key = None
|
|
406
|
+
if generate_websocket_session and self._websocket_session_manager:
|
|
407
|
+
try:
|
|
408
|
+
user_id = str(user["_id"])
|
|
409
|
+
websocket_session_key = await self._websocket_session_manager.create_session(
|
|
410
|
+
user_id=user_id,
|
|
411
|
+
user_email=email,
|
|
412
|
+
app_slug=app_slug,
|
|
413
|
+
)
|
|
414
|
+
logger.debug(
|
|
415
|
+
f"Generated WebSocket session key for user '{email}' " f"(app: {app_slug})"
|
|
416
|
+
)
|
|
417
|
+
except (ValueError, TypeError, AttributeError, RuntimeError) as e:
|
|
418
|
+
# Log but don't fail authentication if WebSocket session generation fails
|
|
419
|
+
logger.warning(f"Failed to generate WebSocket session key: {e}")
|
|
420
|
+
|
|
395
421
|
logger.info(f"User '{email}' authenticated successfully")
|
|
422
|
+
|
|
423
|
+
# Return tuple if WebSocket session key was generated, otherwise just token
|
|
424
|
+
if websocket_session_key:
|
|
425
|
+
return (token, websocket_session_key)
|
|
396
426
|
return token
|
|
397
427
|
|
|
398
428
|
async def validate_token(self, token: str) -> dict[str, Any] | None:
|
|
@@ -514,16 +514,41 @@ async def login_user(
|
|
|
514
514
|
ip_address=device_info.get("ip_address"),
|
|
515
515
|
)
|
|
516
516
|
|
|
517
|
+
# Generate WebSocket session key if WebSocket session manager available
|
|
518
|
+
websocket_session_key = None
|
|
519
|
+
try:
|
|
520
|
+
# Try to get WebSocket session manager from app state
|
|
521
|
+
app = getattr(request, "app", None)
|
|
522
|
+
if app:
|
|
523
|
+
websocket_session_manager = getattr(app.state, "websocket_session_manager", None)
|
|
524
|
+
if websocket_session_manager:
|
|
525
|
+
# Get app slug from request state if available
|
|
526
|
+
app_slug = getattr(request.state, "app_slug", None)
|
|
527
|
+
websocket_session_key = await websocket_session_manager.create_session(
|
|
528
|
+
user_id=str(user["_id"]),
|
|
529
|
+
user_email=user["email"],
|
|
530
|
+
app_slug=app_slug,
|
|
531
|
+
)
|
|
532
|
+
logger.debug(
|
|
533
|
+
f"Generated WebSocket session key for user '{user['email']}' "
|
|
534
|
+
f"(app: {app_slug})"
|
|
535
|
+
)
|
|
536
|
+
except (ValueError, TypeError, AttributeError, RuntimeError) as e:
|
|
537
|
+
# Log but don't fail login if WebSocket session generation fails
|
|
538
|
+
logger.warning(f"Failed to generate WebSocket session key during login: {e}")
|
|
539
|
+
|
|
517
540
|
# Create response
|
|
541
|
+
response_data = {
|
|
542
|
+
"success": True,
|
|
543
|
+
"user": {"email": user["email"], "user_id": str(user["_id"])},
|
|
544
|
+
}
|
|
545
|
+
if websocket_session_key:
|
|
546
|
+
response_data["websocket_session_key"] = websocket_session_key
|
|
547
|
+
|
|
518
548
|
if redirect_url:
|
|
519
549
|
response = RedirectResponse(url=redirect_url, status_code=302)
|
|
520
550
|
else:
|
|
521
|
-
response = JSONResponse(
|
|
522
|
-
{
|
|
523
|
-
"success": True,
|
|
524
|
-
"user": {"email": user["email"], "user_id": str(user["_id"])},
|
|
525
|
-
}
|
|
526
|
-
)
|
|
551
|
+
response = JSONResponse(response_data)
|
|
527
552
|
|
|
528
553
|
# Set cookies
|
|
529
554
|
set_auth_cookies(
|