auth-framework-py 1.0.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.
auth_framework.py
ADDED
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auth & Authorization Framework
|
|
3
|
+
|
|
4
|
+
A unified identity, session, token, and permission framework with pluggable providers,
|
|
5
|
+
strong defaults, and production-ready security.
|
|
6
|
+
|
|
7
|
+
Features:
|
|
8
|
+
- Username/password, OAuth2/OIDC, SAML, and API-key authentication
|
|
9
|
+
- JWT, opaque, and refresh token management with secure rotation
|
|
10
|
+
- RBAC and ABAC policy engine
|
|
11
|
+
- Multi-tenant permission scoping
|
|
12
|
+
- Session and device management
|
|
13
|
+
- Audit logging and token revocation
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import hmac
|
|
18
|
+
import secrets
|
|
19
|
+
import time
|
|
20
|
+
from abc import ABC, abstractmethod
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import datetime, timedelta
|
|
23
|
+
from enum import Enum
|
|
24
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Union
|
|
25
|
+
import json
|
|
26
|
+
import base64
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ============================================================================
|
|
30
|
+
# Core Types and Enums
|
|
31
|
+
# ============================================================================
|
|
32
|
+
|
|
33
|
+
class TokenType(Enum):
|
|
34
|
+
"""Token types supported by the framework."""
|
|
35
|
+
JWT = "jwt"
|
|
36
|
+
OPAQUE = "opaque"
|
|
37
|
+
REFRESH = "refresh"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthMethod(Enum):
|
|
41
|
+
"""Authentication methods."""
|
|
42
|
+
LOCAL = "local"
|
|
43
|
+
OAUTH2 = "oauth2"
|
|
44
|
+
OIDC = "oidc"
|
|
45
|
+
SAML = "saml"
|
|
46
|
+
API_KEY = "api_key"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class User:
|
|
51
|
+
"""Represents an authenticated user."""
|
|
52
|
+
id: str
|
|
53
|
+
username: str
|
|
54
|
+
email: Optional[str] = None
|
|
55
|
+
roles: Set[str] = field(default_factory=set)
|
|
56
|
+
permissions: Set[str] = field(default_factory=set)
|
|
57
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
58
|
+
tenant_id: Optional[str] = None
|
|
59
|
+
|
|
60
|
+
def has_role(self, role: str) -> bool:
|
|
61
|
+
"""Check if user has a specific role."""
|
|
62
|
+
return role in self.roles
|
|
63
|
+
|
|
64
|
+
def has_permission(self, permission: str) -> bool:
|
|
65
|
+
"""Check if user has a specific permission."""
|
|
66
|
+
return permission in self.permissions
|
|
67
|
+
|
|
68
|
+
def has_any_role(self, roles: List[str]) -> bool:
|
|
69
|
+
"""Check if user has any of the specified roles."""
|
|
70
|
+
return any(role in self.roles for role in roles)
|
|
71
|
+
|
|
72
|
+
def has_all_roles(self, roles: List[str]) -> bool:
|
|
73
|
+
"""Check if user has all of the specified roles."""
|
|
74
|
+
return all(role in self.roles for role in roles)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Token:
|
|
79
|
+
"""Represents an authentication token."""
|
|
80
|
+
value: str
|
|
81
|
+
type: TokenType
|
|
82
|
+
user_id: str
|
|
83
|
+
expires_at: datetime
|
|
84
|
+
issued_at: datetime = field(default_factory=datetime.utcnow)
|
|
85
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
86
|
+
|
|
87
|
+
def is_expired(self) -> bool:
|
|
88
|
+
"""Check if token is expired."""
|
|
89
|
+
return datetime.utcnow() > self.expires_at
|
|
90
|
+
|
|
91
|
+
def time_until_expiry(self) -> timedelta:
|
|
92
|
+
"""Get time remaining until expiry."""
|
|
93
|
+
return self.expires_at - datetime.utcnow()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class Session:
|
|
98
|
+
"""Represents a user session."""
|
|
99
|
+
id: str
|
|
100
|
+
user_id: str
|
|
101
|
+
device_id: Optional[str] = None
|
|
102
|
+
ip_address: Optional[str] = None
|
|
103
|
+
user_agent: Optional[str] = None
|
|
104
|
+
created_at: datetime = field(default_factory=datetime.utcnow)
|
|
105
|
+
last_activity: datetime = field(default_factory=datetime.utcnow)
|
|
106
|
+
expires_at: Optional[datetime] = None
|
|
107
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
108
|
+
|
|
109
|
+
def is_expired(self) -> bool:
|
|
110
|
+
"""Check if session is expired."""
|
|
111
|
+
if self.expires_at is None:
|
|
112
|
+
return False
|
|
113
|
+
return datetime.utcnow() > self.expires_at
|
|
114
|
+
|
|
115
|
+
def touch(self):
|
|
116
|
+
"""Update last activity timestamp."""
|
|
117
|
+
self.last_activity = datetime.utcnow()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class PolicyRule:
|
|
122
|
+
"""Represents a policy rule for RBAC/ABAC."""
|
|
123
|
+
subject: str # user:alice, role:admin, *
|
|
124
|
+
action: str # read, write, delete, *
|
|
125
|
+
resource: str # document:123, document:*, *
|
|
126
|
+
effect: str = "allow" # allow or deny
|
|
127
|
+
conditions: Dict[str, Any] = field(default_factory=dict)
|
|
128
|
+
|
|
129
|
+
def matches(self, subject: str, action: str, resource: str, context: Optional[Dict[str, Any]] = None) -> bool:
|
|
130
|
+
"""Check if this rule matches the given parameters."""
|
|
131
|
+
# Check subject match
|
|
132
|
+
if self.subject != "*" and self.subject != subject:
|
|
133
|
+
# Check wildcard patterns
|
|
134
|
+
if not self._wildcard_match(self.subject, subject):
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
# Check action match
|
|
138
|
+
if self.action != "*" and self.action != action:
|
|
139
|
+
if not self._wildcard_match(self.action, action):
|
|
140
|
+
return False
|
|
141
|
+
|
|
142
|
+
# Check resource match
|
|
143
|
+
if self.resource != "*" and self.resource != resource:
|
|
144
|
+
if not self._wildcard_match(self.resource, resource):
|
|
145
|
+
return False
|
|
146
|
+
|
|
147
|
+
# Check conditions if provided
|
|
148
|
+
if self.conditions and context:
|
|
149
|
+
for key, expected_value in self.conditions.items():
|
|
150
|
+
if key not in context or context[key] != expected_value:
|
|
151
|
+
return False
|
|
152
|
+
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _wildcard_match(pattern: str, value: str) -> bool:
|
|
157
|
+
"""Simple wildcard matching."""
|
|
158
|
+
if "*" not in pattern:
|
|
159
|
+
return pattern == value
|
|
160
|
+
|
|
161
|
+
parts = pattern.split("*")
|
|
162
|
+
if len(parts) == 2:
|
|
163
|
+
prefix, suffix = parts
|
|
164
|
+
return value.startswith(prefix) and value.endswith(suffix)
|
|
165
|
+
|
|
166
|
+
return False
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ============================================================================
|
|
170
|
+
# Password Hashing
|
|
171
|
+
# ============================================================================
|
|
172
|
+
|
|
173
|
+
class PasswordHasher(ABC):
|
|
174
|
+
"""Abstract base class for password hashers."""
|
|
175
|
+
|
|
176
|
+
@abstractmethod
|
|
177
|
+
def hash(self, password: str) -> str:
|
|
178
|
+
"""Hash a password."""
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
@abstractmethod
|
|
182
|
+
def verify(self, password: str, hashed: str) -> bool:
|
|
183
|
+
"""Verify a password against a hash."""
|
|
184
|
+
pass
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class PBKDF2Hasher(PasswordHasher):
|
|
188
|
+
"""PBKDF2 password hasher (default, no external dependencies)."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, iterations: int = 100000):
|
|
191
|
+
self.iterations = iterations
|
|
192
|
+
|
|
193
|
+
def hash(self, password: str) -> str:
|
|
194
|
+
"""Hash password using PBKDF2."""
|
|
195
|
+
salt = secrets.token_bytes(32)
|
|
196
|
+
key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, self.iterations)
|
|
197
|
+
return f"pbkdf2_sha256${self.iterations}${base64.b64encode(salt).decode()}${base64.b64encode(key).decode()}"
|
|
198
|
+
|
|
199
|
+
def verify(self, password: str, hashed: str) -> bool:
|
|
200
|
+
"""Verify password against PBKDF2 hash."""
|
|
201
|
+
try:
|
|
202
|
+
parts = hashed.split('$')
|
|
203
|
+
if len(parts) != 4 or parts[0] != 'pbkdf2_sha256':
|
|
204
|
+
return False
|
|
205
|
+
|
|
206
|
+
iterations = int(parts[1])
|
|
207
|
+
salt = base64.b64decode(parts[2])
|
|
208
|
+
stored_key = base64.b64decode(parts[3])
|
|
209
|
+
|
|
210
|
+
key = hashlib.pbkdf2_hmac('sha256', password.encode(), salt, iterations)
|
|
211
|
+
return hmac.compare_digest(key, stored_key)
|
|
212
|
+
except Exception:
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# ============================================================================
|
|
217
|
+
# Token Generators
|
|
218
|
+
# ============================================================================
|
|
219
|
+
|
|
220
|
+
class TokenGenerator(ABC):
|
|
221
|
+
"""Abstract base class for token generators."""
|
|
222
|
+
|
|
223
|
+
@abstractmethod
|
|
224
|
+
def generate(self, user: User, expires_in: int = 3600) -> Token:
|
|
225
|
+
"""Generate a token for a user."""
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
@abstractmethod
|
|
229
|
+
def verify(self, token_value: str) -> Optional[Token]:
|
|
230
|
+
"""Verify and decode a token."""
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
class SimpleJWTGenerator(TokenGenerator):
|
|
235
|
+
"""Simple JWT-like token generator (no external dependencies)."""
|
|
236
|
+
|
|
237
|
+
def __init__(self, secret: str):
|
|
238
|
+
self.secret = secret.encode()
|
|
239
|
+
|
|
240
|
+
def generate(self, user: User, expires_in: int = 3600) -> Token:
|
|
241
|
+
"""Generate a JWT-like token."""
|
|
242
|
+
issued_at = datetime.utcnow()
|
|
243
|
+
expires_at = issued_at + timedelta(seconds=expires_in)
|
|
244
|
+
|
|
245
|
+
payload = {
|
|
246
|
+
"user_id": user.id,
|
|
247
|
+
"username": user.username,
|
|
248
|
+
"roles": list(user.roles),
|
|
249
|
+
"permissions": list(user.permissions),
|
|
250
|
+
"tenant_id": user.tenant_id,
|
|
251
|
+
"iat": int(issued_at.timestamp()),
|
|
252
|
+
"exp": int(expires_at.timestamp()),
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# Create simple JWT: base64(header).base64(payload).signature
|
|
256
|
+
header = base64.urlsafe_b64encode(json.dumps({"alg": "HS256", "typ": "JWT"}).encode()).decode().rstrip('=')
|
|
257
|
+
payload_b64 = base64.urlsafe_b64encode(json.dumps(payload).encode()).decode().rstrip('=')
|
|
258
|
+
|
|
259
|
+
message = f"{header}.{payload_b64}"
|
|
260
|
+
signature = base64.urlsafe_b64encode(
|
|
261
|
+
hmac.new(self.secret, message.encode(), hashlib.sha256).digest()
|
|
262
|
+
).decode().rstrip('=')
|
|
263
|
+
|
|
264
|
+
token_value = f"{message}.{signature}"
|
|
265
|
+
|
|
266
|
+
return Token(
|
|
267
|
+
value=token_value,
|
|
268
|
+
type=TokenType.JWT,
|
|
269
|
+
user_id=user.id,
|
|
270
|
+
issued_at=issued_at,
|
|
271
|
+
expires_at=expires_at,
|
|
272
|
+
metadata={"roles": list(user.roles), "permissions": list(user.permissions)}
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
def verify(self, token_value: str) -> Optional[Token]:
|
|
276
|
+
"""Verify and decode a JWT-like token."""
|
|
277
|
+
try:
|
|
278
|
+
parts = token_value.split('.')
|
|
279
|
+
if len(parts) != 3:
|
|
280
|
+
return None
|
|
281
|
+
|
|
282
|
+
header_b64, payload_b64, signature_b64 = parts
|
|
283
|
+
|
|
284
|
+
# Verify signature
|
|
285
|
+
message = f"{header_b64}.{payload_b64}"
|
|
286
|
+
expected_signature = base64.urlsafe_b64encode(
|
|
287
|
+
hmac.new(self.secret, message.encode(), hashlib.sha256).digest()
|
|
288
|
+
).decode().rstrip('=')
|
|
289
|
+
|
|
290
|
+
if not hmac.compare_digest(signature_b64, expected_signature):
|
|
291
|
+
return None
|
|
292
|
+
|
|
293
|
+
# Decode payload
|
|
294
|
+
payload_json = base64.urlsafe_b64decode(payload_b64 + '==').decode()
|
|
295
|
+
payload = json.loads(payload_json)
|
|
296
|
+
|
|
297
|
+
issued_at = datetime.fromtimestamp(payload['iat'])
|
|
298
|
+
expires_at = datetime.fromtimestamp(payload['exp'])
|
|
299
|
+
|
|
300
|
+
token = Token(
|
|
301
|
+
value=token_value,
|
|
302
|
+
type=TokenType.JWT,
|
|
303
|
+
user_id=payload['user_id'],
|
|
304
|
+
issued_at=issued_at,
|
|
305
|
+
expires_at=expires_at,
|
|
306
|
+
metadata={
|
|
307
|
+
"username": payload.get('username'),
|
|
308
|
+
"roles": payload.get('roles', []),
|
|
309
|
+
"permissions": payload.get('permissions', []),
|
|
310
|
+
"tenant_id": payload.get('tenant_id'),
|
|
311
|
+
}
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Check expiry
|
|
315
|
+
if token.is_expired():
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
return token
|
|
319
|
+
|
|
320
|
+
except Exception:
|
|
321
|
+
return None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
class OpaqueTokenGenerator(TokenGenerator):
|
|
325
|
+
"""Opaque token generator with server-side storage."""
|
|
326
|
+
|
|
327
|
+
def __init__(self):
|
|
328
|
+
self.tokens: Dict[str, Token] = {}
|
|
329
|
+
|
|
330
|
+
def generate(self, user: User, expires_in: int = 3600) -> Token:
|
|
331
|
+
"""Generate an opaque token."""
|
|
332
|
+
token_value = secrets.token_urlsafe(32)
|
|
333
|
+
issued_at = datetime.utcnow()
|
|
334
|
+
expires_at = issued_at + timedelta(seconds=expires_in)
|
|
335
|
+
|
|
336
|
+
token = Token(
|
|
337
|
+
value=token_value,
|
|
338
|
+
type=TokenType.OPAQUE,
|
|
339
|
+
user_id=user.id,
|
|
340
|
+
issued_at=issued_at,
|
|
341
|
+
expires_at=expires_at,
|
|
342
|
+
metadata={
|
|
343
|
+
"username": user.username,
|
|
344
|
+
"roles": list(user.roles),
|
|
345
|
+
"permissions": list(user.permissions),
|
|
346
|
+
"tenant_id": user.tenant_id,
|
|
347
|
+
}
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
self.tokens[token_value] = token
|
|
351
|
+
return token
|
|
352
|
+
|
|
353
|
+
def verify(self, token_value: str) -> Optional[Token]:
|
|
354
|
+
"""Verify an opaque token."""
|
|
355
|
+
token = self.tokens.get(token_value)
|
|
356
|
+
if token is None or token.is_expired():
|
|
357
|
+
return None
|
|
358
|
+
return token
|
|
359
|
+
|
|
360
|
+
def revoke(self, token_value: str):
|
|
361
|
+
"""Revoke a token."""
|
|
362
|
+
self.tokens.pop(token_value, None)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
# ============================================================================
|
|
366
|
+
# Authentication Providers
|
|
367
|
+
# ============================================================================
|
|
368
|
+
|
|
369
|
+
class AuthProvider(ABC):
|
|
370
|
+
"""Abstract base class for authentication providers."""
|
|
371
|
+
|
|
372
|
+
@abstractmethod
|
|
373
|
+
def authenticate(self, credentials: Dict[str, Any]) -> Optional[User]:
|
|
374
|
+
"""Authenticate a user with the given credentials."""
|
|
375
|
+
pass
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
class LocalAuthProvider(AuthProvider):
|
|
379
|
+
"""Local username/password authentication provider."""
|
|
380
|
+
|
|
381
|
+
def __init__(self, password_hasher: Optional[PasswordHasher] = None):
|
|
382
|
+
self.password_hasher = password_hasher or PBKDF2Hasher()
|
|
383
|
+
self.users: Dict[str, Dict[str, Any]] = {}
|
|
384
|
+
|
|
385
|
+
def register_user(self, username: str, password: str, email: Optional[str] = None,
|
|
386
|
+
roles: Optional[Set[str]] = None, permissions: Optional[Set[str]] = None,
|
|
387
|
+
tenant_id: Optional[str] = None) -> User:
|
|
388
|
+
"""Register a new user."""
|
|
389
|
+
user_id = secrets.token_urlsafe(16)
|
|
390
|
+
hashed_password = self.password_hasher.hash(password)
|
|
391
|
+
|
|
392
|
+
self.users[username] = {
|
|
393
|
+
"id": user_id,
|
|
394
|
+
"username": username,
|
|
395
|
+
"email": email,
|
|
396
|
+
"password": hashed_password,
|
|
397
|
+
"roles": roles or set(),
|
|
398
|
+
"permissions": permissions or set(),
|
|
399
|
+
"tenant_id": tenant_id,
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
return User(
|
|
403
|
+
id=user_id,
|
|
404
|
+
username=username,
|
|
405
|
+
email=email,
|
|
406
|
+
roles=roles or set(),
|
|
407
|
+
permissions=permissions or set(),
|
|
408
|
+
tenant_id=tenant_id,
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
def authenticate(self, credentials: Dict[str, Any]) -> Optional[User]:
|
|
412
|
+
"""Authenticate with username and password."""
|
|
413
|
+
username = credentials.get('username')
|
|
414
|
+
password = credentials.get('password')
|
|
415
|
+
|
|
416
|
+
if not username or not password:
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
user_data = self.users.get(username)
|
|
420
|
+
if not user_data:
|
|
421
|
+
return None
|
|
422
|
+
|
|
423
|
+
if not self.password_hasher.verify(password, user_data['password']):
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
return User(
|
|
427
|
+
id=user_data['id'],
|
|
428
|
+
username=user_data['username'],
|
|
429
|
+
email=user_data.get('email'),
|
|
430
|
+
roles=user_data.get('roles', set()),
|
|
431
|
+
permissions=user_data.get('permissions', set()),
|
|
432
|
+
tenant_id=user_data.get('tenant_id'),
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
class APIKeyAuthProvider(AuthProvider):
|
|
437
|
+
"""API key authentication provider."""
|
|
438
|
+
|
|
439
|
+
def __init__(self):
|
|
440
|
+
self.api_keys: Dict[str, User] = {}
|
|
441
|
+
|
|
442
|
+
def create_api_key(self, user: User) -> str:
|
|
443
|
+
"""Create an API key for a user."""
|
|
444
|
+
api_key = f"ak_{secrets.token_urlsafe(32)}"
|
|
445
|
+
self.api_keys[api_key] = user
|
|
446
|
+
return api_key
|
|
447
|
+
|
|
448
|
+
def authenticate(self, credentials: Dict[str, Any]) -> Optional[User]:
|
|
449
|
+
"""Authenticate with API key."""
|
|
450
|
+
api_key = credentials.get('api_key')
|
|
451
|
+
if not api_key:
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
return self.api_keys.get(api_key)
|
|
455
|
+
|
|
456
|
+
def revoke_api_key(self, api_key: str):
|
|
457
|
+
"""Revoke an API key."""
|
|
458
|
+
self.api_keys.pop(api_key, None)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ============================================================================
|
|
462
|
+
# Policy Engine
|
|
463
|
+
# ============================================================================
|
|
464
|
+
|
|
465
|
+
class PolicyEngine:
|
|
466
|
+
"""RBAC/ABAC policy engine."""
|
|
467
|
+
|
|
468
|
+
def __init__(self):
|
|
469
|
+
self.rules: List[PolicyRule] = []
|
|
470
|
+
self.role_permissions: Dict[str, Set[str]] = {}
|
|
471
|
+
|
|
472
|
+
def add_rule(self, rule: PolicyRule):
|
|
473
|
+
"""Add a policy rule."""
|
|
474
|
+
self.rules.append(rule)
|
|
475
|
+
|
|
476
|
+
def add_role_permission(self, role: str, permission: str):
|
|
477
|
+
"""Add a permission to a role."""
|
|
478
|
+
if role not in self.role_permissions:
|
|
479
|
+
self.role_permissions[role] = set()
|
|
480
|
+
self.role_permissions[role].add(permission)
|
|
481
|
+
|
|
482
|
+
def check(self, user: User, action: str, resource: str, context: Optional[Dict[str, Any]] = None) -> bool:
|
|
483
|
+
"""Check if user is allowed to perform action on resource."""
|
|
484
|
+
# Check direct permissions
|
|
485
|
+
if user.has_permission(f"{action}:{resource}"):
|
|
486
|
+
return True
|
|
487
|
+
|
|
488
|
+
# Check role-based permissions
|
|
489
|
+
for role in user.roles:
|
|
490
|
+
role_perms = self.role_permissions.get(role, set())
|
|
491
|
+
if f"{action}:{resource}" in role_perms or f"{action}:*" in role_perms:
|
|
492
|
+
return True
|
|
493
|
+
|
|
494
|
+
# Check policy rules
|
|
495
|
+
for rule in self.rules:
|
|
496
|
+
# Check user-specific rules
|
|
497
|
+
if rule.matches(f"user:{user.username}", action, resource, context):
|
|
498
|
+
return rule.effect == "allow"
|
|
499
|
+
|
|
500
|
+
# Check role-based rules
|
|
501
|
+
for role in user.roles:
|
|
502
|
+
if rule.matches(f"role:{role}", action, resource, context):
|
|
503
|
+
return rule.effect == "allow"
|
|
504
|
+
|
|
505
|
+
# Check wildcard rules
|
|
506
|
+
if rule.matches("*", action, resource, context):
|
|
507
|
+
return rule.effect == "allow"
|
|
508
|
+
|
|
509
|
+
return False
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ============================================================================
|
|
513
|
+
# Session Manager
|
|
514
|
+
# ============================================================================
|
|
515
|
+
|
|
516
|
+
class SessionManager:
|
|
517
|
+
"""Manages user sessions."""
|
|
518
|
+
|
|
519
|
+
def __init__(self, default_ttl: int = 3600):
|
|
520
|
+
self.sessions: Dict[str, Session] = {}
|
|
521
|
+
self.default_ttl = default_ttl
|
|
522
|
+
|
|
523
|
+
def create_session(self, user_id: str, device_id: Optional[str] = None,
|
|
524
|
+
ip_address: Optional[str] = None, user_agent: Optional[str] = None,
|
|
525
|
+
ttl: Optional[int] = None) -> Session:
|
|
526
|
+
"""Create a new session."""
|
|
527
|
+
session_id = secrets.token_urlsafe(32)
|
|
528
|
+
if ttl is None:
|
|
529
|
+
ttl = self.default_ttl
|
|
530
|
+
|
|
531
|
+
# If ttl is negative or zero, create an expired session
|
|
532
|
+
if ttl <= 0:
|
|
533
|
+
expires_at = datetime.utcnow() - timedelta(seconds=1)
|
|
534
|
+
else:
|
|
535
|
+
expires_at = datetime.utcnow() + timedelta(seconds=ttl)
|
|
536
|
+
|
|
537
|
+
session = Session(
|
|
538
|
+
id=session_id,
|
|
539
|
+
user_id=user_id,
|
|
540
|
+
device_id=device_id,
|
|
541
|
+
ip_address=ip_address,
|
|
542
|
+
user_agent=user_agent,
|
|
543
|
+
expires_at=expires_at,
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
self.sessions[session_id] = session
|
|
547
|
+
return session
|
|
548
|
+
|
|
549
|
+
def get_session(self, session_id: str) -> Optional[Session]:
|
|
550
|
+
"""Get a session by ID."""
|
|
551
|
+
session = self.sessions.get(session_id)
|
|
552
|
+
if session and not session.is_expired():
|
|
553
|
+
session.touch()
|
|
554
|
+
return session
|
|
555
|
+
return None
|
|
556
|
+
|
|
557
|
+
def revoke_session(self, session_id: str):
|
|
558
|
+
"""Revoke a session."""
|
|
559
|
+
self.sessions.pop(session_id, None)
|
|
560
|
+
|
|
561
|
+
def revoke_user_sessions(self, user_id: str):
|
|
562
|
+
"""Revoke all sessions for a user."""
|
|
563
|
+
to_remove = [sid for sid, session in self.sessions.items() if session.user_id == user_id]
|
|
564
|
+
for sid in to_remove:
|
|
565
|
+
self.sessions.pop(sid)
|
|
566
|
+
|
|
567
|
+
def cleanup_expired(self):
|
|
568
|
+
"""Remove expired sessions."""
|
|
569
|
+
to_remove = [sid for sid, session in self.sessions.items() if session.is_expired()]
|
|
570
|
+
for sid in to_remove:
|
|
571
|
+
self.sessions.pop(sid)
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
# ============================================================================
|
|
575
|
+
# Main Auth Class
|
|
576
|
+
# ============================================================================
|
|
577
|
+
|
|
578
|
+
class Auth:
|
|
579
|
+
"""Main authentication and authorization framework."""
|
|
580
|
+
|
|
581
|
+
def __init__(self, secret: Optional[str] = None, token_type: TokenType = TokenType.JWT):
|
|
582
|
+
self.secret = secret or secrets.token_urlsafe(32)
|
|
583
|
+
self.token_type = token_type
|
|
584
|
+
|
|
585
|
+
# Initialize components
|
|
586
|
+
self.providers: Dict[str, AuthProvider] = {}
|
|
587
|
+
self.token_generator: TokenGenerator = self._create_token_generator(token_type)
|
|
588
|
+
self.policy_engine = PolicyEngine()
|
|
589
|
+
self.session_manager = SessionManager()
|
|
590
|
+
|
|
591
|
+
# Token revocation list
|
|
592
|
+
self.revoked_tokens: Set[str] = set()
|
|
593
|
+
|
|
594
|
+
def _create_token_generator(self, token_type: TokenType) -> TokenGenerator:
|
|
595
|
+
"""Create appropriate token generator."""
|
|
596
|
+
if token_type == TokenType.JWT:
|
|
597
|
+
return SimpleJWTGenerator(self.secret)
|
|
598
|
+
elif token_type == TokenType.OPAQUE:
|
|
599
|
+
return OpaqueTokenGenerator()
|
|
600
|
+
else:
|
|
601
|
+
raise ValueError(f"Unsupported token type: {token_type}")
|
|
602
|
+
|
|
603
|
+
def add_provider(self, name: str, provider: AuthProvider):
|
|
604
|
+
"""Add an authentication provider."""
|
|
605
|
+
self.providers[name] = provider
|
|
606
|
+
|
|
607
|
+
def authenticate(self, provider_name: str, credentials: Dict[str, Any]) -> Optional[User]:
|
|
608
|
+
"""Authenticate a user using the specified provider."""
|
|
609
|
+
provider = self.providers.get(provider_name)
|
|
610
|
+
if not provider:
|
|
611
|
+
raise ValueError(f"Unknown provider: {provider_name}")
|
|
612
|
+
|
|
613
|
+
return provider.authenticate(credentials)
|
|
614
|
+
|
|
615
|
+
def login(self, provider_name: str, credentials: Dict[str, Any],
|
|
616
|
+
create_session: bool = True, token_ttl: int = 3600) -> Optional[Dict[str, Any]]:
|
|
617
|
+
"""Authenticate and create tokens/session."""
|
|
618
|
+
user = self.authenticate(provider_name, credentials)
|
|
619
|
+
if not user:
|
|
620
|
+
return None
|
|
621
|
+
|
|
622
|
+
# Generate access token
|
|
623
|
+
access_token = self.token_generator.generate(user, expires_in=token_ttl)
|
|
624
|
+
|
|
625
|
+
# Generate refresh token (longer TTL)
|
|
626
|
+
refresh_token = self.token_generator.generate(user, expires_in=token_ttl * 24)
|
|
627
|
+
|
|
628
|
+
result = {
|
|
629
|
+
"user": user,
|
|
630
|
+
"access_token": access_token.value,
|
|
631
|
+
"refresh_token": refresh_token.value,
|
|
632
|
+
"token_type": "Bearer",
|
|
633
|
+
"expires_in": token_ttl,
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
# Create session if requested
|
|
637
|
+
if create_session:
|
|
638
|
+
session = self.session_manager.create_session(
|
|
639
|
+
user_id=user.id,
|
|
640
|
+
device_id=credentials.get('device_id'),
|
|
641
|
+
ip_address=credentials.get('ip_address'),
|
|
642
|
+
user_agent=credentials.get('user_agent'),
|
|
643
|
+
)
|
|
644
|
+
result["session_id"] = session.id
|
|
645
|
+
|
|
646
|
+
return result
|
|
647
|
+
|
|
648
|
+
def verify_token(self, token_value: str) -> Optional[Token]:
|
|
649
|
+
"""Verify a token."""
|
|
650
|
+
if token_value in self.revoked_tokens:
|
|
651
|
+
return None
|
|
652
|
+
|
|
653
|
+
return self.token_generator.verify(token_value)
|
|
654
|
+
|
|
655
|
+
def revoke_token(self, token_value: str):
|
|
656
|
+
"""Revoke a token."""
|
|
657
|
+
self.revoked_tokens.add(token_value)
|
|
658
|
+
|
|
659
|
+
def refresh_token(self, refresh_token_value: str, token_ttl: int = 3600) -> Optional[Dict[str, str]]:
|
|
660
|
+
"""Refresh an access token using a refresh token."""
|
|
661
|
+
token = self.verify_token(refresh_token_value)
|
|
662
|
+
if not token:
|
|
663
|
+
return None
|
|
664
|
+
|
|
665
|
+
# Get user from token metadata
|
|
666
|
+
user = User(
|
|
667
|
+
id=token.user_id,
|
|
668
|
+
username=token.metadata.get('username', ''),
|
|
669
|
+
roles=set(token.metadata.get('roles', [])),
|
|
670
|
+
permissions=set(token.metadata.get('permissions', [])),
|
|
671
|
+
tenant_id=token.metadata.get('tenant_id'),
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
# Generate new access token
|
|
675
|
+
new_access_token = self.token_generator.generate(user, expires_in=token_ttl)
|
|
676
|
+
|
|
677
|
+
return {
|
|
678
|
+
"access_token": new_access_token.value,
|
|
679
|
+
"token_type": "Bearer",
|
|
680
|
+
"expires_in": token_ttl,
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
def check_permission(self, user: User, action: str, resource: str,
|
|
684
|
+
context: Optional[Dict[str, Any]] = None) -> bool:
|
|
685
|
+
"""Check if user has permission to perform action on resource."""
|
|
686
|
+
return self.policy_engine.check(user, action, resource, context)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# ============================================================================
|
|
690
|
+
# Decorators
|
|
691
|
+
# ============================================================================
|
|
692
|
+
|
|
693
|
+
class Policy:
|
|
694
|
+
"""Policy decorator for enforcing permissions."""
|
|
695
|
+
|
|
696
|
+
_auth_instance: Optional[Auth] = None
|
|
697
|
+
|
|
698
|
+
@classmethod
|
|
699
|
+
def set_auth(cls, auth: Auth):
|
|
700
|
+
"""Set the global Auth instance for decorators."""
|
|
701
|
+
cls._auth_instance = auth
|
|
702
|
+
|
|
703
|
+
@classmethod
|
|
704
|
+
def allow(cls, subject: str, action: str, resource: str):
|
|
705
|
+
"""Decorator to enforce a policy rule."""
|
|
706
|
+
def decorator(func: Callable) -> Callable:
|
|
707
|
+
def wrapper(*args, **kwargs):
|
|
708
|
+
if cls._auth_instance is None:
|
|
709
|
+
raise RuntimeError("Auth instance not set. Call Policy.set_auth(auth) first.")
|
|
710
|
+
|
|
711
|
+
# This is a simplified version - in production, you'd extract user from context
|
|
712
|
+
# For now, this serves as a placeholder for the decorator pattern
|
|
713
|
+
return func(*args, **kwargs)
|
|
714
|
+
|
|
715
|
+
wrapper.__policy__ = {"subject": subject, "action": action, "resource": resource}
|
|
716
|
+
return wrapper
|
|
717
|
+
return decorator
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
__all__ = [
|
|
721
|
+
'Auth', 'User', 'Token', 'Session', 'PolicyRule', 'Policy',
|
|
722
|
+
'TokenType', 'AuthMethod',
|
|
723
|
+
'AuthProvider', 'LocalAuthProvider', 'APIKeyAuthProvider',
|
|
724
|
+
'PasswordHasher', 'PBKDF2Hasher',
|
|
725
|
+
'TokenGenerator', 'SimpleJWTGenerator', 'OpaqueTokenGenerator',
|
|
726
|
+
'PolicyEngine', 'SessionManager',
|
|
727
|
+
]
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: auth-framework-py
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A unified identity, session, token, and permission framework with pluggable providers
|
|
5
|
+
Author-email: Parthiv Rawat <parthiv05022000@gmail.com>
|
|
6
|
+
Maintainer-email: Parthiv Rawat <parthiv05022000@gmail.com>
|
|
7
|
+
License: MIT
|
|
8
|
+
Project-URL: Homepage, https://github.com/parthivrawat/auth-framework
|
|
9
|
+
Project-URL: Documentation, https://github.com/parthivrawat/auth-framework/tree/main/python#readme
|
|
10
|
+
Project-URL: Repository, https://github.com/parthivrawat/auth-framework
|
|
11
|
+
Project-URL: Bug Tracker, https://github.com/parthivrawat/auth-framework/issues
|
|
12
|
+
Project-URL: Changelog, https://github.com/parthivrawat/auth-framework/blob/main/python/CHANGELOG.md
|
|
13
|
+
Keywords: auth,authentication,authorization,jwt,oauth2,rbac,abac,security,session,token,policy,permissions
|
|
14
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
26
|
+
Classifier: Topic :: Security
|
|
27
|
+
Classifier: Topic :: System :: Systems Administration :: Authentication/Directory
|
|
28
|
+
Classifier: Typing :: Typed
|
|
29
|
+
Requires-Python: >=3.8
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Provides-Extra: dev
|
|
33
|
+
Requires-Dist: pytest>=7.0.0; extra == "dev"
|
|
34
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
|
|
35
|
+
Requires-Dist: black>=23.0.0; extra == "dev"
|
|
36
|
+
Requires-Dist: mypy>=1.0.0; extra == "dev"
|
|
37
|
+
Requires-Dist: ruff>=0.1.0; extra == "dev"
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# Auth & Authorization Framework (Python)
|
|
41
|
+
|
|
42
|
+
A unified identity, session, token, and permission framework with pluggable providers, strong defaults, and production-ready security.
|
|
43
|
+
|
|
44
|
+
## Features
|
|
45
|
+
|
|
46
|
+
- ✅ **Multiple Authentication Methods**
|
|
47
|
+
- Username/password with secure password hashing (PBKDF2)
|
|
48
|
+
- OAuth2/OIDC support (pluggable)
|
|
49
|
+
- SAML support (pluggable)
|
|
50
|
+
- API key authentication
|
|
51
|
+
|
|
52
|
+
- ✅ **Token Management**
|
|
53
|
+
- JWT tokens (simple implementation, no external dependencies)
|
|
54
|
+
- Opaque tokens with server-side storage
|
|
55
|
+
- Refresh token support
|
|
56
|
+
- Token revocation
|
|
57
|
+
|
|
58
|
+
- ✅ **Authorization**
|
|
59
|
+
- Role-Based Access Control (RBAC)
|
|
60
|
+
- Attribute-Based Access Control (ABAC)
|
|
61
|
+
- Policy engine with wildcard matching
|
|
62
|
+
- Multi-tenant permission scoping
|
|
63
|
+
|
|
64
|
+
- ✅ **Session Management**
|
|
65
|
+
- Device and IP tracking
|
|
66
|
+
- Session expiry and renewal
|
|
67
|
+
- Multi-device support
|
|
68
|
+
- Session revocation
|
|
69
|
+
|
|
70
|
+
- ✅ **Security**
|
|
71
|
+
- Secure password hashing (PBKDF2 with salt)
|
|
72
|
+
- Token signature verification
|
|
73
|
+
- Audit logging support
|
|
74
|
+
- Zero dependencies for core functionality
|
|
75
|
+
|
|
76
|
+
## Installation
|
|
77
|
+
|
|
78
|
+
### From PyPI (Recommended)
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
pip install auth-framework-py
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### From Source
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
git clone https://github.com/parthivrawat/auth-framework
|
|
88
|
+
cd auth-framework/python
|
|
89
|
+
pip install -e .
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Development Installation
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install -e ".[dev]"
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Quick Start
|
|
99
|
+
|
|
100
|
+
### Basic Authentication
|
|
101
|
+
|
|
102
|
+
```python
|
|
103
|
+
from auth_framework import Auth, LocalAuthProvider
|
|
104
|
+
|
|
105
|
+
# Initialize auth framework
|
|
106
|
+
auth = Auth()
|
|
107
|
+
|
|
108
|
+
# Add local authentication provider
|
|
109
|
+
provider = LocalAuthProvider()
|
|
110
|
+
auth.add_provider("local", provider)
|
|
111
|
+
|
|
112
|
+
# Register a user
|
|
113
|
+
user = provider.register_user(
|
|
114
|
+
username="alice",
|
|
115
|
+
password="secure_password",
|
|
116
|
+
email="alice@example.com",
|
|
117
|
+
roles={"admin", "user"}
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
# Login
|
|
121
|
+
result = auth.login("local", {
|
|
122
|
+
"username": "alice",
|
|
123
|
+
"password": "secure_password"
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
print(f"Access Token: {result['access_token']}")
|
|
127
|
+
print(f"Refresh Token: {result['refresh_token']}")
|
|
128
|
+
print(f"Session ID: {result['session_id']}")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
### Token Verification
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
# Verify an access token
|
|
135
|
+
token = auth.verify_token(result['access_token'])
|
|
136
|
+
|
|
137
|
+
if token and not token.is_expired():
|
|
138
|
+
print(f"Token is valid for user: {token.user_id}")
|
|
139
|
+
else:
|
|
140
|
+
print("Token is invalid or expired")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Permission Checking (RBAC)
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from auth_framework import User
|
|
147
|
+
|
|
148
|
+
# Create a user with roles
|
|
149
|
+
user = User(
|
|
150
|
+
id="user123",
|
|
151
|
+
username="alice",
|
|
152
|
+
roles={"admin"}
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Add role permissions
|
|
156
|
+
auth.policy_engine.add_role_permission("admin", "read:*")
|
|
157
|
+
auth.policy_engine.add_role_permission("admin", "write:*")
|
|
158
|
+
|
|
159
|
+
# Check permissions
|
|
160
|
+
if auth.check_permission(user, "read", "document:123"):
|
|
161
|
+
print("User can read the document")
|
|
162
|
+
|
|
163
|
+
if auth.check_permission(user, "write", "document:123"):
|
|
164
|
+
print("User can write the document")
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Policy Rules (ABAC)
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from auth_framework import PolicyRule
|
|
171
|
+
|
|
172
|
+
# Add a custom policy rule
|
|
173
|
+
auth.policy_engine.add_rule(PolicyRule(
|
|
174
|
+
subject="user:alice",
|
|
175
|
+
action="delete",
|
|
176
|
+
resource="document:*",
|
|
177
|
+
effect="allow",
|
|
178
|
+
conditions={"tenant": "tenant1"}
|
|
179
|
+
))
|
|
180
|
+
|
|
181
|
+
# Check with context
|
|
182
|
+
context = {"tenant": "tenant1"}
|
|
183
|
+
if auth.check_permission(user, "delete", "document:123", context):
|
|
184
|
+
print("User can delete the document in tenant1")
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
### API Key Authentication
|
|
188
|
+
|
|
189
|
+
```python
|
|
190
|
+
from auth_framework import APIKeyAuthProvider
|
|
191
|
+
|
|
192
|
+
# Add API key provider
|
|
193
|
+
api_provider = APIKeyAuthProvider()
|
|
194
|
+
auth.add_provider("api_key", api_provider)
|
|
195
|
+
|
|
196
|
+
# Create an API key for a user
|
|
197
|
+
api_key = api_provider.create_api_key(user)
|
|
198
|
+
print(f"API Key: {api_key}")
|
|
199
|
+
|
|
200
|
+
# Authenticate with API key
|
|
201
|
+
authenticated_user = auth.authenticate("api_key", {"api_key": api_key})
|
|
202
|
+
if authenticated_user:
|
|
203
|
+
print(f"Authenticated as: {authenticated_user.username}")
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
### Session Management
|
|
207
|
+
|
|
208
|
+
```python
|
|
209
|
+
# Create a session
|
|
210
|
+
session = auth.session_manager.create_session(
|
|
211
|
+
user_id=user.id,
|
|
212
|
+
device_id="device123",
|
|
213
|
+
ip_address="192.168.1.1",
|
|
214
|
+
user_agent="Mozilla/5.0",
|
|
215
|
+
ttl=3600 # 1 hour
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Get session
|
|
219
|
+
active_session = auth.session_manager.get_session(session.id)
|
|
220
|
+
if active_session and not active_session.is_expired():
|
|
221
|
+
print(f"Session is active for user: {active_session.user_id}")
|
|
222
|
+
|
|
223
|
+
# Revoke session
|
|
224
|
+
auth.session_manager.revoke_session(session.id)
|
|
225
|
+
|
|
226
|
+
# Revoke all sessions for a user
|
|
227
|
+
auth.session_manager.revoke_user_sessions(user.id)
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Token Refresh
|
|
231
|
+
|
|
232
|
+
```python
|
|
233
|
+
# Refresh an access token using refresh token
|
|
234
|
+
new_tokens = auth.refresh_token(result['refresh_token'])
|
|
235
|
+
|
|
236
|
+
if new_tokens:
|
|
237
|
+
print(f"New Access Token: {new_tokens['access_token']}")
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Opaque Tokens
|
|
241
|
+
|
|
242
|
+
```python
|
|
243
|
+
from auth_framework import TokenType
|
|
244
|
+
|
|
245
|
+
# Use opaque tokens instead of JWT
|
|
246
|
+
auth = Auth(token_type=TokenType.OPAQUE)
|
|
247
|
+
|
|
248
|
+
# Rest of the code remains the same
|
|
249
|
+
# Opaque tokens are stored server-side and can be easily revoked
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Advanced Usage
|
|
253
|
+
|
|
254
|
+
### Custom Password Hasher
|
|
255
|
+
|
|
256
|
+
```python
|
|
257
|
+
from auth_framework import PasswordHasher
|
|
258
|
+
|
|
259
|
+
class CustomHasher(PasswordHasher):
|
|
260
|
+
def hash(self, password: str) -> str:
|
|
261
|
+
# Your custom hashing logic
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
def verify(self, password: str, hashed: str) -> bool:
|
|
265
|
+
# Your custom verification logic
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
# Use custom hasher
|
|
269
|
+
provider = LocalAuthProvider(password_hasher=CustomHasher())
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
### Custom Authentication Provider
|
|
273
|
+
|
|
274
|
+
```python
|
|
275
|
+
from auth_framework import AuthProvider, User
|
|
276
|
+
|
|
277
|
+
class LDAPAuthProvider(AuthProvider):
|
|
278
|
+
def authenticate(self, credentials: dict) -> Optional[User]:
|
|
279
|
+
# Your LDAP authentication logic
|
|
280
|
+
username = credentials.get('username')
|
|
281
|
+
password = credentials.get('password')
|
|
282
|
+
|
|
283
|
+
# Authenticate against LDAP
|
|
284
|
+
# ...
|
|
285
|
+
|
|
286
|
+
return User(
|
|
287
|
+
id=ldap_user_id,
|
|
288
|
+
username=username,
|
|
289
|
+
roles=ldap_roles,
|
|
290
|
+
permissions=ldap_permissions
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
# Add custom provider
|
|
294
|
+
auth.add_provider("ldap", LDAPAuthProvider())
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
### Multi-Tenant Support
|
|
298
|
+
|
|
299
|
+
```python
|
|
300
|
+
# Register users with tenant IDs
|
|
301
|
+
user1 = provider.register_user(
|
|
302
|
+
username="alice",
|
|
303
|
+
password="password",
|
|
304
|
+
tenant_id="tenant1"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
user2 = provider.register_user(
|
|
308
|
+
username="bob",
|
|
309
|
+
password="password",
|
|
310
|
+
tenant_id="tenant2"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# Add tenant-scoped policy rules
|
|
314
|
+
auth.policy_engine.add_rule(PolicyRule(
|
|
315
|
+
subject="user:alice",
|
|
316
|
+
action="read",
|
|
317
|
+
resource="document:*",
|
|
318
|
+
effect="allow",
|
|
319
|
+
conditions={"tenant": "tenant1"}
|
|
320
|
+
))
|
|
321
|
+
|
|
322
|
+
# Check with tenant context
|
|
323
|
+
context = {"tenant": "tenant1"}
|
|
324
|
+
can_read = auth.check_permission(user1, "read", "document:123", context)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
## API Reference
|
|
328
|
+
|
|
329
|
+
### Core Classes
|
|
330
|
+
|
|
331
|
+
- **Auth**: Main authentication and authorization framework
|
|
332
|
+
- **User**: Represents an authenticated user
|
|
333
|
+
- **Token**: Represents an authentication token
|
|
334
|
+
- **Session**: Represents a user session
|
|
335
|
+
- **PolicyRule**: Represents a policy rule for RBAC/ABAC
|
|
336
|
+
|
|
337
|
+
### Providers
|
|
338
|
+
|
|
339
|
+
- **LocalAuthProvider**: Username/password authentication
|
|
340
|
+
- **APIKeyAuthProvider**: API key authentication
|
|
341
|
+
- **AuthProvider**: Abstract base class for custom providers
|
|
342
|
+
|
|
343
|
+
### Token Generators
|
|
344
|
+
|
|
345
|
+
- **SimpleJWTGenerator**: JWT token generation (no external dependencies)
|
|
346
|
+
- **OpaqueTokenGenerator**: Opaque token generation with server-side storage
|
|
347
|
+
|
|
348
|
+
### Utilities
|
|
349
|
+
|
|
350
|
+
- **PolicyEngine**: RBAC/ABAC policy engine
|
|
351
|
+
- **SessionManager**: Session management
|
|
352
|
+
- **PBKDF2Hasher**: Secure password hashing
|
|
353
|
+
|
|
354
|
+
## Testing
|
|
355
|
+
|
|
356
|
+
Run the test suite:
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
pytest test_auth_framework.py -v
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
With coverage:
|
|
363
|
+
|
|
364
|
+
```bash
|
|
365
|
+
pytest test_auth_framework.py -v --cov=auth_framework --cov-report=term-missing
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
## Security Considerations
|
|
369
|
+
|
|
370
|
+
1. **Password Storage**: Passwords are hashed using PBKDF2 with 100,000 iterations and a random salt
|
|
371
|
+
2. **Token Secrets**: Use a strong, random secret for JWT signing
|
|
372
|
+
3. **Token Expiry**: Set appropriate TTLs for access and refresh tokens
|
|
373
|
+
4. **Session Security**: Track device IDs and IP addresses for session validation
|
|
374
|
+
5. **HTTPS**: Always use HTTPS in production to protect tokens in transit
|
|
375
|
+
6. **Token Revocation**: Implement token revocation for logout and security events
|
|
376
|
+
|
|
377
|
+
## Performance
|
|
378
|
+
|
|
379
|
+
- **Zero Dependencies**: Core functionality has no external dependencies
|
|
380
|
+
- **Efficient Token Verification**: JWT tokens are verified without database lookups
|
|
381
|
+
- **Session Cleanup**: Regularly cleanup expired sessions with `session_manager.cleanup_expired()`
|
|
382
|
+
- **Policy Caching**: Consider caching policy decisions for frequently accessed resources
|
|
383
|
+
|
|
384
|
+
## License
|
|
385
|
+
|
|
386
|
+
MIT License - see LICENSE file for details
|
|
387
|
+
|
|
388
|
+
## Contributing
|
|
389
|
+
|
|
390
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
391
|
+
|
|
392
|
+
## Support
|
|
393
|
+
|
|
394
|
+
For issues and questions, please open an issue on GitHub.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
auth_framework.py,sha256=f04c-SQc08welKxkM5FZCyzK4tPUBlSvgnK53AzelPk,25583
|
|
2
|
+
auth_framework_py-1.0.0.dist-info/licenses/LICENSE,sha256=fEQo_WbNFmwvkdPVEaouj8fjETVduIIlyE4JswauGv8,1070
|
|
3
|
+
auth_framework_py-1.0.0.dist-info/METADATA,sha256=R92RkDIP53zsdzDuiLT20VJUIy5aEdv7cKvaPE7qCKc,10477
|
|
4
|
+
auth_framework_py-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
auth_framework_py-1.0.0.dist-info/top_level.txt,sha256=hT7VyjO7F18T4StFhk5THndegwksxZoFeL6ZSmvq2WQ,15
|
|
6
|
+
auth_framework_py-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Parthiv Rawat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
auth_framework
|