mdb-engine 0.5.1__py3-none-any.whl → 0.6.0__py3-none-any.whl
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/__init__.py +13 -9
- mdb_engine/auth/__init__.py +9 -0
- mdb_engine/auth/csrf.py +273 -40
- mdb_engine/auth/shared_users.py +32 -2
- mdb_engine/auth/utils.py +31 -6
- mdb_engine/auth/websocket_sessions.py +433 -0
- mdb_engine/core/engine.py +41 -1
- mdb_engine/core/manifest.py +12 -0
- mdb_engine/core/types.py +1 -0
- mdb_engine/routing/websockets.py +54 -10
- {mdb_engine-0.5.1.dist-info → mdb_engine-0.6.0.dist-info}/METADATA +1 -1
- {mdb_engine-0.5.1.dist-info → mdb_engine-0.6.0.dist-info}/RECORD +16 -15
- {mdb_engine-0.5.1.dist-info → mdb_engine-0.6.0.dist-info}/WHEEL +0 -0
- {mdb_engine-0.5.1.dist-info → mdb_engine-0.6.0.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.5.1.dist-info → mdb_engine-0.6.0.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.5.1.dist-info → mdb_engine-0.6.0.dist-info}/top_level.txt +0 -0
mdb_engine/__init__.py
CHANGED
|
@@ -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__ = [
|
mdb_engine/auth/__init__.py
CHANGED
|
@@ -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
|
]
|
mdb_engine/auth/csrf.py
CHANGED
|
@@ -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)
|
mdb_engine/auth/shared_users.py
CHANGED
|
@@ -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:
|
mdb_engine/auth/utils.py
CHANGED
|
@@ -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(
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
"""
|
|
2
|
+
WebSocket Session Manager with Envelope Encryption
|
|
3
|
+
|
|
4
|
+
Manages WebSocket session keys using envelope encryption and private collections.
|
|
5
|
+
Provides secure-by-default WebSocket authentication without relying on CSRF cookies.
|
|
6
|
+
|
|
7
|
+
This module is part of MDB_ENGINE - MongoDB Engine.
|
|
8
|
+
|
|
9
|
+
Security Model:
|
|
10
|
+
- Session keys generated on authentication
|
|
11
|
+
- Stored encrypted in _mdb_engine_websocket_sessions collection
|
|
12
|
+
- Validated during WebSocket upgrade
|
|
13
|
+
- Uses envelope encryption (same as app secrets)
|
|
14
|
+
- Security by default: CSRF always required
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import logging
|
|
19
|
+
import secrets
|
|
20
|
+
from collections.abc import Callable
|
|
21
|
+
from datetime import datetime, timedelta
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
25
|
+
from pymongo.errors import OperationFailure, PyMongoError
|
|
26
|
+
|
|
27
|
+
from ..core.encryption import EnvelopeEncryptionService
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
# Collection name for storing encrypted WebSocket session keys
|
|
32
|
+
WEBSOCKET_SESSIONS_COLLECTION_NAME = "_mdb_engine_websocket_sessions"
|
|
33
|
+
|
|
34
|
+
# Session key configuration
|
|
35
|
+
SESSION_KEY_SIZE = 32 # 256 bits
|
|
36
|
+
SESSION_TTL_HOURS = 24 # Sessions expire after 24 hours
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class WebSocketSessionManager:
|
|
40
|
+
"""
|
|
41
|
+
Manages WebSocket session keys using envelope encryption.
|
|
42
|
+
|
|
43
|
+
Session keys are:
|
|
44
|
+
- Generated on user authentication
|
|
45
|
+
- Encrypted using envelope encryption
|
|
46
|
+
- Stored in private collection (_mdb_engine_websocket_sessions)
|
|
47
|
+
- Validated during WebSocket upgrade
|
|
48
|
+
- Automatically expired after TTL
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
mongo_db: AsyncIOMotorDatabase,
|
|
54
|
+
encryption_service: EnvelopeEncryptionService,
|
|
55
|
+
):
|
|
56
|
+
"""
|
|
57
|
+
Initialize the WebSocket session manager.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
mongo_db: MongoDB database instance (raw, not scoped)
|
|
61
|
+
encryption_service: Envelope encryption service instance
|
|
62
|
+
"""
|
|
63
|
+
self._mongo_db = mongo_db
|
|
64
|
+
self._encryption_service = encryption_service
|
|
65
|
+
self._sessions_collection = mongo_db[WEBSOCKET_SESSIONS_COLLECTION_NAME]
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def generate_session_key() -> str:
|
|
69
|
+
"""
|
|
70
|
+
Generate a random WebSocket session key.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
Base64-encoded session key string
|
|
74
|
+
"""
|
|
75
|
+
key_bytes = secrets.token_bytes(SESSION_KEY_SIZE)
|
|
76
|
+
return base64.urlsafe_b64encode(key_bytes).decode().rstrip("=")
|
|
77
|
+
|
|
78
|
+
async def create_session(
|
|
79
|
+
self,
|
|
80
|
+
user_id: str,
|
|
81
|
+
user_email: str | None = None,
|
|
82
|
+
app_slug: str | None = None,
|
|
83
|
+
) -> str:
|
|
84
|
+
"""
|
|
85
|
+
Create a new WebSocket session with encrypted session key.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
user_id: User ID
|
|
89
|
+
user_email: Optional user email
|
|
90
|
+
app_slug: Optional app slug for scoping
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Plaintext session key (to be sent to client)
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
OperationFailure: If MongoDB operation fails
|
|
97
|
+
"""
|
|
98
|
+
try:
|
|
99
|
+
# Generate session key
|
|
100
|
+
session_key = self.generate_session_key()
|
|
101
|
+
|
|
102
|
+
# Encrypt session key using envelope encryption
|
|
103
|
+
encrypted_key, encrypted_dek = self._encryption_service.encrypt_secret(session_key)
|
|
104
|
+
|
|
105
|
+
# Encode as base64 for storage
|
|
106
|
+
encrypted_key_b64 = base64.b64encode(encrypted_key).decode()
|
|
107
|
+
encrypted_dek_b64 = base64.b64encode(encrypted_dek).decode()
|
|
108
|
+
|
|
109
|
+
# Calculate expiration
|
|
110
|
+
expires_at = datetime.utcnow() + timedelta(hours=SESSION_TTL_HOURS)
|
|
111
|
+
|
|
112
|
+
# Prepare document
|
|
113
|
+
document = {
|
|
114
|
+
"_id": session_key, # Use session key as ID for fast lookup
|
|
115
|
+
"user_id": user_id,
|
|
116
|
+
"user_email": user_email,
|
|
117
|
+
"app_slug": app_slug,
|
|
118
|
+
"encrypted_key": encrypted_key_b64,
|
|
119
|
+
"encrypted_dek": encrypted_dek_b64,
|
|
120
|
+
"algorithm": "AES-256-GCM",
|
|
121
|
+
"created_at": datetime.utcnow(),
|
|
122
|
+
"expires_at": expires_at,
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
# Store in private collection
|
|
126
|
+
await self._sessions_collection.insert_one(document)
|
|
127
|
+
|
|
128
|
+
logger.info(
|
|
129
|
+
f"Created WebSocket session for user '{user_id}' "
|
|
130
|
+
f"(app: {app_slug}, expires: {expires_at})"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
return session_key
|
|
134
|
+
|
|
135
|
+
except (OperationFailure, PyMongoError):
|
|
136
|
+
logger.exception("Failed to create WebSocket session")
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
async def validate_session(
|
|
140
|
+
self,
|
|
141
|
+
session_key: str,
|
|
142
|
+
user_id: str | None = None,
|
|
143
|
+
) -> dict[str, Any] | None:
|
|
144
|
+
"""
|
|
145
|
+
Validate a WebSocket session key.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
session_key: Session key to validate
|
|
149
|
+
user_id: Optional user ID for additional validation
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
Session document if valid, None otherwise
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
OperationFailure: If MongoDB operation fails
|
|
156
|
+
"""
|
|
157
|
+
try:
|
|
158
|
+
# Find session by key
|
|
159
|
+
session_doc = await self._sessions_collection.find_one({"_id": session_key})
|
|
160
|
+
|
|
161
|
+
if not session_doc:
|
|
162
|
+
logger.warning(f"WebSocket session not found: {session_key[:16]}...")
|
|
163
|
+
return None
|
|
164
|
+
|
|
165
|
+
# Check expiration
|
|
166
|
+
expires_at = session_doc.get("expires_at")
|
|
167
|
+
if expires_at and expires_at < datetime.utcnow():
|
|
168
|
+
logger.warning(
|
|
169
|
+
f"WebSocket session expired: {session_key[:16]}... " f"(expired: {expires_at})"
|
|
170
|
+
)
|
|
171
|
+
# Clean up expired session
|
|
172
|
+
await self._sessions_collection.delete_one({"_id": session_key})
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
# Optional: Validate user_id matches
|
|
176
|
+
if user_id and session_doc.get("user_id") != user_id:
|
|
177
|
+
logger.warning(
|
|
178
|
+
f"WebSocket session user mismatch: "
|
|
179
|
+
f"session_user={session_doc.get('user_id')}, "
|
|
180
|
+
f"provided_user={user_id}"
|
|
181
|
+
)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
# Decrypt session key to verify it's valid
|
|
185
|
+
try:
|
|
186
|
+
encrypted_key = base64.b64decode(session_doc["encrypted_key"])
|
|
187
|
+
encrypted_dek = base64.b64decode(session_doc["encrypted_dek"])
|
|
188
|
+
decrypted_key = self._encryption_service.decrypt_secret(
|
|
189
|
+
encrypted_key, encrypted_dek
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Verify decrypted key matches session_key
|
|
193
|
+
if decrypted_key != session_key:
|
|
194
|
+
logger.error(
|
|
195
|
+
f"WebSocket session key decryption mismatch: "
|
|
196
|
+
f"session_key={session_key[:16]}..."
|
|
197
|
+
)
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
except (ValueError, TypeError, AttributeError, KeyError):
|
|
201
|
+
logger.exception("Failed to decrypt WebSocket session key")
|
|
202
|
+
return None
|
|
203
|
+
|
|
204
|
+
logger.debug(
|
|
205
|
+
f"Validated WebSocket session for user '{session_doc.get('user_id')}' "
|
|
206
|
+
f"(app: {session_doc.get('app_slug')})"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
"user_id": session_doc.get("user_id"),
|
|
211
|
+
"user_email": session_doc.get("user_email"),
|
|
212
|
+
"app_slug": session_doc.get("app_slug"),
|
|
213
|
+
"created_at": session_doc.get("created_at"),
|
|
214
|
+
"expires_at": session_doc.get("expires_at"),
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
except (OperationFailure, PyMongoError):
|
|
218
|
+
logger.exception("Failed to validate WebSocket session")
|
|
219
|
+
raise
|
|
220
|
+
|
|
221
|
+
async def revoke_session(self, session_key: str) -> bool:
|
|
222
|
+
"""
|
|
223
|
+
Revoke a WebSocket session.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
session_key: Session key to revoke
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
True if session was revoked, False if not found
|
|
230
|
+
"""
|
|
231
|
+
try:
|
|
232
|
+
result = await self._sessions_collection.delete_one({"_id": session_key})
|
|
233
|
+
if result.deleted_count > 0:
|
|
234
|
+
logger.info(f"Revoked WebSocket session: {session_key[:16]}...")
|
|
235
|
+
return True
|
|
236
|
+
return False
|
|
237
|
+
except (OperationFailure, PyMongoError):
|
|
238
|
+
logger.exception("Failed to revoke WebSocket session")
|
|
239
|
+
return False
|
|
240
|
+
|
|
241
|
+
async def revoke_user_sessions(self, user_id: str, app_slug: str | None = None) -> int:
|
|
242
|
+
"""
|
|
243
|
+
Revoke all sessions for a user.
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
user_id: User ID
|
|
247
|
+
app_slug: Optional app slug filter
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
Number of sessions revoked
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
query = {"user_id": user_id}
|
|
254
|
+
if app_slug:
|
|
255
|
+
query["app_slug"] = app_slug
|
|
256
|
+
|
|
257
|
+
result = await self._sessions_collection.delete_many(query)
|
|
258
|
+
logger.info(
|
|
259
|
+
f"Revoked {result.deleted_count} WebSocket sessions "
|
|
260
|
+
f"for user '{user_id}' (app: {app_slug})"
|
|
261
|
+
)
|
|
262
|
+
return result.deleted_count
|
|
263
|
+
except (OperationFailure, PyMongoError):
|
|
264
|
+
logger.exception("Failed to revoke user WebSocket sessions")
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
async def cleanup_expired_sessions(self) -> int:
|
|
268
|
+
"""
|
|
269
|
+
Clean up expired WebSocket sessions.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Number of sessions cleaned up
|
|
273
|
+
"""
|
|
274
|
+
try:
|
|
275
|
+
result = await self._sessions_collection.delete_many(
|
|
276
|
+
{"expires_at": {"$lt": datetime.utcnow()}}
|
|
277
|
+
)
|
|
278
|
+
if result.deleted_count > 0:
|
|
279
|
+
logger.info(f"Cleaned up {result.deleted_count} expired WebSocket sessions")
|
|
280
|
+
return result.deleted_count
|
|
281
|
+
except (OperationFailure, PyMongoError):
|
|
282
|
+
logger.exception("Failed to cleanup expired WebSocket sessions")
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def create_websocket_session_endpoint(
|
|
287
|
+
session_manager: WebSocketSessionManager,
|
|
288
|
+
) -> Callable:
|
|
289
|
+
"""
|
|
290
|
+
Create a FastAPI endpoint for generating WebSocket session keys.
|
|
291
|
+
|
|
292
|
+
This endpoint requires authentication and generates a new WebSocket session key
|
|
293
|
+
for the authenticated user. The session key is encrypted and stored in the
|
|
294
|
+
private collection.
|
|
295
|
+
|
|
296
|
+
Args:
|
|
297
|
+
session_manager: WebSocketSessionManager instance
|
|
298
|
+
|
|
299
|
+
Returns:
|
|
300
|
+
FastAPI route handler function
|
|
301
|
+
|
|
302
|
+
Example:
|
|
303
|
+
```python
|
|
304
|
+
from mdb_engine.auth.websocket_sessions import (
|
|
305
|
+
WebSocketSessionManager,
|
|
306
|
+
create_websocket_session_endpoint,
|
|
307
|
+
)
|
|
308
|
+
from mdb_engine.core.encryption import EnvelopeEncryptionService
|
|
309
|
+
|
|
310
|
+
# Initialize session manager
|
|
311
|
+
encryption_service = EnvelopeEncryptionService()
|
|
312
|
+
session_manager = WebSocketSessionManager(
|
|
313
|
+
mongo_db=db,
|
|
314
|
+
encryption_service=encryption_service,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
# Create endpoint
|
|
318
|
+
endpoint = create_websocket_session_endpoint(session_manager)
|
|
319
|
+
app.get("/auth/websocket-session")(endpoint)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
The endpoint:
|
|
323
|
+
- Requires authentication (user must be logged in)
|
|
324
|
+
- Returns JSON: `{"session_key": "...", "expires_at": "..."}`
|
|
325
|
+
- Uses user info from `request.state.user` (set by SharedAuthMiddleware)
|
|
326
|
+
"""
|
|
327
|
+
from fastapi import Request, status
|
|
328
|
+
from fastapi.responses import JSONResponse
|
|
329
|
+
|
|
330
|
+
async def websocket_session_endpoint(request: Request) -> JSONResponse:
|
|
331
|
+
"""
|
|
332
|
+
Generate a WebSocket session key for the authenticated user.
|
|
333
|
+
|
|
334
|
+
Requires:
|
|
335
|
+
- User to be authenticated (via request.state.user or auth cookie)
|
|
336
|
+
- WebSocket session manager to be available
|
|
337
|
+
|
|
338
|
+
Returns:
|
|
339
|
+
- JSONResponse with session_key and expires_at
|
|
340
|
+
"""
|
|
341
|
+
# Check if user is authenticated (set by middleware)
|
|
342
|
+
user = getattr(request.state, "user", None)
|
|
343
|
+
|
|
344
|
+
# If not set by middleware, try to authenticate using cookie
|
|
345
|
+
# This handles the case where endpoint is on parent app without auth middleware
|
|
346
|
+
if not user:
|
|
347
|
+
from .shared_middleware import AUTH_COOKIE_NAME
|
|
348
|
+
|
|
349
|
+
# Get user pool from app state
|
|
350
|
+
user_pool = None
|
|
351
|
+
try:
|
|
352
|
+
if hasattr(request, "app") and hasattr(request.app, "state"):
|
|
353
|
+
user_pool = getattr(request.app.state, "user_pool", None)
|
|
354
|
+
except (AttributeError, TypeError):
|
|
355
|
+
pass
|
|
356
|
+
|
|
357
|
+
# Only try to authenticate if we have a real user pool (not None)
|
|
358
|
+
if user_pool is not None:
|
|
359
|
+
# Extract token from cookie
|
|
360
|
+
token = None
|
|
361
|
+
try:
|
|
362
|
+
if hasattr(request, "cookies"):
|
|
363
|
+
token = request.cookies.get(AUTH_COOKIE_NAME)
|
|
364
|
+
except (AttributeError, TypeError):
|
|
365
|
+
pass
|
|
366
|
+
|
|
367
|
+
if token:
|
|
368
|
+
try:
|
|
369
|
+
# Validate token and get user
|
|
370
|
+
user = await user_pool.validate_token(token)
|
|
371
|
+
except (TypeError, AttributeError):
|
|
372
|
+
# If user_pool is a mock that can't be awaited, ignore
|
|
373
|
+
pass
|
|
374
|
+
|
|
375
|
+
if not user:
|
|
376
|
+
return JSONResponse(
|
|
377
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
378
|
+
content={"detail": "Authentication required"},
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
# Extract user info
|
|
382
|
+
# Prefer user_id, sub (JWT standard), or _id (MongoDB document ID)
|
|
383
|
+
user_id = user.get("user_id") or user.get("sub") or user.get("_id")
|
|
384
|
+
if not user_id:
|
|
385
|
+
# Email is not a valid user_id - it's just metadata
|
|
386
|
+
logger.error("Cannot generate WebSocket session: user_id not found in user data")
|
|
387
|
+
return JSONResponse(
|
|
388
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
389
|
+
content={"detail": "Invalid user data"},
|
|
390
|
+
)
|
|
391
|
+
user_email = user.get("email")
|
|
392
|
+
app_slug = getattr(request.state, "app_slug", None)
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
# Generate session key
|
|
396
|
+
session_key = await session_manager.create_session(
|
|
397
|
+
user_id=str(user_id),
|
|
398
|
+
user_email=user_email,
|
|
399
|
+
app_slug=app_slug,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
# Get expiration time (24 hours from now)
|
|
403
|
+
from datetime import datetime, timedelta
|
|
404
|
+
|
|
405
|
+
expires_at = datetime.utcnow() + timedelta(hours=SESSION_TTL_HOURS)
|
|
406
|
+
|
|
407
|
+
logger.info(
|
|
408
|
+
f"Generated WebSocket session key for user '{user_id}' " f"(app: {app_slug})"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
return JSONResponse(
|
|
412
|
+
{
|
|
413
|
+
"session_key": session_key,
|
|
414
|
+
"expires_at": expires_at.isoformat(),
|
|
415
|
+
"ttl_hours": SESSION_TTL_HOURS,
|
|
416
|
+
}
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
except (
|
|
420
|
+
ValueError,
|
|
421
|
+
TypeError,
|
|
422
|
+
AttributeError,
|
|
423
|
+
RuntimeError,
|
|
424
|
+
OperationFailure,
|
|
425
|
+
PyMongoError,
|
|
426
|
+
):
|
|
427
|
+
logger.exception("Failed to generate WebSocket session key")
|
|
428
|
+
return JSONResponse(
|
|
429
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
430
|
+
content={"detail": "Failed to generate WebSocket session key"},
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
return websocket_session_endpoint
|
mdb_engine/core/engine.py
CHANGED
|
@@ -148,6 +148,7 @@ class MongoDBEngine:
|
|
|
148
148
|
self._service_initializer: ServiceInitializer | None = None
|
|
149
149
|
self._encryption_service: EnvelopeEncryptionService | None = None
|
|
150
150
|
self._app_secrets_manager: AppSecretsManager | None = None
|
|
151
|
+
self._websocket_session_manager: Any | None = None # WebSocketSessionManager
|
|
151
152
|
|
|
152
153
|
# Store app read_scopes mapping for validation
|
|
153
154
|
self._app_read_scopes: dict[str, list[str]] = {}
|
|
@@ -201,6 +202,13 @@ class MongoDBEngine:
|
|
|
201
202
|
mongo_db=self._connection_manager.mongo_db,
|
|
202
203
|
encryption_service=self._encryption_service,
|
|
203
204
|
)
|
|
205
|
+
# Initialize WebSocket session manager for secure-by-default WebSocket auth
|
|
206
|
+
from ..auth.websocket_sessions import WebSocketSessionManager
|
|
207
|
+
|
|
208
|
+
self._websocket_session_manager = WebSocketSessionManager(
|
|
209
|
+
mongo_db=self._connection_manager.mongo_db,
|
|
210
|
+
encryption_service=self._encryption_service,
|
|
211
|
+
)
|
|
204
212
|
|
|
205
213
|
# Set up component managers
|
|
206
214
|
self._app_registration_manager = AppRegistrationManager(
|
|
@@ -2283,6 +2291,11 @@ class MongoDBEngine:
|
|
|
2283
2291
|
logger.debug(f"No WebSocket configuration found for app '{slug}'")
|
|
2284
2292
|
return
|
|
2285
2293
|
|
|
2294
|
+
# Store WebSocket config in parent app state for CSRF middleware to access
|
|
2295
|
+
if not hasattr(parent_app.state, "websocket_configs"):
|
|
2296
|
+
parent_app.state.websocket_configs = {}
|
|
2297
|
+
parent_app.state.websocket_configs[slug] = websockets_config
|
|
2298
|
+
|
|
2286
2299
|
try:
|
|
2287
2300
|
from fastapi import APIRouter
|
|
2288
2301
|
|
|
@@ -2489,6 +2502,13 @@ class MongoDBEngine:
|
|
|
2489
2502
|
child_app.state.audit_log = app.state.audit_log
|
|
2490
2503
|
logger.debug(f"Shared user_pool with child app '{slug}'")
|
|
2491
2504
|
|
|
2505
|
+
# Share WebSocket session manager with child app
|
|
2506
|
+
if hasattr(app.state, "websocket_session_manager"):
|
|
2507
|
+
child_app.state.websocket_session_manager = (
|
|
2508
|
+
app.state.websocket_session_manager
|
|
2509
|
+
)
|
|
2510
|
+
logger.debug(f"Shared WebSocket session manager with child app '{slug}'")
|
|
2511
|
+
|
|
2492
2512
|
# Add middleware for app context helpers
|
|
2493
2513
|
from starlette.middleware.base import BaseHTTPMiddleware
|
|
2494
2514
|
from starlette.requests import Request
|
|
@@ -2836,12 +2856,31 @@ class MongoDBEngine:
|
|
|
2836
2856
|
# Create CSRF middleware with default config (will use parent app's CORS config)
|
|
2837
2857
|
# Exempt routes that don't need CSRF (health checks, public routes from child apps)
|
|
2838
2858
|
# all_public_routes includes base routes + child app public routes with path prefixes
|
|
2859
|
+
# Add WebSocket session endpoint to public routes (it handles its own auth)
|
|
2860
|
+
public_routes_with_session_endpoint = list(all_public_routes) + [
|
|
2861
|
+
"/auth/websocket-session"
|
|
2862
|
+
]
|
|
2839
2863
|
parent_csrf_config = {
|
|
2840
2864
|
"csrf_protection": True,
|
|
2841
|
-
"public_routes":
|
|
2865
|
+
"public_routes": public_routes_with_session_endpoint,
|
|
2842
2866
|
}
|
|
2843
2867
|
csrf_middleware = create_csrf_middleware(parent_csrf_config)
|
|
2844
2868
|
parent_app.add_middleware(csrf_middleware)
|
|
2869
|
+
|
|
2870
|
+
# Store WebSocket session manager in app state for CSRF middleware and endpoints
|
|
2871
|
+
if self._websocket_session_manager:
|
|
2872
|
+
parent_app.state.websocket_session_manager = self._websocket_session_manager
|
|
2873
|
+
logger.info("WebSocket session manager stored in parent app state")
|
|
2874
|
+
|
|
2875
|
+
# Register WebSocket session endpoint on parent app
|
|
2876
|
+
from ..auth.websocket_sessions import create_websocket_session_endpoint
|
|
2877
|
+
|
|
2878
|
+
session_endpoint = create_websocket_session_endpoint(
|
|
2879
|
+
self._websocket_session_manager
|
|
2880
|
+
)
|
|
2881
|
+
parent_app.get("/auth/websocket-session")(session_endpoint)
|
|
2882
|
+
logger.info("WebSocket session endpoint registered at /auth/websocket-session")
|
|
2883
|
+
|
|
2845
2884
|
logger.info("CSRFMiddleware added to parent app for WebSocket origin validation")
|
|
2846
2885
|
|
|
2847
2886
|
# Add shared CORS middleware if configured
|
|
@@ -3320,6 +3359,7 @@ class MongoDBEngine:
|
|
|
3320
3359
|
self._shared_user_pool = SharedUserPool(
|
|
3321
3360
|
self._connection_manager.mongo_db,
|
|
3322
3361
|
allow_insecure_dev=is_dev,
|
|
3362
|
+
websocket_session_manager=self._websocket_session_manager,
|
|
3323
3363
|
)
|
|
3324
3364
|
await self._shared_user_pool.ensure_indexes()
|
|
3325
3365
|
logger.info("SharedUserPool initialized")
|
mdb_engine/core/manifest.py
CHANGED
|
@@ -1338,6 +1338,18 @@ MANIFEST_SCHEMA_V2 = {
|
|
|
1338
1338
|
"auth is required (default: false)"
|
|
1339
1339
|
),
|
|
1340
1340
|
},
|
|
1341
|
+
"csrf_required": {
|
|
1342
|
+
"type": "boolean",
|
|
1343
|
+
"default": True,
|
|
1344
|
+
"description": (
|
|
1345
|
+
"Require CSRF validation for WebSocket connections "
|
|
1346
|
+
"(default: true - security by default). "
|
|
1347
|
+
"When true, uses encrypted session keys stored in "
|
|
1348
|
+
"private collection for CSRF protection. "
|
|
1349
|
+
"Set to false to use Origin validation + "
|
|
1350
|
+
"SameSite cookies only."
|
|
1351
|
+
),
|
|
1352
|
+
},
|
|
1341
1353
|
},
|
|
1342
1354
|
"additionalProperties": False,
|
|
1343
1355
|
"description": (
|
mdb_engine/core/types.py
CHANGED
mdb_engine/routing/websockets.py
CHANGED
|
@@ -365,12 +365,11 @@ async def authenticate_websocket(
|
|
|
365
365
|
require_auth: bool = True,
|
|
366
366
|
) -> tuple[str | None, str | None]:
|
|
367
367
|
"""
|
|
368
|
-
Authenticate a WebSocket connection via httpOnly cookies.
|
|
368
|
+
Authenticate a WebSocket connection via session key or httpOnly cookies.
|
|
369
369
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
-
|
|
373
|
-
- Origin validation provides additional protection
|
|
370
|
+
Authentication methods (in order of preference):
|
|
371
|
+
1. Session key (query param or header) - secure-by-default, uses envelope encryption
|
|
372
|
+
2. Cookie-based authentication - backward compatibility fallback
|
|
374
373
|
|
|
375
374
|
Args:
|
|
376
375
|
websocket: FastAPI WebSocket instance (can access headers before accept)
|
|
@@ -394,8 +393,51 @@ async def authenticate_websocket(
|
|
|
394
393
|
return None, None
|
|
395
394
|
|
|
396
395
|
try:
|
|
397
|
-
#
|
|
398
|
-
|
|
396
|
+
# Try to get WebSocket session manager from app
|
|
397
|
+
websocket_session_manager = None
|
|
398
|
+
try:
|
|
399
|
+
app = getattr(websocket, "app", None)
|
|
400
|
+
if app:
|
|
401
|
+
websocket_session_manager = getattr(app.state, "websocket_session_manager", None)
|
|
402
|
+
except (AttributeError, TypeError):
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
# Method 1: Try session key authentication (secure-by-default)
|
|
406
|
+
session_key = None
|
|
407
|
+
try:
|
|
408
|
+
# Check query params first
|
|
409
|
+
if hasattr(websocket, "query_params"):
|
|
410
|
+
session_key = websocket.query_params.get("session_key")
|
|
411
|
+
|
|
412
|
+
# Check headers if not in query params
|
|
413
|
+
if not session_key and hasattr(websocket, "headers"):
|
|
414
|
+
session_key = websocket.headers.get("X-WebSocket-Session-Key")
|
|
415
|
+
except (AttributeError, TypeError, KeyError):
|
|
416
|
+
pass
|
|
417
|
+
|
|
418
|
+
if session_key and websocket_session_manager:
|
|
419
|
+
try:
|
|
420
|
+
# Validate session key
|
|
421
|
+
session_data = await websocket_session_manager.validate_session(session_key)
|
|
422
|
+
if session_data:
|
|
423
|
+
user_id = session_data.get("user_id")
|
|
424
|
+
user_email = session_data.get("user_email")
|
|
425
|
+
|
|
426
|
+
logger.info(
|
|
427
|
+
f"WebSocket authenticated successfully for app '{app_slug}': {user_email} "
|
|
428
|
+
f"(method: session_key)"
|
|
429
|
+
)
|
|
430
|
+
return user_id, user_email
|
|
431
|
+
else:
|
|
432
|
+
logger.warning(
|
|
433
|
+
f"WebSocket session key validation failed for app '{app_slug}'. "
|
|
434
|
+
f"Session key: {session_key[:16]}..."
|
|
435
|
+
)
|
|
436
|
+
except (ValueError, TypeError, AttributeError, KeyError, RuntimeError) as e:
|
|
437
|
+
logger.warning(f"WebSocket session key validation error for app '{app_slug}': {e}")
|
|
438
|
+
# Fall through to cookie-based auth
|
|
439
|
+
|
|
440
|
+
# Method 2: Fall back to cookie-based authentication (backward compatibility)
|
|
399
441
|
from ..auth.shared_middleware import AUTH_COOKIE_NAME
|
|
400
442
|
|
|
401
443
|
cookies = _get_cookies_from_websocket(websocket)
|
|
@@ -403,17 +445,19 @@ async def authenticate_websocket(
|
|
|
403
445
|
|
|
404
446
|
if not token:
|
|
405
447
|
logger.error(
|
|
406
|
-
f"❌ No
|
|
448
|
+
f"❌ No authentication found for WebSocket connection to app '{app_slug}' "
|
|
407
449
|
f"(require_auth={require_auth}). "
|
|
450
|
+
f"Session key: {bool(session_key)}, Cookie: {bool(token)}, "
|
|
408
451
|
f"Available cookies: {list(cookies.keys()) if cookies else 'none'}. "
|
|
409
|
-
f"Ensure httpOnly cookie is set during authentication."
|
|
452
|
+
f"Ensure session key or httpOnly cookie is set during authentication."
|
|
410
453
|
)
|
|
411
454
|
if require_auth:
|
|
412
455
|
return None, None # Signal auth failure
|
|
413
456
|
return None, None
|
|
414
457
|
|
|
415
458
|
logger.info(
|
|
416
|
-
f"WebSocket token found in cookie for app '{app_slug}' "
|
|
459
|
+
f"WebSocket token found in cookie for app '{app_slug}' "
|
|
460
|
+
"(cookie-based authentication, fallback)"
|
|
417
461
|
)
|
|
418
462
|
|
|
419
463
|
# Decode and validate token
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
mdb_engine/README.md,sha256=T3EFGcPopY9LslYW3lxgG3hohWkAOmBNbYG0FDMUJiY,3502
|
|
2
|
-
mdb_engine/__init__.py,sha256=
|
|
2
|
+
mdb_engine/__init__.py,sha256=vishQ3BF6oGQOmAsrMG1UJ-5C-3FNM49JSHC35MhH0k,3937
|
|
3
3
|
mdb_engine/config.py,sha256=DTAyxfKB8ogyI0v5QR9Y-SJOgXQr_eDBCKxNBSqEyLc,7269
|
|
4
4
|
mdb_engine/constants.py,sha256=eaotvW57TVOg7rRbLziGrVNoP7adgw_G9iVByHezc_A,7837
|
|
5
5
|
mdb_engine/dependencies.py,sha256=MJuYQhZ9ZGzXlip1ha5zba9Rvn04HDPWahJFJH81Q2s,14107
|
|
6
6
|
mdb_engine/exceptions.py,sha256=NkBSdTBNP9bVTtI6w6mAoEfeVZDq-Bg7vCF2L26ZDZo,8442
|
|
7
7
|
mdb_engine/auth/ARCHITECTURE.md,sha256=JXZsjEIpNz4Szk18KaOiEabhEuz1pmYWXQRN-EJEHDM,4076
|
|
8
8
|
mdb_engine/auth/README.md,sha256=IVlUOat96V3yM6wk4T-u4GxJ4-WF05eBSJBlvIexyBo,37285
|
|
9
|
-
mdb_engine/auth/__init__.py,sha256=
|
|
9
|
+
mdb_engine/auth/__init__.py,sha256=SWyMZZvCx2u1IXMlOBbw2h1CHAhIBPuwMQ6iDzlqMsQ,6075
|
|
10
10
|
mdb_engine/auth/audit.py,sha256=UQ0Bj5Zxp5et9A21YZCVkUjaABFqO1CXaMrWxNcdxm8,17352
|
|
11
11
|
mdb_engine/auth/base.py,sha256=4E3XkbruZLG9lc6aC56w0ypjnfiR5RbtjQKwn4am8to,7418
|
|
12
12
|
mdb_engine/auth/casbin_factory.py,sha256=oZLHIPyVA1hiho__ledRVDoBisaZgo96WZIKa0-kQic,16610
|
|
@@ -14,7 +14,7 @@ mdb_engine/auth/casbin_models.py,sha256=7XtFmRBhhjw1nKprnluvjyJoTj5fzdPeQwVvo6fI
|
|
|
14
14
|
mdb_engine/auth/config_defaults.py,sha256=1YI_hIHuTiEXpkEYMcufNHdLr1oxPiJylg3CKrJCSGY,2012
|
|
15
15
|
mdb_engine/auth/config_helpers.py,sha256=Qharb2YagLOKDGtE7XhYRDbBoQ_KGykrcIKrsOwWIJ4,6303
|
|
16
16
|
mdb_engine/auth/cookie_utils.py,sha256=glsSocSmy-_wRTLro0xy17s84oBk3HPDPL-FVXl7Rv8,5302
|
|
17
|
-
mdb_engine/auth/csrf.py,sha256=
|
|
17
|
+
mdb_engine/auth/csrf.py,sha256=s3OfZjLSIaABmLORbAGrTfVp4dWEbP7l9T_lf2bNEi0,32538
|
|
18
18
|
mdb_engine/auth/decorators.py,sha256=LkVVEuRrT0Iz8EwctN14BEi3fSV-xtN6DaGXgtbiYYo,12287
|
|
19
19
|
mdb_engine/auth/dependencies.py,sha256=JB1iYvZJgTR6gcaiGe_GJFCS6NdUKMxWBZRv6vVxnzw,27112
|
|
20
20
|
mdb_engine/auth/helpers.py,sha256=BCrid985cYh-3h5ZMUV9TES0q40uJXio4oYKQZta7KA,1970
|
|
@@ -27,11 +27,12 @@ mdb_engine/auth/rate_limiter.py,sha256=l3EYZE1Kz9yVfZwNrKq_1AgdD7GXB1WOLSqqGQVSS
|
|
|
27
27
|
mdb_engine/auth/restrictions.py,sha256=tOyQBO_w0bK9zmTsOPZf9cbvh4oITvpNfSxIXt-XrcU,8824
|
|
28
28
|
mdb_engine/auth/session_manager.py,sha256=ywWJjTarm-obgJ3zO3s-1cdqEYe0XrozlY00q_yMJ8I,15396
|
|
29
29
|
mdb_engine/auth/shared_middleware.py,sha256=0iSbRkwdivL1NIj7Gr161qPJiqcw0JafOpZLCkXjT7k,37633
|
|
30
|
-
mdb_engine/auth/shared_users.py,sha256=
|
|
30
|
+
mdb_engine/auth/shared_users.py,sha256=huE7e3ywqA7fZ2HYIPNc-kQ9syEdpYUrCYjYbX3bVhA,29456
|
|
31
31
|
mdb_engine/auth/token_lifecycle.py,sha256=Q9S1X2Y6W7Ckt5PvyYXswBRh2Tg9DGpyRv_3Xve7VYQ,6708
|
|
32
32
|
mdb_engine/auth/token_store.py,sha256=-B8j5RH5YEoKsswF4rnMoI51BaxMe4icke3kuehXmcI,9121
|
|
33
33
|
mdb_engine/auth/users.py,sha256=t9Us2_A_wKOL9qy1O_SBwTvapAyNztn0v8padxJVq6A,49891
|
|
34
|
-
mdb_engine/auth/utils.py,sha256=
|
|
34
|
+
mdb_engine/auth/utils.py,sha256=YkexCo0xV37mpOJUI32cntRHVOUUS7r19TIMPWHcgpA,27348
|
|
35
|
+
mdb_engine/auth/websocket_sessions.py,sha256=7eFNagY2K3Rp1x7d_cO5JcpT-DrYkc__cmVhl6pAC2M,15081
|
|
35
36
|
mdb_engine/cli/__init__.py,sha256=PANRi4THmL34d1mawlqxIrnuItXMdqoMTq5Z1zHd7rM,301
|
|
36
37
|
mdb_engine/cli/main.py,sha256=Y5ELFhvsr8zxFWv4WScOGNHiLUTdSXAJeUFLpRXCelg,811
|
|
37
38
|
mdb_engine/cli/utils.py,sha256=bNRGJgdzxUjXAOVe1aoxWJ5M_IqtAE-eW4pfAkwiDDM,2760
|
|
@@ -46,13 +47,13 @@ mdb_engine/core/app_registration.py,sha256=7szt2a7aBkpSppjmhdkkPPYMKGKo0MkLKZeEe
|
|
|
46
47
|
mdb_engine/core/app_secrets.py,sha256=bo-syg9UUATibNyXEZs-0TTYWG-JaY-2S0yNSGA12n0,10524
|
|
47
48
|
mdb_engine/core/connection.py,sha256=XnwuPG34pJ7kJGJ84T0mhj1UZ6_CLz_9qZf6NRYGIS8,8346
|
|
48
49
|
mdb_engine/core/encryption.py,sha256=RZ5LPF5g28E3ZBn6v1IMw_oas7u9YGFtBcEj8lTi9LM,7515
|
|
49
|
-
mdb_engine/core/engine.py,sha256=
|
|
50
|
+
mdb_engine/core/engine.py,sha256=s-lzI3UdmXjAsiemYUAlpVElSlHhhjPeekoQHaxbb2o,153755
|
|
50
51
|
mdb_engine/core/index_management.py,sha256=9-r7MIy3JnjQ35sGqsbj8K_I07vAUWtAVgSWC99lJcE,5555
|
|
51
|
-
mdb_engine/core/manifest.py,sha256=
|
|
52
|
+
mdb_engine/core/manifest.py,sha256=9g-r8HBr308rDxtpzPvZgP9Bc1VeN9pW7D073KXas-U,139989
|
|
52
53
|
mdb_engine/core/ray_integration.py,sha256=vexYOzztscvRYje1xTNmXJbi99oJxCaVJAwKfTNTF_E,13610
|
|
53
54
|
mdb_engine/core/seeding.py,sha256=c5IhdwlqUf_4Q5FFTAhPLaHPaUr_Txo3z_DUwZmWsFs,6421
|
|
54
55
|
mdb_engine/core/service_initialization.py,sha256=rtb6BaPvFqomwT_s7bdbbvqi5m74llT0LkJFEhVG9Gg,12996
|
|
55
|
-
mdb_engine/core/types.py,sha256=
|
|
56
|
+
mdb_engine/core/types.py,sha256=RGQeO8ctTBytZmiezmtffsT5kMhHKm1Dv8sz4QnjEgc,11226
|
|
56
57
|
mdb_engine/database/README.md,sha256=-31mVxBeVQaYsF3AD1-gQbD2NCYVcPjdFoA6sZ6b02Y,19354
|
|
57
58
|
mdb_engine/database/__init__.py,sha256=rrc3eZFli3K2zrvVdDbMBi8YkmoHYzP6JNT0AUBE5VU,981
|
|
58
59
|
mdb_engine/database/abstraction.py,sha256=H6f2WYY80r3onqN6s139uDSyG9W_QpadaoQ84hJuG1E,23438
|
|
@@ -86,12 +87,12 @@ mdb_engine/repositories/mongo.py,sha256=Wg32_6v0KHAHumhz5z8QkoqJRWAMJFA7Y2lYIJ7L
|
|
|
86
87
|
mdb_engine/repositories/unit_of_work.py,sha256=XvmwGOspEDj4hsfOULPsQKjB1QZqh83TJo6vGV4tiqU,5118
|
|
87
88
|
mdb_engine/routing/README.md,sha256=WVvTQXDq0amryrjkCu0wP_piOEwFjLukjmPz2mroWHY,13658
|
|
88
89
|
mdb_engine/routing/__init__.py,sha256=reupjHi_RTc2ZBA4AH5XzobAmqy4EQIsfSUcTkFknUM,2438
|
|
89
|
-
mdb_engine/routing/websockets.py,sha256=
|
|
90
|
+
mdb_engine/routing/websockets.py,sha256=gOYZPDCIicqRkWIVrNWTmY2ALUnZs1s0nTPmckgxgHQ,33922
|
|
90
91
|
mdb_engine/utils/__init__.py,sha256=lDxQSGqkV4fVw5TWIk6FA6_eey_ZnEtMY0fir3cpAe8,236
|
|
91
92
|
mdb_engine/utils/mongo.py,sha256=Oqtv4tQdpiiZzrilGLEYQPo8Vmh8WsTQypxQs8Of53s,3369
|
|
92
|
-
mdb_engine-0.
|
|
93
|
-
mdb_engine-0.
|
|
94
|
-
mdb_engine-0.
|
|
95
|
-
mdb_engine-0.
|
|
96
|
-
mdb_engine-0.
|
|
97
|
-
mdb_engine-0.
|
|
93
|
+
mdb_engine-0.6.0.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
|
|
94
|
+
mdb_engine-0.6.0.dist-info/METADATA,sha256=d7bW5MiTEH918H4IWkoZuDVAlZiQ6iwastveacmAN70,15810
|
|
95
|
+
mdb_engine-0.6.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
96
|
+
mdb_engine-0.6.0.dist-info/entry_points.txt,sha256=INCbYdFbBzJalwPwxliEzLmPfR57IvQ7RAXG_pn8cL8,48
|
|
97
|
+
mdb_engine-0.6.0.dist-info/top_level.txt,sha256=PH0UEBwTtgkm2vWvC9He_EOMn7hVn_Wg_Jyc0SmeO8k,11
|
|
98
|
+
mdb_engine-0.6.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|