mdb-engine 0.1.6__py3-none-any.whl → 0.2.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 +104 -11
- mdb_engine/auth/ARCHITECTURE.md +112 -0
- mdb_engine/auth/README.md +648 -11
- mdb_engine/auth/__init__.py +136 -29
- mdb_engine/auth/audit.py +592 -0
- mdb_engine/auth/base.py +252 -0
- mdb_engine/auth/casbin_factory.py +264 -69
- mdb_engine/auth/config_helpers.py +7 -6
- mdb_engine/auth/cookie_utils.py +3 -7
- mdb_engine/auth/csrf.py +373 -0
- mdb_engine/auth/decorators.py +3 -10
- mdb_engine/auth/dependencies.py +47 -50
- mdb_engine/auth/helpers.py +3 -3
- mdb_engine/auth/integration.py +53 -80
- mdb_engine/auth/jwt.py +2 -6
- mdb_engine/auth/middleware.py +77 -34
- mdb_engine/auth/oso_factory.py +18 -38
- mdb_engine/auth/provider.py +270 -171
- mdb_engine/auth/rate_limiter.py +504 -0
- mdb_engine/auth/restrictions.py +8 -24
- mdb_engine/auth/session_manager.py +14 -29
- mdb_engine/auth/shared_middleware.py +600 -0
- mdb_engine/auth/shared_users.py +759 -0
- mdb_engine/auth/token_store.py +14 -28
- mdb_engine/auth/users.py +54 -113
- mdb_engine/auth/utils.py +213 -15
- mdb_engine/cli/commands/generate.py +545 -9
- mdb_engine/cli/commands/validate.py +3 -7
- mdb_engine/cli/utils.py +3 -3
- mdb_engine/config.py +7 -21
- mdb_engine/constants.py +65 -0
- mdb_engine/core/README.md +117 -6
- mdb_engine/core/__init__.py +39 -7
- mdb_engine/core/app_registration.py +22 -41
- mdb_engine/core/app_secrets.py +290 -0
- mdb_engine/core/connection.py +18 -9
- mdb_engine/core/encryption.py +223 -0
- mdb_engine/core/engine.py +1057 -93
- mdb_engine/core/index_management.py +12 -16
- mdb_engine/core/manifest.py +459 -150
- mdb_engine/core/ray_integration.py +435 -0
- mdb_engine/core/seeding.py +10 -18
- mdb_engine/core/service_initialization.py +12 -23
- mdb_engine/core/types.py +2 -5
- mdb_engine/database/README.md +140 -17
- mdb_engine/database/__init__.py +17 -6
- mdb_engine/database/abstraction.py +25 -37
- mdb_engine/database/connection.py +11 -18
- mdb_engine/database/query_validator.py +367 -0
- mdb_engine/database/resource_limiter.py +204 -0
- mdb_engine/database/scoped_wrapper.py +713 -196
- mdb_engine/dependencies.py +426 -0
- mdb_engine/di/__init__.py +34 -0
- mdb_engine/di/container.py +248 -0
- mdb_engine/di/providers.py +205 -0
- mdb_engine/di/scopes.py +139 -0
- mdb_engine/embeddings/README.md +54 -24
- mdb_engine/embeddings/__init__.py +31 -24
- mdb_engine/embeddings/dependencies.py +37 -154
- mdb_engine/embeddings/service.py +11 -25
- mdb_engine/exceptions.py +92 -0
- mdb_engine/indexes/README.md +30 -13
- mdb_engine/indexes/__init__.py +1 -0
- mdb_engine/indexes/helpers.py +1 -1
- mdb_engine/indexes/manager.py +50 -114
- mdb_engine/memory/README.md +2 -2
- mdb_engine/memory/__init__.py +1 -2
- mdb_engine/memory/service.py +30 -87
- mdb_engine/observability/README.md +4 -2
- mdb_engine/observability/__init__.py +26 -9
- mdb_engine/observability/health.py +8 -9
- mdb_engine/observability/metrics.py +32 -12
- mdb_engine/repositories/__init__.py +34 -0
- mdb_engine/repositories/base.py +325 -0
- mdb_engine/repositories/mongo.py +233 -0
- mdb_engine/repositories/unit_of_work.py +166 -0
- mdb_engine/routing/README.md +1 -1
- mdb_engine/routing/__init__.py +1 -3
- mdb_engine/routing/websockets.py +25 -60
- mdb_engine-0.2.0.dist-info/METADATA +313 -0
- mdb_engine-0.2.0.dist-info/RECORD +96 -0
- mdb_engine-0.1.6.dist-info/METADATA +0 -213
- mdb_engine-0.1.6.dist-info/RECORD +0 -75
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/WHEEL +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/entry_points.txt +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/licenses/LICENSE +0 -0
- {mdb_engine-0.1.6.dist-info → mdb_engine-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Shared User Pool for Multi-App SSO
|
|
3
|
+
|
|
4
|
+
Provides a centralized user pool that enables Single Sign-On (SSO) across
|
|
5
|
+
all apps using "shared" auth mode. Users authenticate once and can access
|
|
6
|
+
any app that uses shared auth mode (subject to role requirements).
|
|
7
|
+
|
|
8
|
+
This module is part of MDB_ENGINE - MongoDB Engine.
|
|
9
|
+
|
|
10
|
+
Security Features:
|
|
11
|
+
- JWT secret required (fail-fast validation)
|
|
12
|
+
- JTI (JWT ID) for token revocation support
|
|
13
|
+
- Token blacklist integration
|
|
14
|
+
- Secure cookie configuration helpers
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
# Initialize shared user pool (JWT secret required!)
|
|
18
|
+
pool = SharedUserPool(mongo_db, jwt_secret="your-secret")
|
|
19
|
+
# Or use environment variable: MDB_ENGINE_JWT_SECRET
|
|
20
|
+
|
|
21
|
+
# For local development only:
|
|
22
|
+
pool = SharedUserPool(mongo_db, allow_insecure_dev=True)
|
|
23
|
+
|
|
24
|
+
# Register a new user
|
|
25
|
+
user = await pool.create_user(
|
|
26
|
+
email="user@example.com",
|
|
27
|
+
password="secure_password",
|
|
28
|
+
app_roles={"my_app": ["viewer"]}
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# Authenticate and get JWT token
|
|
32
|
+
token = await pool.authenticate("user@example.com", "secure_password")
|
|
33
|
+
|
|
34
|
+
# Validate token and get user
|
|
35
|
+
user = await pool.validate_token(token)
|
|
36
|
+
|
|
37
|
+
# Revoke a token (e.g., on logout)
|
|
38
|
+
await pool.revoke_token(token)
|
|
39
|
+
|
|
40
|
+
# Check if user has required role for an app
|
|
41
|
+
has_access = pool.user_has_role(user, "my_app", "viewer")
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import logging
|
|
45
|
+
import os
|
|
46
|
+
import secrets
|
|
47
|
+
from datetime import datetime, timedelta
|
|
48
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
49
|
+
|
|
50
|
+
import bcrypt
|
|
51
|
+
import jwt
|
|
52
|
+
from motor.motor_asyncio import AsyncIOMotorDatabase
|
|
53
|
+
from pymongo.errors import OperationFailure, PyMongoError
|
|
54
|
+
|
|
55
|
+
if TYPE_CHECKING:
|
|
56
|
+
from fastapi import Request
|
|
57
|
+
|
|
58
|
+
logger = logging.getLogger(__name__)
|
|
59
|
+
|
|
60
|
+
# Collection names
|
|
61
|
+
SHARED_USERS_COLLECTION = "_mdb_engine_shared_users"
|
|
62
|
+
TOKEN_BLACKLIST_COLLECTION = "_mdb_engine_token_blacklist"
|
|
63
|
+
|
|
64
|
+
# Default JWT settings
|
|
65
|
+
DEFAULT_JWT_ALGORITHM = "HS256"
|
|
66
|
+
DEFAULT_TOKEN_EXPIRY_HOURS = 24
|
|
67
|
+
|
|
68
|
+
# Supported JWT algorithms
|
|
69
|
+
SYMMETRIC_ALGORITHMS = {"HS256", "HS384", "HS512"}
|
|
70
|
+
ASYMMETRIC_ALGORITHMS = {"RS256", "RS384", "RS512", "ES256", "ES384", "ES512"}
|
|
71
|
+
SUPPORTED_ALGORITHMS = SYMMETRIC_ALGORITHMS | ASYMMETRIC_ALGORITHMS
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class JWTSecretError(ValueError):
|
|
75
|
+
"""Raised when JWT secret is missing or invalid."""
|
|
76
|
+
|
|
77
|
+
pass
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class JWTKeyError(ValueError):
|
|
81
|
+
"""Raised when JWT key configuration is invalid."""
|
|
82
|
+
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class SharedUserPool:
|
|
87
|
+
"""
|
|
88
|
+
Manages a shared user pool for SSO across multi-app deployments.
|
|
89
|
+
|
|
90
|
+
Users are stored in a central collection with per-app role assignments.
|
|
91
|
+
JWT tokens are used for stateless session management.
|
|
92
|
+
|
|
93
|
+
Supports both symmetric (HS256) and asymmetric (RS256, ES256) algorithms:
|
|
94
|
+
- HS256: Uses shared secret for both signing and verification
|
|
95
|
+
- RS256: Uses RSA private key for signing, public key for verification
|
|
96
|
+
- ES256: Uses ECDSA private key for signing, public key for verification
|
|
97
|
+
|
|
98
|
+
Schema for user documents:
|
|
99
|
+
{
|
|
100
|
+
"_id": ObjectId,
|
|
101
|
+
"email": "user@example.com",
|
|
102
|
+
"password_hash": "bcrypt_hash",
|
|
103
|
+
"app_roles": {
|
|
104
|
+
"app_slug_1": ["role1", "role2"],
|
|
105
|
+
"app_slug_2": ["role3"]
|
|
106
|
+
},
|
|
107
|
+
"created_at": datetime,
|
|
108
|
+
"updated_at": datetime,
|
|
109
|
+
"last_login": datetime,
|
|
110
|
+
"is_active": bool
|
|
111
|
+
}
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(
|
|
115
|
+
self,
|
|
116
|
+
mongo_db: AsyncIOMotorDatabase,
|
|
117
|
+
jwt_secret: Optional[str] = None,
|
|
118
|
+
jwt_public_key: Optional[str] = None,
|
|
119
|
+
jwt_algorithm: str = DEFAULT_JWT_ALGORITHM,
|
|
120
|
+
token_expiry_hours: int = DEFAULT_TOKEN_EXPIRY_HOURS,
|
|
121
|
+
allow_insecure_dev: bool = False,
|
|
122
|
+
):
|
|
123
|
+
"""
|
|
124
|
+
Initialize the shared user pool.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
mongo_db: MongoDB database instance (raw, not scoped)
|
|
128
|
+
jwt_secret: Secret or private key for JWT signing. For HS256, this is
|
|
129
|
+
the shared secret. For RS256/ES256, this is the private key.
|
|
130
|
+
If not provided, reads from MDB_ENGINE_JWT_SECRET env var.
|
|
131
|
+
jwt_public_key: Public key for RS256/ES256 verification. If not provided,
|
|
132
|
+
reads from MDB_ENGINE_JWT_PUBLIC_KEY env var.
|
|
133
|
+
Not needed for HS256 (symmetric).
|
|
134
|
+
jwt_algorithm: JWT algorithm (default: HS256). Supported:
|
|
135
|
+
- HS256, HS384, HS512 (symmetric/HMAC)
|
|
136
|
+
- RS256, RS384, RS512 (RSA asymmetric)
|
|
137
|
+
- ES256, ES384, ES512 (ECDSA asymmetric)
|
|
138
|
+
token_expiry_hours: Token expiry in hours (default: 24)
|
|
139
|
+
allow_insecure_dev: Allow insecure auto-generated secret for local
|
|
140
|
+
development only. NEVER use in production!
|
|
141
|
+
|
|
142
|
+
Raises:
|
|
143
|
+
JWTSecretError: If no JWT secret is provided and allow_insecure_dev=False
|
|
144
|
+
JWTKeyError: If asymmetric algorithm is used without proper keys
|
|
145
|
+
"""
|
|
146
|
+
self._db = mongo_db
|
|
147
|
+
self._collection = mongo_db[SHARED_USERS_COLLECTION]
|
|
148
|
+
self._blacklist_collection = mongo_db[TOKEN_BLACKLIST_COLLECTION]
|
|
149
|
+
|
|
150
|
+
# Validate algorithm
|
|
151
|
+
if jwt_algorithm not in SUPPORTED_ALGORITHMS:
|
|
152
|
+
raise JWTKeyError(
|
|
153
|
+
f"Unsupported JWT algorithm: {jwt_algorithm}. "
|
|
154
|
+
f"Supported: {', '.join(sorted(SUPPORTED_ALGORITHMS))}"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
self._jwt_algorithm = jwt_algorithm
|
|
158
|
+
self._is_asymmetric = jwt_algorithm in ASYMMETRIC_ALGORITHMS
|
|
159
|
+
|
|
160
|
+
# Load keys from params or environment
|
|
161
|
+
self._jwt_secret = jwt_secret or os.getenv("MDB_ENGINE_JWT_SECRET")
|
|
162
|
+
self._jwt_public_key = jwt_public_key or os.getenv("MDB_ENGINE_JWT_PUBLIC_KEY")
|
|
163
|
+
|
|
164
|
+
# Validate key configuration
|
|
165
|
+
if self._is_asymmetric:
|
|
166
|
+
self._setup_asymmetric_keys(allow_insecure_dev)
|
|
167
|
+
else:
|
|
168
|
+
self._setup_symmetric_key(allow_insecure_dev)
|
|
169
|
+
|
|
170
|
+
self._token_expiry_hours = token_expiry_hours
|
|
171
|
+
self._blacklist_indexes_created = False
|
|
172
|
+
|
|
173
|
+
logger.info(f"SharedUserPool initialized (algorithm={jwt_algorithm})")
|
|
174
|
+
|
|
175
|
+
def _setup_symmetric_key(self, allow_insecure_dev: bool) -> None:
|
|
176
|
+
"""Set up symmetric key for HMAC algorithms (HS256, etc.)."""
|
|
177
|
+
if not self._jwt_secret:
|
|
178
|
+
if allow_insecure_dev:
|
|
179
|
+
# Generate ephemeral secret for development
|
|
180
|
+
self._jwt_secret = secrets.token_urlsafe(32)
|
|
181
|
+
logger.warning(
|
|
182
|
+
"⚠️ INSECURE: Using auto-generated JWT secret. "
|
|
183
|
+
"Tokens will be invalid after restart. "
|
|
184
|
+
"Set MDB_ENGINE_JWT_SECRET for production!"
|
|
185
|
+
)
|
|
186
|
+
else:
|
|
187
|
+
raise JWTSecretError(
|
|
188
|
+
"JWT secret required for SharedUserPool. "
|
|
189
|
+
"Set MDB_ENGINE_JWT_SECRET environment variable or pass jwt_secret parameter. "
|
|
190
|
+
"Generate a secure secret with: "
|
|
191
|
+
'python -c "import secrets; print(secrets.token_urlsafe(32))" '
|
|
192
|
+
"For local development only, pass allow_insecure_dev=True."
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# For symmetric, signing key = verification key
|
|
196
|
+
self._signing_key = self._jwt_secret
|
|
197
|
+
self._verification_key = self._jwt_secret
|
|
198
|
+
|
|
199
|
+
def _setup_asymmetric_keys(self, allow_insecure_dev: bool) -> None:
|
|
200
|
+
"""Set up asymmetric keys for RSA/ECDSA algorithms (RS256, ES256, etc.)."""
|
|
201
|
+
if not self._jwt_secret:
|
|
202
|
+
if allow_insecure_dev:
|
|
203
|
+
logger.warning(
|
|
204
|
+
f"⚠️ INSECURE: {self._jwt_algorithm} requires a private key. "
|
|
205
|
+
f"Set MDB_ENGINE_JWT_SECRET with your private key in production!"
|
|
206
|
+
)
|
|
207
|
+
# We can't auto-generate RSA/ECDSA keys easily, so error out
|
|
208
|
+
raise JWTKeyError(
|
|
209
|
+
f"Private key required for {self._jwt_algorithm} algorithm. "
|
|
210
|
+
f"Set MDB_ENGINE_JWT_SECRET environment variable with your "
|
|
211
|
+
f"PEM-encoded private key. "
|
|
212
|
+
f"Asymmetric algorithms cannot auto-generate keys even in dev mode."
|
|
213
|
+
)
|
|
214
|
+
else:
|
|
215
|
+
raise JWTKeyError(
|
|
216
|
+
f"Private key required for {self._jwt_algorithm} algorithm. "
|
|
217
|
+
f"Set MDB_ENGINE_JWT_SECRET environment variable with your "
|
|
218
|
+
f"PEM-encoded private key."
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if not self._jwt_public_key:
|
|
222
|
+
# For verification, we can derive public key from private in some cases,
|
|
223
|
+
# or require it explicitly for better security
|
|
224
|
+
logger.warning(
|
|
225
|
+
f"⚠️ No public key provided for {self._jwt_algorithm}. "
|
|
226
|
+
f"Token verification will use the private key (less secure). "
|
|
227
|
+
f"Set MDB_ENGINE_JWT_PUBLIC_KEY for better key separation."
|
|
228
|
+
)
|
|
229
|
+
# Use private key for both (PyJWT can handle this for RSA)
|
|
230
|
+
self._verification_key = self._jwt_secret
|
|
231
|
+
else:
|
|
232
|
+
self._verification_key = self._jwt_public_key
|
|
233
|
+
|
|
234
|
+
self._signing_key = self._jwt_secret
|
|
235
|
+
|
|
236
|
+
logger.info(
|
|
237
|
+
f"Asymmetric JWT configured: algorithm={self._jwt_algorithm}, "
|
|
238
|
+
f"public_key={'provided' if self._jwt_public_key else 'derived'}"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def jwt_algorithm(self) -> str:
|
|
243
|
+
"""Get the configured JWT algorithm."""
|
|
244
|
+
return self._jwt_algorithm
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def is_asymmetric(self) -> bool:
|
|
248
|
+
"""Check if using asymmetric (public/private key) algorithm."""
|
|
249
|
+
return self._is_asymmetric
|
|
250
|
+
|
|
251
|
+
async def ensure_indexes(self) -> None:
|
|
252
|
+
"""Create necessary indexes for the shared users and blacklist collections."""
|
|
253
|
+
try:
|
|
254
|
+
# Unique index on email
|
|
255
|
+
await self._collection.create_index("email", unique=True, name="email_unique_idx")
|
|
256
|
+
# Index for active users
|
|
257
|
+
await self._collection.create_index(
|
|
258
|
+
[("is_active", 1), ("email", 1)], name="active_email_idx"
|
|
259
|
+
)
|
|
260
|
+
logger.info("SharedUserPool user indexes ensured")
|
|
261
|
+
except OperationFailure as e:
|
|
262
|
+
logger.warning(f"Failed to create user indexes: {e}")
|
|
263
|
+
|
|
264
|
+
# Ensure blacklist indexes
|
|
265
|
+
await self._ensure_blacklist_indexes()
|
|
266
|
+
|
|
267
|
+
async def _ensure_blacklist_indexes(self) -> None:
|
|
268
|
+
"""Create indexes for token blacklist collection."""
|
|
269
|
+
if self._blacklist_indexes_created:
|
|
270
|
+
return
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
# Unique index on JTI for fast lookups
|
|
274
|
+
await self._blacklist_collection.create_index("jti", unique=True, name="jti_unique_idx")
|
|
275
|
+
# TTL index for automatic cleanup of expired entries
|
|
276
|
+
await self._blacklist_collection.create_index(
|
|
277
|
+
"expires_at", expireAfterSeconds=0, name="expires_at_ttl_idx"
|
|
278
|
+
)
|
|
279
|
+
self._blacklist_indexes_created = True
|
|
280
|
+
logger.info("SharedUserPool blacklist indexes ensured")
|
|
281
|
+
except OperationFailure as e:
|
|
282
|
+
logger.warning(f"Failed to create blacklist indexes: {e}")
|
|
283
|
+
|
|
284
|
+
async def create_user(
|
|
285
|
+
self,
|
|
286
|
+
email: str,
|
|
287
|
+
password: str,
|
|
288
|
+
app_roles: Optional[Dict[str, List[str]]] = None,
|
|
289
|
+
is_active: bool = True,
|
|
290
|
+
) -> Dict[str, Any]:
|
|
291
|
+
"""
|
|
292
|
+
Create a new shared user.
|
|
293
|
+
|
|
294
|
+
Args:
|
|
295
|
+
email: User email (must be unique)
|
|
296
|
+
password: Plain text password (will be hashed)
|
|
297
|
+
app_roles: Dict of app_slug -> list of roles
|
|
298
|
+
is_active: Whether user is active (default: True)
|
|
299
|
+
|
|
300
|
+
Returns:
|
|
301
|
+
Created user document (without password_hash)
|
|
302
|
+
|
|
303
|
+
Raises:
|
|
304
|
+
ValueError: If email already exists
|
|
305
|
+
"""
|
|
306
|
+
# Check if email already exists
|
|
307
|
+
existing = await self._collection.find_one({"email": email})
|
|
308
|
+
if existing:
|
|
309
|
+
raise ValueError(f"User with email '{email}' already exists")
|
|
310
|
+
|
|
311
|
+
# Hash password
|
|
312
|
+
password_hash = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8")
|
|
313
|
+
|
|
314
|
+
now = datetime.utcnow()
|
|
315
|
+
user_doc = {
|
|
316
|
+
"email": email,
|
|
317
|
+
"password_hash": password_hash,
|
|
318
|
+
"app_roles": app_roles or {},
|
|
319
|
+
"is_active": is_active,
|
|
320
|
+
"created_at": now,
|
|
321
|
+
"updated_at": now,
|
|
322
|
+
"last_login": None,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
result = await self._collection.insert_one(user_doc)
|
|
326
|
+
user_doc["_id"] = result.inserted_id
|
|
327
|
+
|
|
328
|
+
# Return without password hash
|
|
329
|
+
return self._sanitize_user(user_doc)
|
|
330
|
+
|
|
331
|
+
async def authenticate(
|
|
332
|
+
self,
|
|
333
|
+
email: str,
|
|
334
|
+
password: str,
|
|
335
|
+
ip_address: Optional[str] = None,
|
|
336
|
+
fingerprint: Optional[str] = None,
|
|
337
|
+
session_binding: Optional[Dict[str, Any]] = None,
|
|
338
|
+
) -> Optional[str]:
|
|
339
|
+
"""
|
|
340
|
+
Authenticate user and return JWT token.
|
|
341
|
+
|
|
342
|
+
Args:
|
|
343
|
+
email: User email
|
|
344
|
+
password: Plain text password
|
|
345
|
+
ip_address: Client IP address for session binding
|
|
346
|
+
fingerprint: Device fingerprint for session binding
|
|
347
|
+
session_binding: Session binding config from manifest:
|
|
348
|
+
- bind_ip: Include IP in token claims
|
|
349
|
+
- bind_fingerprint: Include fingerprint in token claims
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
JWT token if authentication succeeds, None otherwise
|
|
353
|
+
"""
|
|
354
|
+
user = await self._collection.find_one(
|
|
355
|
+
{
|
|
356
|
+
"email": email,
|
|
357
|
+
"is_active": True,
|
|
358
|
+
}
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if not user:
|
|
362
|
+
logger.warning(f"Authentication failed: user '{email}' not found or inactive")
|
|
363
|
+
return None
|
|
364
|
+
|
|
365
|
+
# Verify password
|
|
366
|
+
if not bcrypt.checkpw(password.encode("utf-8"), user["password_hash"].encode("utf-8")):
|
|
367
|
+
logger.warning(f"Authentication failed: invalid password for '{email}'")
|
|
368
|
+
return None
|
|
369
|
+
|
|
370
|
+
# Update last login
|
|
371
|
+
await self._collection.update_one(
|
|
372
|
+
{"_id": user["_id"]}, {"$set": {"last_login": datetime.utcnow()}}
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
# Prepare extra claims for session binding
|
|
376
|
+
extra_claims = {}
|
|
377
|
+
session_binding = session_binding or {}
|
|
378
|
+
|
|
379
|
+
if session_binding.get("bind_ip", False) and ip_address:
|
|
380
|
+
extra_claims["ip"] = ip_address
|
|
381
|
+
logger.debug(f"Session bound to IP: {ip_address}")
|
|
382
|
+
|
|
383
|
+
if session_binding.get("bind_fingerprint", True) and fingerprint:
|
|
384
|
+
extra_claims["fp"] = fingerprint
|
|
385
|
+
logger.debug(f"Session bound to fingerprint: {fingerprint[:16]}...")
|
|
386
|
+
|
|
387
|
+
# Generate JWT token with session binding claims
|
|
388
|
+
token = self._generate_token(user, extra_claims=extra_claims or None)
|
|
389
|
+
|
|
390
|
+
logger.info(f"User '{email}' authenticated successfully")
|
|
391
|
+
return token
|
|
392
|
+
|
|
393
|
+
async def validate_token(self, token: str) -> Optional[Dict[str, Any]]:
|
|
394
|
+
"""
|
|
395
|
+
Validate JWT token and return user data.
|
|
396
|
+
|
|
397
|
+
Validation steps:
|
|
398
|
+
1. Decode and verify JWT signature (uses public key for asymmetric)
|
|
399
|
+
2. Check if token is blacklisted (revoked)
|
|
400
|
+
3. Fetch current user data (roles may have changed)
|
|
401
|
+
|
|
402
|
+
Args:
|
|
403
|
+
token: JWT token string
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
User dict if token is valid, None otherwise
|
|
407
|
+
"""
|
|
408
|
+
try:
|
|
409
|
+
payload = jwt.decode(token, self._verification_key, algorithms=[self._jwt_algorithm])
|
|
410
|
+
|
|
411
|
+
user_id = payload.get("sub")
|
|
412
|
+
if not user_id:
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
# Check if token is revoked (blacklisted)
|
|
416
|
+
jti = payload.get("jti")
|
|
417
|
+
if jti and await self._is_token_revoked(jti):
|
|
418
|
+
logger.debug(f"Token validation failed: token revoked (jti={jti})")
|
|
419
|
+
return None
|
|
420
|
+
|
|
421
|
+
# Fetch current user data (roles may have changed)
|
|
422
|
+
from bson import ObjectId
|
|
423
|
+
|
|
424
|
+
user = await self._collection.find_one(
|
|
425
|
+
{
|
|
426
|
+
"_id": ObjectId(user_id),
|
|
427
|
+
"is_active": True,
|
|
428
|
+
}
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
if not user:
|
|
432
|
+
return None
|
|
433
|
+
|
|
434
|
+
return self._sanitize_user(user)
|
|
435
|
+
|
|
436
|
+
except jwt.ExpiredSignatureError:
|
|
437
|
+
logger.debug("Token validation failed: token expired")
|
|
438
|
+
return None
|
|
439
|
+
except jwt.InvalidTokenError as e:
|
|
440
|
+
logger.debug(f"Token validation failed: {e}")
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
async def _is_token_revoked(self, jti: str) -> bool:
|
|
444
|
+
"""Check if a token JTI is in the blacklist."""
|
|
445
|
+
try:
|
|
446
|
+
entry = await self._blacklist_collection.find_one({"jti": jti})
|
|
447
|
+
if entry:
|
|
448
|
+
# Double-check expiration (TTL index should handle this)
|
|
449
|
+
expires_at = entry.get("expires_at")
|
|
450
|
+
if expires_at and isinstance(expires_at, datetime):
|
|
451
|
+
return datetime.utcnow() < expires_at
|
|
452
|
+
return True # No expiration = permanently blacklisted
|
|
453
|
+
return False
|
|
454
|
+
except PyMongoError as e:
|
|
455
|
+
logger.exception(f"Error checking token blacklist: {e}")
|
|
456
|
+
# Fail open for availability (can be changed to fail closed for security)
|
|
457
|
+
return False
|
|
458
|
+
|
|
459
|
+
async def revoke_token(
|
|
460
|
+
self,
|
|
461
|
+
token: str,
|
|
462
|
+
reason: str = "logout",
|
|
463
|
+
) -> bool:
|
|
464
|
+
"""
|
|
465
|
+
Revoke a token by adding its JTI to the blacklist.
|
|
466
|
+
|
|
467
|
+
Args:
|
|
468
|
+
token: JWT token to revoke
|
|
469
|
+
reason: Reason for revocation (default: "logout")
|
|
470
|
+
|
|
471
|
+
Returns:
|
|
472
|
+
True if token was successfully revoked, False otherwise
|
|
473
|
+
"""
|
|
474
|
+
try:
|
|
475
|
+
# Decode token to get JTI and expiration
|
|
476
|
+
payload = jwt.decode(
|
|
477
|
+
token,
|
|
478
|
+
self._verification_key,
|
|
479
|
+
algorithms=[self._jwt_algorithm],
|
|
480
|
+
options={"verify_exp": False}, # Allow revoking expired tokens
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
jti = payload.get("jti")
|
|
484
|
+
if not jti:
|
|
485
|
+
logger.warning("Cannot revoke token: no JTI claim")
|
|
486
|
+
return False
|
|
487
|
+
|
|
488
|
+
# Get expiration from token, or use default
|
|
489
|
+
exp_timestamp = payload.get("exp")
|
|
490
|
+
if exp_timestamp:
|
|
491
|
+
expires_at = datetime.utcfromtimestamp(exp_timestamp)
|
|
492
|
+
else:
|
|
493
|
+
expires_at = datetime.utcnow() + timedelta(days=7)
|
|
494
|
+
|
|
495
|
+
# Ensure blacklist indexes exist
|
|
496
|
+
await self._ensure_blacklist_indexes()
|
|
497
|
+
|
|
498
|
+
# Add to blacklist with upsert
|
|
499
|
+
await self._blacklist_collection.update_one(
|
|
500
|
+
{"jti": jti},
|
|
501
|
+
{
|
|
502
|
+
"$set": {
|
|
503
|
+
"jti": jti,
|
|
504
|
+
"user_id": payload.get("sub"),
|
|
505
|
+
"email": payload.get("email"),
|
|
506
|
+
"revoked_at": datetime.utcnow(),
|
|
507
|
+
"expires_at": expires_at,
|
|
508
|
+
"reason": reason,
|
|
509
|
+
}
|
|
510
|
+
},
|
|
511
|
+
upsert=True,
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
logger.info(f"Token revoked: jti={jti}, reason={reason}")
|
|
515
|
+
return True
|
|
516
|
+
|
|
517
|
+
except jwt.InvalidTokenError as e:
|
|
518
|
+
logger.warning(f"Cannot revoke invalid token: {e}")
|
|
519
|
+
return False
|
|
520
|
+
except PyMongoError as e:
|
|
521
|
+
logger.exception(f"Error revoking token: {e}")
|
|
522
|
+
return False
|
|
523
|
+
|
|
524
|
+
async def revoke_all_user_tokens(
|
|
525
|
+
self,
|
|
526
|
+
user_id: str,
|
|
527
|
+
reason: str = "logout_all",
|
|
528
|
+
) -> None:
|
|
529
|
+
"""
|
|
530
|
+
Revoke all tokens for a user by storing a revocation marker.
|
|
531
|
+
|
|
532
|
+
Note: This requires checking user's token_revoked_at during validation.
|
|
533
|
+
For immediate effect, consider using short token expiry + refresh tokens.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
user_id: User ID to revoke tokens for
|
|
537
|
+
reason: Reason for revocation
|
|
538
|
+
"""
|
|
539
|
+
await self._collection.update_one(
|
|
540
|
+
{"_id": user_id},
|
|
541
|
+
{
|
|
542
|
+
"$set": {
|
|
543
|
+
"tokens_revoked_at": datetime.utcnow(),
|
|
544
|
+
"tokens_revoked_reason": reason,
|
|
545
|
+
}
|
|
546
|
+
},
|
|
547
|
+
)
|
|
548
|
+
logger.info(f"All tokens revoked for user {user_id}: {reason}")
|
|
549
|
+
|
|
550
|
+
async def get_user_by_email(self, email: str) -> Optional[Dict[str, Any]]:
|
|
551
|
+
"""Get user by email."""
|
|
552
|
+
user = await self._collection.find_one({"email": email})
|
|
553
|
+
if user:
|
|
554
|
+
return self._sanitize_user(user)
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
async def update_user_roles(
|
|
558
|
+
self,
|
|
559
|
+
email: str,
|
|
560
|
+
app_slug: str,
|
|
561
|
+
roles: List[str],
|
|
562
|
+
) -> bool:
|
|
563
|
+
"""
|
|
564
|
+
Update a user's roles for a specific app.
|
|
565
|
+
|
|
566
|
+
Args:
|
|
567
|
+
email: User email
|
|
568
|
+
app_slug: App slug to update roles for
|
|
569
|
+
roles: New list of roles for this app
|
|
570
|
+
|
|
571
|
+
Returns:
|
|
572
|
+
True if updated, False if user not found
|
|
573
|
+
"""
|
|
574
|
+
result = await self._collection.update_one(
|
|
575
|
+
{"email": email},
|
|
576
|
+
{
|
|
577
|
+
"$set": {
|
|
578
|
+
f"app_roles.{app_slug}": roles,
|
|
579
|
+
"updated_at": datetime.utcnow(),
|
|
580
|
+
}
|
|
581
|
+
},
|
|
582
|
+
)
|
|
583
|
+
|
|
584
|
+
if result.modified_count > 0:
|
|
585
|
+
logger.info(f"Updated roles for '{email}' in app '{app_slug}': {roles}")
|
|
586
|
+
return True
|
|
587
|
+
return False
|
|
588
|
+
|
|
589
|
+
async def remove_user_from_app(self, email: str, app_slug: str) -> bool:
|
|
590
|
+
"""
|
|
591
|
+
Remove a user's access to a specific app.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
email: User email
|
|
595
|
+
app_slug: App slug to remove access from
|
|
596
|
+
|
|
597
|
+
Returns:
|
|
598
|
+
True if updated, False if user not found
|
|
599
|
+
"""
|
|
600
|
+
result = await self._collection.update_one(
|
|
601
|
+
{"email": email},
|
|
602
|
+
{
|
|
603
|
+
"$unset": {f"app_roles.{app_slug}": ""},
|
|
604
|
+
"$set": {"updated_at": datetime.utcnow()},
|
|
605
|
+
},
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
if result.modified_count > 0:
|
|
609
|
+
logger.info(f"Removed '{email}' from app '{app_slug}'")
|
|
610
|
+
return True
|
|
611
|
+
return False
|
|
612
|
+
|
|
613
|
+
async def deactivate_user(self, email: str) -> bool:
|
|
614
|
+
"""Deactivate a user account."""
|
|
615
|
+
result = await self._collection.update_one(
|
|
616
|
+
{"email": email},
|
|
617
|
+
{
|
|
618
|
+
"$set": {
|
|
619
|
+
"is_active": False,
|
|
620
|
+
"updated_at": datetime.utcnow(),
|
|
621
|
+
}
|
|
622
|
+
},
|
|
623
|
+
)
|
|
624
|
+
return result.modified_count > 0
|
|
625
|
+
|
|
626
|
+
async def activate_user(self, email: str) -> bool:
|
|
627
|
+
"""Activate a user account."""
|
|
628
|
+
result = await self._collection.update_one(
|
|
629
|
+
{"email": email},
|
|
630
|
+
{
|
|
631
|
+
"$set": {
|
|
632
|
+
"is_active": True,
|
|
633
|
+
"updated_at": datetime.utcnow(),
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
)
|
|
637
|
+
return result.modified_count > 0
|
|
638
|
+
|
|
639
|
+
@staticmethod
|
|
640
|
+
def user_has_role(
|
|
641
|
+
user: Dict[str, Any],
|
|
642
|
+
app_slug: str,
|
|
643
|
+
required_role: str,
|
|
644
|
+
role_hierarchy: Optional[Dict[str, List[str]]] = None,
|
|
645
|
+
) -> bool:
|
|
646
|
+
"""
|
|
647
|
+
Check if user has a required role for an app.
|
|
648
|
+
|
|
649
|
+
Args:
|
|
650
|
+
user: User dict (from validate_token or get_user_by_email)
|
|
651
|
+
app_slug: App slug to check
|
|
652
|
+
required_role: Role to check for
|
|
653
|
+
role_hierarchy: Optional dict mapping roles to their inherited roles
|
|
654
|
+
e.g., {"admin": ["editor", "viewer"], "editor": ["viewer"]}
|
|
655
|
+
|
|
656
|
+
Returns:
|
|
657
|
+
True if user has the required role (directly or via hierarchy)
|
|
658
|
+
"""
|
|
659
|
+
app_roles = user.get("app_roles", {}).get(app_slug, [])
|
|
660
|
+
|
|
661
|
+
# Direct role check
|
|
662
|
+
if required_role in app_roles:
|
|
663
|
+
return True
|
|
664
|
+
|
|
665
|
+
# Check via hierarchy
|
|
666
|
+
if role_hierarchy:
|
|
667
|
+
for user_role in app_roles:
|
|
668
|
+
inherited_roles = role_hierarchy.get(user_role, [])
|
|
669
|
+
if required_role in inherited_roles:
|
|
670
|
+
return True
|
|
671
|
+
|
|
672
|
+
return False
|
|
673
|
+
|
|
674
|
+
@staticmethod
|
|
675
|
+
def get_user_roles_for_app(
|
|
676
|
+
user: Dict[str, Any],
|
|
677
|
+
app_slug: str,
|
|
678
|
+
) -> List[str]:
|
|
679
|
+
"""Get user's roles for a specific app."""
|
|
680
|
+
return user.get("app_roles", {}).get(app_slug, [])
|
|
681
|
+
|
|
682
|
+
def _generate_token(
|
|
683
|
+
self,
|
|
684
|
+
user: Dict[str, Any],
|
|
685
|
+
extra_claims: Optional[Dict[str, Any]] = None,
|
|
686
|
+
) -> str:
|
|
687
|
+
"""
|
|
688
|
+
Generate JWT token for user with unique JTI for revocation support.
|
|
689
|
+
|
|
690
|
+
Token payload includes:
|
|
691
|
+
- sub: User ID
|
|
692
|
+
- email: User email
|
|
693
|
+
- jti: Unique token identifier (for revocation)
|
|
694
|
+
- iat: Issued at timestamp
|
|
695
|
+
- exp: Expiration timestamp
|
|
696
|
+
- Additional claims for session binding (ip, fingerprint) if provided
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
user: User document
|
|
700
|
+
extra_claims: Optional extra claims to include (e.g., ip, fingerprint)
|
|
701
|
+
|
|
702
|
+
Returns:
|
|
703
|
+
Signed JWT token string
|
|
704
|
+
"""
|
|
705
|
+
now = datetime.utcnow()
|
|
706
|
+
payload = {
|
|
707
|
+
"sub": str(user["_id"]),
|
|
708
|
+
"email": user["email"],
|
|
709
|
+
"jti": secrets.token_urlsafe(16), # Unique token ID for revocation
|
|
710
|
+
"iat": now,
|
|
711
|
+
"exp": now + timedelta(hours=self._token_expiry_hours),
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
# Add extra claims (session binding info, etc.)
|
|
715
|
+
if extra_claims:
|
|
716
|
+
payload.update(extra_claims)
|
|
717
|
+
|
|
718
|
+
return jwt.encode(payload, self._signing_key, algorithm=self._jwt_algorithm)
|
|
719
|
+
|
|
720
|
+
@staticmethod
|
|
721
|
+
def _sanitize_user(user: Dict[str, Any]) -> Dict[str, Any]:
|
|
722
|
+
"""Remove sensitive fields from user document."""
|
|
723
|
+
sanitized = dict(user)
|
|
724
|
+
sanitized.pop("password_hash", None)
|
|
725
|
+
# Convert ObjectId to string for JSON serialization
|
|
726
|
+
if "_id" in sanitized:
|
|
727
|
+
sanitized["_id"] = str(sanitized["_id"])
|
|
728
|
+
return sanitized
|
|
729
|
+
|
|
730
|
+
def get_secure_cookie_config(self, request: "Request") -> Dict[str, Any]:
|
|
731
|
+
"""
|
|
732
|
+
Get secure cookie settings for auth tokens.
|
|
733
|
+
|
|
734
|
+
Integrates with existing cookie_utils for environment-aware security settings.
|
|
735
|
+
Automatically enables Secure flag in production/HTTPS environments.
|
|
736
|
+
|
|
737
|
+
Args:
|
|
738
|
+
request: FastAPI Request object for environment detection
|
|
739
|
+
|
|
740
|
+
Returns:
|
|
741
|
+
Dict of cookie settings ready for response.set_cookie()
|
|
742
|
+
|
|
743
|
+
Usage:
|
|
744
|
+
cookie_config = user_pool.get_secure_cookie_config(request)
|
|
745
|
+
response.set_cookie(value=token, **cookie_config)
|
|
746
|
+
"""
|
|
747
|
+
from .cookie_utils import get_secure_cookie_settings
|
|
748
|
+
|
|
749
|
+
base_settings = get_secure_cookie_settings(request)
|
|
750
|
+
return {
|
|
751
|
+
**base_settings,
|
|
752
|
+
"key": "mdb_auth_token",
|
|
753
|
+
"max_age": self._token_expiry_hours * 3600,
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
@property
|
|
757
|
+
def token_expiry_hours(self) -> int:
|
|
758
|
+
"""Get token expiry duration in hours."""
|
|
759
|
+
return self._token_expiry_hours
|